CI and Architectural Gate Mechanics

Many spec-kitty gates pass a naive local run and only go red ~40 minutes into CI, because they live in shards a fast local run never selects. This page maps the durable, repeatable ones: what trips each gate, the symptom it produces, and the command that reproduces it locally so you can catch it before pushing.

It is a mechanics reference, not a status page — for the current red list and fast-drifting toggles see Known Current Friction Points; for what a red main means see Red Main and Release Readiness.

How CI is wired (so a local run can mirror it)

CI runs as a lean modular pipeline: a path router (ci-router.yml) decides which module and data shards are affected, per-module test jobs run (module-tests.yml / ci-modules.yml), and results aggregate with a coverage and diff-cover gate (ci-aggregate.yml); a nightly full run (ci-nightly.yml) and a fork-safe Sonar scan (sonar.yml) sit alongside. The trap is structural: a passing pytest <file> locally does not mean a gate will be green, because the gate that fails may run in a different shard, under a different marker selection, or only over paths your diff happened to touch.

Two habits defuse most of this class:

  • Reproduce a gate by running its exact selection, not just the test file — e.g. a marker-scoped shard runs -m "fast and not windows_ci", so a test missing the fast marker is silently deselected there even though it passes when you name it directly.
  • Before declaring a branch green, run the architectural gate files your change implicates over the rebased tip. Do not sweep the whole tests/architectural/ directory in mission work (NO_FULL_HEAVY_SUITES_IN_MISSION); CI runs the whole battery for you: the always-on architectural-fast job plus the two architectural-heavy legs on code and CI-config PRs.

Since mission ci-runtime-stabilisation (#5510), these things about that pipeline are worth knowing before you read a red check:

  • The battery has three parts. architectural-fast is always on (docs-only PRs included). Its roster of deterministic ratchet and census gates is held in .github/ci-module-registry.yml under special_tiers.architectural.fast_gate, with a per-file budget. architectural-heavy is one job key with a two-leg matrix (--battery-part 1/2 and 2/2). The three parts are file-disjoint and together cover the base selection exactly, which tests/architectural/test_battery_partition_proof.py proves statically.

  • CI-config changes select the heavy battery. The router path group ci_config (workflows, composite actions, scripts/ci/**, pytest.ini, pyproject.toml, Makefile, the registry and the shard timings file) gates the heavy battery and no other job.

  • A change to a path a gate reads selects that gate (#6994). Many heavy gates also read paths outside their code scope. The clock bans, for example, scan scripts/** and tests/**. Each such gate declares those paths once, under special_tiers.architectural.scan_scopes in the registry. scripts/ci/gate_selection.py derives the router group arch_scan_scope from them, and ci-router.yml must list exactly that union. A diff that matches only that group runs, in each heavy leg, only the gates whose scope it touches (scripts/ci/battery_scope.py writes the list and the plugin reads it from SK_BATTERY_GATES_FILE). A leg that holds none of them skips its run. A code diff still runs every gate, and so does a change to a file the battery imports outside tests/architectural/ (tests/conftest.py, tests/_support/**, helper scripts), which is battery code, not a scan scope. When a new gate reads outside its code scope, add its row to scan_scopes. tests/architectural/test_battery_scan_scope_routing.py proves the property for every declared gate and glob.

  • A declaration must be complete, and CI checks it (#7000). A row is only as good as the read it describes: when a gate starts reading a new root, a stale row lets routing miss it again. tests/architectural/test_gate_scan_scope_completeness.py extracts, statically, every repository-rooted read a gate makes -- _REPO_ROOT / "…", a chain built from it, a glob/rglob/iterdir/walk call, a literal git ls-files / ls-tree -- <path> pathspec, in the gate file and in every tests/** helper it imports (absolute or relative) -- and fails when neither the gate's scan_scopes row nor the battery code scope (src/**, tests/architectural/** and the router's own code groups) covers it. A read whose whole path is computed at runtime (_REPO_ROOT / rel) names no repository path and is recorded rather than guessed at; a partly computed read is checked at its literal parent. The only exemptions are reads that are not repository reads at all -- a synthetic probe path or an untracked scratch dir -- and each one carries a reason and goes stale-red. Adding a read therefore means adding a glob, not editing the check. The scan is static, so it has known blind spots (listed in the _gate_read_scope docstring): a gate that takes the root from a pytest parameter (repo_root) or from a tmp_path, a pathspec passed through a variable or through a helper whose call never names the root, and the reads of an imported tests/conftest.py are not seen. It narrows the #6994 defect class; it does not prove absence.

  • Corpus tests have one advisory owner. packs.yml runs the -m corpus suite as the advisory job built-in / -m corpus suite (advisory). The corpus tests that have no other blocking per-PR home run blocking in two router jobs: tests (corpus-blocking) (the modules that read the committed corpus) and tests (contract tools) (the contract tool unit tests). The router jobs tests (cli), tests (status), tests (consolidation) and tests (corpus) no longer exist; the cli, status and consolidation module rows in ci-modules.yml are now their only homes.

  • A nightly backstop runs the whole battery. ci-nightly.yml job architectural-backstop executes the full base selection with no partition plugin, so a green nightly means the battery ran (see the amendment to ADR 2026-09-26-1).

  • A ready_for_review run can skip on prior evidence. When the same workflow already went green for the identical tested key, the selection step is suppressed and the required gates post success. A re-run always executes. See Skip-if-green on ready-for-review below and the amendment to ADR 2026-09-23-1.

  • The merge queue runs the required checks again. ci-router.yml, ci-modules.yml and ci-quality.yml also trigger on merge_group, so the three required checks report on the queue's temporary gh-readonly-queue/main/... ref, which holds main plus the PRs ahead in the queue. The changed-files and prose-scan steps diff a queue run against github.event.merge_group.base_sha, so it is path-scoped like the PR run. Skip-if-green never applies there, the pr:* label guards read no labels, CI Aggregate skips queue runs (diff coverage was judged on the PR), and no optional workflow joins the queue. tests/ci/test_merge_queue_wiring.py pins this.

The rest of this page groups gates by the change that trips them.

Testing and marker gates

These fire when a test file's markers, or the coverage a test contributes, do not line up with the shard that is supposed to run it.

A test with the wrong marker contributes zero CI coverage

  • Trips it: a new test carries the wrong (or too few) pytestmark markers, so the shard meant to run it deselects it. Example: a test marked only unit when its shard selects -m "fast and ..." never runs in CI.
  • Symptom: the diff-coverage gate (ci-aggregate.yml) goes red on a source file that has a passing test — because that test never executed in CI. Named directly (pytest <file> --cov) it shows full coverage locally, which hides the problem.
  • Fix / repro: give the new test the same speed + domain markers its siblings use (typically fast plus the module's domain marker). Reproduce the shard's real selection locally — run pytest <dir> -m "fast and not windows_ci" — not just pytest <file>.

A file with two markers can fall between two shards

  • Trips it: a test file carries two markers whose CI jobs mutually exclude it (e.g. both distribution and e2e, where the e2e shard runs -m "not distribution" and the other shard ignores the e2e directory). The file reaches no gate at all.
  • Symptom: tests/architectural/test_marker_job_completeness.py goes red — each routed marker gained a carrier that no job selects. This runs in an architectural shard, so it only surfaces on CI.
  • Fix / repro: make sure the file is positively selected by at least one job. The minimal fix is a complementary pytest step in an existing job (e.g. add a -m "distribution and e2e and not windows_ci" pass over the e2e directory); the completeness gate auto-discovers it. Run pytest tests/architectural/test_marker_job_completeness.py before pushing.

A new test file must declare a marker

  • Trips it: a new tests/**/test_*.py file lands without a module-level pytestmark.
  • Symptom: an architectural marker-convention gate reds, insisting every test file declare some marker. It is distinct from the completeness gate above — and distinct from the marker-baseline gate below — so one can be green while another is red.
  • Fix / repro: add pytestmark = [pytest.mark.unit, pytest.mark.fast] (or the markers the file's siblings use) at module scope. When a fold adds or moves a test file, run the marker-related tests in tests/architectural/, not just one of them.

Moving a slow / stress / quarantine test can red the marker baseline

  • Trips it: relocating or renaming a test that carries a budget marker (slow, stress, quarantine). Its node-id changes, so the pinned baseline set gains a "new" node and loses the old one, and the growth check reads that as new slow surface.
  • Symptom: the marker-baseline architectural gate reds even though it is the same test with the same marker.
  • Fix / repro: do a documented one-for-one swap in the baseline — remove the old node-id, add the new one, and add a dated comment noting it is the same node relocated (no net-new slow surface). This is legitimate; a blanket baseline "update" that hides a genuinely new slow test is not. This gate is easy to miss because it is not in the fast shard.

New contracts/*.md YAML blocks need a round-trip skip marker

  • Trips it: the router job tests (corpus-blocking) (job key tests-corpus-blocking in ci-router.yml) runs tests/contract/test_example_round_trip.py, which walks every kitty-specs/*/contracts/*.md and collects each fenced ```yaml block as a contract-example case. A block in a non-legacy file that is neither executable nor marked as an illustration fails.

  • Symptom: test_contract_example_round_trip[...MISSING_FRONTMATTER] fails, reddening tests (corpus-blocking). The Packs advisory corpus job deselects this file, so a red here is blocking, not advisory. A local run of tests/ci + tests/architectural (the obvious blast radius for a CI change) does not cover tests/contract/, so illustrative YAML passes locally and only reds on CI.

  • Fix / repro: tag each YAML block with one of, as its first line inside the fence:

    • # pydantic_model: <Model> — executed against that model, or
    • # round-trip: skip: <reason> — non-executable illustration; the reason is mandatory (a bare marker still fails).

    Run pytest tests/contract/test_example_round_trip.py locally before pushing.

The pre-review gate can run the full suite and hang

  • Trips it: spec-kitty agent action implement WP## (and review, and move-task --to for_review) trigger the auto-scoped pre-review regression gate (src/specify_cli/review/pre_review_gate.py, baseline capture in review/baseline.py). When the auto-scope resolves broadly it runs effectively the whole suite with no path arguments.
  • Symptom: the CLI hangs with no output for a very long time, blocked waiting on the pytest child; parallel workers each doing this pin every core.
  • Fix / repro: set SPEC_KITTY_SKIP_PRE_REVIEW_GATE=1 on the lifecycle command (this is the canonical opt-out; it no longer reads the retired sync-disable vocabulary). The claim still succeeds — the WP moves to in_progress and the worktree is created — even if baseline capture times out. To make the baseline itself cheap, declare a fast review.test_command in .kittify/config.yaml (a local, uncommitted no-op command is legitimate; revert it at mission end). If a killed gate leaves a stale run-lock or orphaned pytest children, remove them before retrying. Rely on targeted per-WP tests plus CI for breadth, never the full suite in-session.

The architectural gate battery

Adding new src/ symbols and new test files trips a battery of architectural gates that each pass in isolation but only surface together in CI's architectural jobs: the always-on architectural-fast job and the two legs of architectural-heavy (a code-scoped job whose failure reds the router gate). Pre-run the targeted gate files before pushing. A useful invocation base for these is PYTHONPATH=src -o addopts="" so collection matches CI.

  • Dead-symbol gate (tests/architectural/test_no_dead_symbols.py): a new public symbol in a module's __all__ that no other src/ file imports fails. Fix by trimming __all__ to genuinely public symbols — test-only helpers stay importable but out of __all__.
  • Dead-module gate (tests/architectural/test_no_dead_modules.py): a new module with zero non-test src/ importer fails — even a python -m entry point launched by an argv string, because that is not a static import. Fix by wiring it: have the launcher reference the module by name (from . import _server_main; MODULE = _server_main.__name__) rather than a string literal, so it is statically reachable and rename-safe.
  • Clock call-ban (tests/architectural/test_clock_call_ban.py): time.time() / .now() / .utcnow() / .today() anywhere (including tests) outside src/kernel/clock.py fails. Import from kernel.clock instead.
  • Clock import-ban (tests/architectural/test_clock_import_ban.py, distinct from the call-ban): a raw import datetime / from datetime import ... anywhere outside src/kernel/clock.py fails. Import from kernel.clock, or add an exemption line under tests/architectural/_exemptions/.
  • Charter facade table (tests/architectural/test_charter_facades_reexport_offering.py): this self-discovers every src/charter/*.py, so a new charter facade that re-exports a charter.offering symbol in its __all__ must be registered in the facade table. It runs in an architectural shard, invisible to fast-shard local runs.
  • Environment-fragile absolute counts: integration jobs run uv sync --frozen --all-extras, which installs and imports more optional dependencies than a local checkout. An absolute module-count or import-count assertion (len(modules) <= N) passes locally and false-reds on CI. Assert a specific module's presence or absence, never an absolute count.

Fast roster, shard legs, and the partition proof

The battery base selection is tests/architectural with the marker expression not performance and not stress and not timing, minus the four files that other always-on lanes own (test_no_legacy_terminology.py, test_layer_rules.py, test_pyproject_shape.py, test_archive_root_byte_identical.py). It runs as:

Part Job (display name) Selection When
Fast architectural fast gates (ratchet/census, always-on) --battery-part fast: the registry roster every PR shape
Heavy leg 1 architectural battery (heavy, code-scoped) 1/2 --battery-part 1/2 code, ci_config or architectural path changes; for a diff that matches only arch_scan_scope, the leg's gates whose declared scan scope the diff touches. A prose-only diff (#4842) runs the same scoped selection when it touches a declared scan scope, and skips the battery when it declares none (#6999)
Heavy leg 2 architectural battery (heavy, code-scoped) 2/2 --battery-part 2/2 same as leg 1
Backstop Architectural battery backstop (nightly-only, #5510) the base, no partition flag nightly
  • Prose-only diffs and the declared scopes (#6999, operator ruling 2026-11-01). A PR whose whole Python diff is comments and/or docstrings is down-routed off the module matrix and every code shard (scripts/ci/prose_only.py, published as prose-scan's prose_only output). The heavy battery is exempted from that subtraction for a declared scan-scope match: prose-scan also publishes scan_scope_match (the arch_scan_scope globs, read through gate_selection.matches_declared_scan_scope), the battery if: runs when prose_only != 'true' or scan_scope_match == 'true', and the selection step passes the verdict on (battery_scope select --prose-only) so each leg runs only the gates whose scan_scopes declaration matches. A prose-only diff that matches no declared scope still skips the battery. The companion output, rather than a second reference to needs.changes.outputs.arch_scan_scope, keeps the parsed group set honest: deleting the group's own OR term still reds tests/architectural/test_battery_scan_scope_routing.py. Declared scopes only: for a prose-only diff the battery relies on the scan_scopes rows being complete (checked by tests/architectural/test_gate_scan_scope_completeness.py, #7016), so a gate that reads prose more widely than its declared scope is skipped for a prose edit outside that scope.
  • The roster is registry-held. Each entry in special_tiers.architectural.fast_gate.roster carries a path, a budget_seconds and a reason. A file earns a roster slot only if it is a deterministic static gate, so a new slow or CLI-round-trip file belongs in the heavy legs. The budgets are checked statically against the committed timings by tests/ci/test_battery_roster_budgets.py, never against wall-clock time.
  • Adding or moving a battery test file needs no workflow edit: the plugin scripts/ci/battery_partition_plugin.py assigns each file to exactly one part from the per-file timings in .github/ci-shard-timings.json. A file with no timing gets the median weight, and a timing-set mismatch prints a ::warning:: rather than silently using uniform weights.
  • A partition-proof red means a file is in two parts or in none. The proof (tests/architectural/test_battery_partition_proof.py) evaluates the three literal --battery-part commands, so fix the roster or the timings, not the proof.
  • Worker count is a literal -n 4 in every CI battery command; see CI worker policy.

Skip-if-green on ready-for-review

When a draft PR is marked ready for review, the head SHA has usually already been tested. The selection jobs (router changes, CI Modules generate-matrix, Packs changes) therefore run scripts/ci/green_match.py decide first. It suppresses the path-filter step, and so every path-gated job, only when all of these hold:

  • the event is a pull_request with the action ready_for_review, on the first attempt of the run;
  • a completed, successful run of the same workflow exists for the same head SHA;
  • that run carries a non-expired marker artifact ci-tested-key-pr<N>-base-<sha> for the identical tested key (workflow file, PR, head SHA, first parent of the merge commit the run tested).

The required contexts router gate and CI Modules gate still post success. On a skip run the always-on lanes and prose-scan still run, and tests (docs) still runs on a prose-only PR.

  • Symptom of a surprise skip: a ready_for_review run finished with every path-gated shard skipped, and the step summary names a matched run.
  • Escape hatch: re-run the workflow. Attempt 2 always executes.
  • Never suppressed: push, workflow_dispatch, workflow_call, schedule, any other pull_request action, a failed, cancelled or in-progress prior run, a moved base, and any lookup error (the helper runs normally and warns).
  • CI Aggregate re-points to the matched CI Modules run after re-verifying it, and fails with "re-run CI Modules to execute" if it cannot. Re-run CI Modules, not Aggregate.

Moving or renaming a symbol that is dead-symbol allowlisted

The dead-symbol allowlist lives in tests/architectural/dead_symbol_allowlist.yaml and is keyed by (module, name): module is the module whose __all__ declares the name. No body hash is stored (ADR 2026-10-01-1).

  • Editing the body of an allowlisted symbol costs nothing: no allowlist edit.
  • Renaming or moving one makes the gate report the old entry GONE (for a move, with a "probably moved to X" hint) and the new location as an offender. Update module: or name: of that entry in the YAML file.
  • Adding an entry needs a declared category_*, a rationale (its own or inherited from the category -- see declaring a new category), an issue where the category sets requires_issue: true, and a raised test_no_dead_symbols leaf in tests/architectural/_baselines.yaml (allowlist_entries, or widened_grandfathered_470 for the widened section). Both leaves are shrink-only caps. Try wiring the symbol, dropping it from __all__ or deleting it first.

Never weaken the gate to get past it.

The ruff format exclude ratchet has a twin

[tool.ruff.format].exclude in pyproject.toml is a large, shrink-only formatter-debt allowlist (that is why a whole-repo ruff format --check . passes on main — excluded files are skipped). Two distinct architectural tests guard it, and checking only one misses failures:

  • tests/architectural/test_ruff_format_enforcement.py — asserts ruff format --check . exits 0 and every exclude entry names a live file.
  • tests/architectural/test_ruff_format_exclude_ratchet.py — asserts every exclude entry still genuinely reformats, and that the entry count stays under a shrink-only ceiling.

Two changes trip the ratchet, and both are caught only at the integration/merge gate, not by a per-WP subset run:

  1. You run ruff format on an excluded file (making it clean) → its entry no longer reformats → red.
  2. You delete an excluded file's source → its entry names a missing file → red.

Fix both by removing the now-dead entries (this only shrinks the list, so no ceiling bump is needed). Remove entries only for files your branch actually formats or deletes.

A scanner that swallows a parse failure reds the parse fail-closed gate

tests/architectural/test_scanner_parse_fail_closed.py scans every module under tests/ for a construct that can swallow a failure to parse source: an except catching SyntaxError (or a subclass) that does not always re-raise, a bare, Exception, BaseException, AssertionError or aliased handler around a parse call, contextlib.suppress(...) around one, or finally: return. A scanner that skips a file it cannot parse drops it from its census, so the gate it feeds passes over code it never read.

It trips on harmless-looking code too — for example try: parse_file(p) except AssertionError: continue. Repro: pytest tests/architectural/test_scanner_parse_fail_closed.py. Fix it by reading and parsing through tests/architectural/_ast_scan.py (read_and_parse / parse_file / parse_source / read_source, which fail closed naming the file). Only when the input is deliberately allowed to be unparseable, add a rationale row to its _ALLOWED_SWALLOWS ledger; the ledger is checked for exact equality, so remove the row when the handler goes.

Router path filters are the tail of a guarded SSOT chain

The ci-router.yml path-filter globs are not a free-standing authority — they are the verbatim tail of a three-link single-source-of-truth chain: tests/release/ci_retirement_scrub.json (the SSOT) → .github/ci-module-registry.yml → ci-router.yml filters. Each link is pinned by an architectural transcription guard (tests/architectural/test_module_shard_registry.py, tests/architectural/test_ci_router_transcription_guards.py).

  • Trips it: adding a glob to a router filter group alone.
  • Symptom: the router-transcription guard reds; cascading the glob to satisfy it then reds the registry-verbatim guard unless the registry roots change too — and the registry roots drive the per-module test matrix, so this is a real CI-matrix change, not a config tweak.
  • Fix: to change what a module owns for routing, edit the scrub SSOT and cascade verbatim through all three links. To route test directories to modules without touching the chain, derive the mapping from the registry inventory in the gate-selection logic (scripts/ci/gate_selection.py) rather than hand-authoring a second map.

An extra narrow lane for an out-of-matrix test

Not every test tree gets a module row. When a single out-of-matrix test file must run per PR on a specific diff — the precedent is mission_state (#5864) and the runtime-home path-literal guard (#6879) — add a non-src router group plus one job, not a registry row:

  • declare the group's globs in tests/release/ci_retirement_scrub.json non_src_router_groups and transcribe them verbatim into the filter block, changes.outputs and the job's if:, plus a row in tests/architectural/test_no_duplicate_suite_execution.AUTHORIZED_PER_CHANGE_SUITE_JOBS and the router-gate needs: list;
  • spell the globs **/-prefixed (e.g. **/specify_cli/status/**). A src/-spelled glob makes the group src-backed, and every src-backed group must also be a module-registry row (test_gate_selection_authority.py), so the lane becomes a CI-matrix change;
  • keep it out of the FR-004 unmatched union and architectural-heavy's if: — both are derived from the src-backed group set and the transcription guards red if they diverge;
  • do not name a directory that no src-backed group owns (e.g. src/specify_cli/paths/**). Such a directory trips the fail-closed catch-all and already runs every job, this lane included; naming it turns that sensor off and narrows the run to your lane.

Trips it: adding the globs to the filter block only, or to a src-backed group without a module row. Symptom: the transcription guard in tests/architectural/test_ci_router_transcription_guards.py, or test_select_modules_full_mode_selects_all_registry_modules, or the duplicate-suite ledger.

Stored test-universe collection

Several architectural gates need the full list of collected tests with their markers (the "test universe"). One real pytest --collect-only of the tree takes tens of seconds, and before this change the first test that needed the list collected it inside its own setup while four xdist workers competed for the same CPUs. The list is now collected once per job, in a step before pytest, and stored on disk; the tests read the stored copy.

What it does and does not do. A first run of a checkout still collects once in each job that consumes the list: the architectural battery leg whose files hold a collecting test (today both legs hold one — test_fast_tier_marker_completeness.py in 1/2 and test_same_tier_uniqueness.py in 2/2 — but a leg only collects when a selected gate holds one, see below), and the ci module shard. The change moves that collection out of test setup and out of worker contention. A re-run of the same checkout restores the stored list from the CI cache instead of collecting. On one developer machine a collection took 62.5 s and reading the stored copy 0.33 s. On CI the pre-test step took 89 to 233 s on a first run and 0.3 to 0.6 s when restored from the Actions cache; the three runs are in kitty-specs/shared-collection-and-shard-recapture-01M42V58/evidence/ci-measurements.md.

Where it lives and what it is keyed on

  • The code is tests/architectural/_universe_store.py. collect_universe() in tests/architectural/_gate_coverage.py calls it; its signature and return value are unchanged.
  • The store is .pytest_cache/universe-store/, which git ignores. It holds one record file, <key>.json, plus a lock file. Writing a record deletes every other record file.
  • The key is a digest of the committed tree (not the commit), the interpreter, the platform, every installed distribution with its version, and the environment variables named SPEC_KITTY_* plus PYTEST_ADDOPTS. The variables the test session sets for itself are listed in ENV_EXCLUDED_NAMES and left out, so a pre-test step and a test compute the same key. The key also overlays the operator env file (.kitty.env) through the product's own loader. A machine whose home-level .kitty.env sets SPEC_KITTY_* variables may therefore see local reuse not happen, because pytest isolates HOME; CI is unaffected.
  • A record is stored only when the checkout is still the one the key was computed for after the collection: if a commit, a branch switch or an edit happened meanwhile, the caller still gets the universe it collected, the report line says not stored: the checkout changed during the collection, and the pre-test step fails on that line.
  • A record is read only when its schema, key, record count and origin (commit and tree) all match and it holds at least 1,000 records (a corruption guard, not a ratchet). Anything else is treated as absent and replaced after a fresh collection.
  • For one key, one collection runs at a time on a machine. Other callers wait on the lock and then read the stored copy.

When the store is bypassed

The store is not read or written, and the caller collects for itself, when:

Reason in the report Condition
dirty-checkout git status --porcelain is not empty. Untracked files count, because an untracked test file changes what is collected.
root-override the caller passed a repo_root (a patched root).
unsupported-platform the platform is not Linux or macOS. The same reason, with a detail, is reported when the store directory or its lock cannot be used.
git-unavailable git cannot describe the checkout.
env-file-unreadable an operator .kitty.env exists but cannot be read, so the environment variables of the key are unknown.

The per-test budget on a collecting gate

A bypass is not free: the caller now pays the whole collection inside its own test setup. One pytest --collect-only of the tree costs 89 to 233 s on a first CI run (the measurements above), 247.7 s on the machine that filed #7052, and 90 to 422 s on a shared development VM depending on load; the post-test check step cannot see that cost, because it only reads the report lines.

The suite's per-test timeout is a 240 s runaway-hang backstop (pytest.ini, #3143), which is at or below a legitimate collection. A gate whose fixture setup may collect therefore carries its own @pytest.mark.timeout(...) budget, derived from the collector's own ceiling (tests/architectural/_gate_coverage.COLLECT_TIMEOUT_S, 900 s) plus an allowance for the work that runs beside the collection. pytest-timeout installs its timer over setup as well as the call (timeout_func_only is unset), so the marker also bounds the module-scoped fixture setup that triggers the collection.

The rule is the one pytest.ini states: a legitimate slow test gets a per-test marker, the global default is not raised. The budget must never be below COLLECT_TIMEOUT_S — a smaller one reports a real collection as a hang, which is exactly #7052: on the architectural battery leg the whole-repo collection was killed at 240 s and 16 tests in tests/architectural/test_same_tier_uniqueness.py errored at once while check still printed "Reuse held" (an interrupted request writes no report line).

Two invariants keep the two numbers together, both in that test module: every test whose fixture closure reaches universe must carry a budget of at least COLLECT_TIMEOUT_S, and _gate_coverage.py may not pass a bare numeric timeout to a collection subprocess. A test that never collects keeps the 240 s default.

Giving the tests room does not make a broken reuse green. On CI a bypass inside the pytest step is still a red at the check step (Reuse broken: … collected / … bypassed (dirty-checkout)), with the reason and the dirty paths in the job summary; the budget only stops that red from arriving as a timeout storm that names no cause.

The report line

When SK_GATE_REUSE_REPORT names a file, each call of collect_universe() appends one JSON line to it: outcome (reused, collected or bypassed), reason (no-record, invalid-record, origin-mismatch or a bypass reason from the table), key, caller (the pre-step is prestep, a test is its file path), seconds, and when present detail and dirty_paths. A detail starting with not stored on a collected line means the fresh collection could not be written back.

The pre-test step and the post-test check

python -m scripts.ci.collect_universe_prestep <command> runs from the repository root checkout:

Command What it does Exit status
key [--with-commit] Prints the collection key. With --with-commit it prints <key>-<commit>, which is the CI cache key. 0; 2 when the checkout is dirty or git cannot describe it
collect Calls collect_universe() once and prints the report line. 0 only when the line is reused, collected with the record stored, or an unsupported-platform bypass without a detail; 1 for anything else, including a record that could not be stored
check Reads the report file and writes a table to the job summary. 1 when the pre-test line is not acceptable, when a later request in the job collected or was bypassed (other than root-override) after the pre-test step stored or reused a record, or when a report line is malformed; 0 otherwise
consumers --battery-part <part> [--gates-file <path>] Prints true when the battery partition holds a test file that calls collect_universe() (found by scanning tests/architectural/test_*.py), false otherwise. --gates-file (default $SK_BATTERY_GATES_FILE, the variable the battery plug-in reads) intersects the question with the gates the diff selected for this leg (#7001), so a scope-only leg whose selected gates hold no consumer skips the pre-step instead of collecting a universe it never reads; no file means the whole partition. 0 with an answer; 1 and no answer for an unknown part, an unreadable gates file, or any other failure
compare Collects once with the store bypassed and diffs the result against the stored record. 1 on any difference or when no usable record exists

collect and check fail when the pre-test step could not store a collection. Only an unsupported platform is a legitimate fallback: the job then collects for itself, check prints a fallback note and exits 0.

Where it runs

  • ci-router.yml, job architectural-heavy (a leg runs these steps only when its partition holds a collecting test; the battery partition moves with the timings, so each leg first runs consumers --battery-part <leg> in a step with id select, and a failed answer fails the job). Steps in order: compute the key, restore the store, collect, save the store, run the battery (the pytest command line is unchanged), check with if: always(). The decision is intersected with the leg's own selected-gates file (#7001): consumers is asked with --gates-file bound to the same steps.scope.outputs.gates_file the battery step reads as SK_BATTERY_GATES_FILE. A scoped leg (#6994) runs a handful of gates, so it needs the universe only when one of those gates collects it; when none does, every universe step is skipped. A whole-leg run passes an empty path and keeps the whole-partition answer.
  • module-tests.yml, job test. The same steps, but only in per-PR mode and only on the shard whose selected test list contains tests/ci/test_corpus_blocking_home.py::test_every_corpus_test_has_a_blocking_per_pr_home, the test that takes the live_universe fixture. The ci module runs as one shard (shard_count: 1 in .github/ci-module-registry.yml), so that shard holds the test. tests/ci/test_ci_workflow_prestep_shape.py pins that the test still exists. Full-mode warm-up may rewrite uv.lock and leave the checkout dirty, so it skips the step.
  • The cache key is universe-<key>-<commit>. It includes the commit because a stored record is valid for one commit only and a CI cache key never changes once saved; a key without the commit could keep a record that a later commit rejects, and the save step is skipped after a restore hit. Only an exact key restores (there are no restore-keys), and the save runs before pytest, so a leg that goes red on a test can still be re-run warm.
  • The shard list lives in the runner's temp directory, not in the checkout. An untracked file there would make the checkout dirty for the rest of the job.
  • None of the collect, check or compare steps sets continue-on-error. The shape test pins that, because it would turn the check into a summary.

Nightly equivalence job

ci-nightly.yml job universe-equivalence runs collect and then compare in one job. It shows that collection is deterministic and that a stored record round-trips to the same list as a fresh collection. It does not show that a record restored from the CI cache on another runner equals a fresh one. It is part of nightly-summary.

Run it locally

# The key of a clean checkout, and the CI cache key (commit appended)
python -m scripts.ci.collect_universe_prestep key
python -m scripts.ci.collect_universe_prestep key --with-commit

# Collect once and store it, then run a gate against the stored copy
export SK_GATE_REUSE_REPORT=/tmp/universe-reuse.jsonl
python -m scripts.ci.collect_universe_prestep collect
PYTHONPATH=src python -m pytest tests/architectural/test_fast_tier_marker_completeness.py -q
python -m scripts.ci.collect_universe_prestep check

# Prove the stored copy equals a fresh collection
python -m scripts.ci.collect_universe_prestep compare

# Ask a leg about its own selected gates instead of its whole partition (#7001)
printf '%s\n' tests/architectural/test_dead_builtin_doc_paths.py > /tmp/gates.txt
python -m scripts.ci.collect_universe_prestep consumers --battery-part 1/2 --gates-file /tmp/gates.txt
# -> false (that gate never collects the universe), so every universe step is skipped
printf '%s\n' tests/architectural/test_fast_tier_marker_completeness.py > /tmp/gates.txt
python -m scripts.ci.collect_universe_prestep consumers --battery-part 1/2 --gates-file /tmp/gates.txt
# -> true (this one does)

What a scope-only leg skips

A pull request that touches only a path a few gates declare as their scan scope runs only those gates (#6994). Until #7001 its legs still asked their question of the whole battery partition, whose only two collecting tests live one per leg, so both legs answered "collect" for a diff that selected three or four unrelated gates. The intersection removes that:

Diff Selected gates (1/2 · 2/2) A collecting one among them? Universe steps
docs-only (docs/**) 3 · 3 no all five skipped
docs/adr-only (docs/adr/**) 3 · 4 no all five skipped
tests-only (tests/**) 19 · 23 yes (both legs) unchanged
a src/** change — full leg (mode=full) unchanged

Measured on the development host (a shared machine with heavy concurrent load, so the absolute numbers are pessimistic): a cold collection took 396.7 s and a warm reuse 0.55 s, while the decision itself — partition, AST scan and the one file read — costs about 1.5 s. The published CI range for a first collection is 89–233 s, so the ordering holds on CI even though this host is slower. A cold collection is the common case for a new commit: the cache key is universe-<key>-<commit>.

Further cheap wins, noted but not taken here:

  • A re-run of the same commit restores the store from the Actions cache instead of collecting, because the key does not include the partition. On a first run the two legs still collect in parallel: each job populates the cache only when it finishes, so the second leg cannot see the first one's record in time. Making the two legs share one collection would need a job-level split, not a cache change.
  • A scoped leg still pays checkout and uv sync --frozen --all-extras (about 1–2 minutes) for a handful of gates; narrowing that sync is a separate change.
  • The decision could reuse the partition it already computed for the plug-in instead of recomputing it, saving well under a second.

A dirty checkout (including an untracked file) is bypassed on purpose; commit or stash first to see a reused line. To force a fresh collection, run rm -rf .pytest_cache/universe-store; there is no bypass environment variable, and pytest --cache-clear does not remove that directory.

Scheduled shard-timings recapture

.github/ci-shard-timings.json records, for each registry module, the exact node ids its shard collects and how long each took (module_test_node_durations, the identity-keyed authority scripts/ci/shard_select.py weights a shard by), their ordered projection (module_test_durations), the count and the summed seconds. The entry drifts whenever a test is added, removed, renamed or replaced. The workflow ci-shard-recapture.yml (schedule 04:41 UTC daily, plus workflow_dispatch, and only on the primary branch (main)) refreshes the drifted entries for every registry module and proposes the change as a pull request. The code is scripts/ci/recapture_shard_timings.py.

The job recapture-shard-timings runs the script in three steps:

Phase Holds the token What it does
detect yes Asks gh whether a recapture pull request is open on the proposal branch.
capture no Finds drifted modules, captures them, and writes the result (drifted, captured, failed, deferred) as a step output and a job-summary table.
publish yes Pushes the refreshed file and opens or refreshes the pull request.

The capture phase runs test code for a long time, so it refuses to run when CHARTER_SHARD_RECAPTURE_TOKEN is in its environment and starts every subprocess without any token variable.

Finding drift. A collection-only pass (pytest --collect-only, over the same directories and marker expression the producer uses) lists each module's node ids for every module whose committed provenance is valid. A module is drifted when its provenance record is missing or not valid, when its collected id set differs from the committed module_test_node_durations (a rename at an unchanged count included — a count-only check sees nothing), when the entry is still the retired positional shape with no node ids (a recapture is what migrates it), or when its committed tables disagree with each other. A reordered but identical id set is not drift: the weights are keyed by id.

Capturing. Each drifted module is captured by python -m scripts.ci.capture_shard_timings --module <m> --write in its own subprocess, longest-untouched first.

Time budget. The workflow passes --budget-seconds 4200, counted from the start of the capture phase, so the count-only passes are inside it. No count pass and no capture starts once the budget is spent; the modules left over are reported as deferred, never as clean, and the next run recomputes drift (no state is kept). A count pass is capped at 300 s per module, a capture at 2,100 s, and the job at 130 minutes (70 minutes to start work, up to 35 for the last capture and 25 for setup, battery refresh and publication).

The valid-capture rule. A capture is kept when its provenance record carries the run id the script passed in, the pytest exit status was 0 or 1, at least one test was measured, and the committed slice is structurally complete — exact, unique node ids, finite non-negative durations, and every derived table (ordered projection, count, summed seconds, unique_tests_measured) agreeing with the keyed table. Anything else (a crash, a collection error, a timeout, an unreadable file, an entry whose ids and durations do not line up) puts the committed file back byte for byte and reports the module as failed; the other modules still run. A capture whose pytest exit status was 1 must include a failed runtest report; it keeps its complete durations but is reported as captured with failing tests, never as a clean capture. An exit of 1 without a failed test report is rejected as possibly incomplete. A count pass that fails or times out also reports its module as failed. The script exits 1 when any module failed.

Proposal. The branch is ci/recapture-shard-timings, and GitHub allows one open pull request per head branch.

  • With no open pull request, publish force-pushes the branch and opens one.
  • With an open pull request, capture starts from the proposal's timings for the modules it already refreshed, so each run continues the work, and publish adds a follow-up commit with a plain push. It never forces.
  • A push the token is not allowed to make (HTTP 403) fails the step with a message naming the secret. That is a token problem code cannot fix; see #5624.

The red window. Per pull request, a drifted count is a non-blocking ShardTimingsDriftWarning. The job strict-shard-timings-check in the same workflow runs tests/architectural/test_module_length_agreement.py with SPEC_KITTY_STRICT_SHARD_TIMINGS=1 and has no needs edge to the recapture job. So from the first scheduled run that sees a drift until the proposal merges, the strict check is red on each scheduled run. That window is accepted: the red is the alarm and the proposal is the fix.

To run it by hand, capture without --write only reports drift:

python -m scripts.ci.recapture_shard_timings capture --module charter

Docs-freshness and registration gates

Adding or moving a docs/** page trips several documentation gates that draw from separate committed catalogs; fixing one leaves the others red.

The prose of those pages is linted by a separate, always-on job: see docs-lint.

A new docs page needs triple registration plus a description band

  • Trips it: a new docs/**/*.md page that is not registered everywhere.

  • Symptom: the docs build and docs-freshness checks red with a mix of DOCS-INDEX-DRIFT, INVENTORY-INCOMPLETE / INVENTORY-LOCKFILE-DRIFT / LEAK-MISSING-INVENTORY, and a description-length failure. The "committed index" in a drift error is the machine-generated retrieval index, not the curated index.md, which is why updating only index.md is not enough.

  • Fix / repro: register the page in its curated index and satisfy the frontmatter band; leave the generated surfaces alone:

    1. Curated section index — hand-add the page to its section's index.md (satisfies the index-completeness rule).
    2. Page inventory, retrieval index, ADR era table rows — do not regenerate docs/development/page-inventory.yaml, docs/development/docs-retrieval-index.yaml or the era table rows in the PR. They are regenerated on main after merge by the ci/regen-docs-index bot pull request (docs-index-regen.yml, scripts/ci/regen_docs_index.py), and the docs-lint guard (scripts/ci/check_generated_docs_untouched.py) refuses a PR that edits them. Until the bot PR merges, INVENTORY-INCOMPLETE, LEAK-MISSING-INVENTORY, INVENTORY-LOCKFILE-DRIFT and DOCS-INDEX-DRIFT report the new page as warnings, and spec-kitty docs query does not find it yet. The per-PR content check is tests/docs/test_docs_index_regenerates_cleanly.py: the generators must run cleanly over the tree you merge.
    3. Frontmatter description — a hard 50–180 character band, enforced by scripts/docs/description_length_check.py and the docs SEO tests.

    Verify with PYTHONPATH=. python scripts/docs/check_docs_freshness.py --ci (expect errors=0; staleness and external-URL link-health warnings are fine).

    ADRs need only the ADR file: the bot adds the era index-table row (freshen_adr_inventory --all). An ADR's frontmatter needs title:, date:, updated:, and a 50–180 character description.

docs-lint

  • Trips it: a typo or British spelling in docs prose, or a changelog [Unreleased] entry that breaks the house style. Any PR can trip it, because the job has no path filter.
  • Job: docs-lint in .github/workflows/ci-router.yml (display name "docs lint (spelling + changelog style, always-on)"). It is listed in the router-gate job's needs, so a red docs-lint blocks the merge. It is blocking: neither command's exit code is masked.
  • Why always-on: its inputs span docs/**, README.md, packs/built-in/**/*.md, pyproject.toml (the [tool.codespell] table and the pinned codespell) and uv.lock. No single router path group covers all of them, so a path filter would let some edits skip the check.
  • What it runs: uv sync --frozen, then python -m scripts.docs.check_spelling (typo, US-spelling and Unreleased passes) and python -m scripts.docs.check_changelog_style. It runs no pytest. The planted-violation and live-tree tests for the two scripts (tests/docs/test_check_spelling.py, tests/docs/test_changelog_style.py, tests/docs/test_docs_spelling_live.py) run in the tests-docs job.
  • Exit codes: 0 clean; 1 findings; 2 the check could not run or could not prove it looked at anything: a usage error, codespell missing, no [tool.codespell] table in pyproject.toml, an unreadable changelog (both scripts), a typo or us pass that scanned 0 files, or an unreleased scratch file that a [tool.codespell] skip glob would drop. A changelog with no [Unreleased] section is 0 (nothing to check). The changelog guard also exits 0 on warnings alone.
  • Scope: Markdown only. docs/archive/, docs/reports/, docs/plans/ and the generated CLI reference (docs/api/cli-commands.md) are skipped through the [tool.codespell] skip list, so a finding never comes from them. A relative --changelog resolves against the repository root in both scripts (against --repo-root for the spelling check), not the current directory.
  • Symptom: the job log lists one finding per line as path:line: [rule] ... — fix, ending in a summary line. It is not one of the docs-freshness or registration errors above. The guard's rules include bullet-marker (a column-0 * or + bullet), conflict-marker (a leftover <<<<<<<, ||||||| or >>>>>>> line) and banned-token checks on prose between a ###/#### heading and its first bullet; the how-to lists every rule and the false-positive shapes (WP-D-1, SC-2086, DEFAULT-branch) to put in backticks.
  • Fix / repro: make docs-lint from the repository root reproduces the job (it runs the same two commands). The how-to has the steps: run the checks, allow a word, exempt a quoted literal, and read a failure. Never run bare codespell, which scans the whole repository.
  • Owning files: scripts/docs/check_spelling.py, scripts/docs/check_changelog_style.py, and the [tool.codespell] table (skip, ignore-words-list) plus the exact codespell==2.4.3 pin in pyproject.toml. The pin is exact on purpose: a new codespell release changes its dictionary, so bump it deliberately, together with the docs it newly flags.

Touching any docs path can surface a pre-existing docs-test flake

  • Trips it: touching any docs/** path flips the docs-test path filter, which can run a tests/docs/ test that never ran on your branch before.
  • Symptom: a docs test you did not write reds — often a fragile single-cold-measurement performance assertion on a contended runner. Do not misattribute it to your diff.
  • Fix / repro: classify it as pre-existing (reproduce on the merge base), then fix perf flakes at the root with warm-run discipline (discard the cold pass, assert the fastest of several warm runs); never retry-to-green.

Where the docs site deploys from

docs.spec-kitty.ai is deployed by .github/workflows/docs-pages.yml from this repository's main. The workflow runs on pushes to main that touch docs/** (and a few docs-tooling paths) or on manual dispatch. It first probes whether GitHub Pages is configured and skips cleanly if not; the deploy job itself runs only for spec-kitty/spec-kitty on refs/heads/main. A workflow_dispatch on another branch builds but does not deploy.

The GitHub Pages docsite build needs PYTHONPATH

  • Trips it: the docsite deploy (docs-pages.yml) runs Python post-processing steps that import from kernel.clock import .... kernel is a src-layout package, so a step that runs raw python3 with no PYTHONPATH and no installed package raises ModuleNotFoundError: No module named 'kernel'.
  • Symptom: the DocFX render succeeds but the Python post-step fails, the deploy job is skipped, and merged docs/ changes never go live. Footgun: docs-pages.yml has no pull_request trigger, so a PR that changes it is not exercised by PR CI.
  • Fix / repro: ensure the build job exports PYTHONPATH: .:src at job scope (job, not step — several post-process modules import each other and the scripts.docs.* package). Verify a change to this workflow via workflow_dispatch on the branch, where the build runs and the deploy stays skipped off main.

Accept-to-consolidate gates

The accept → consolidate close-out has several gates that block silently until fed exactly what they expect.

The issue-matrix verdict gate blocks WP approval

  • Trips it: move-task <WP> --to approved when a gating #NNN reference in the mission's artifacts has no verdict row in issue-matrix.json. Not every reference gates: context-only and PR/commit references are recorded but do not block.
  • Symptom: the approval is blocked, and the error lists the missing rows.
  • Fix: seed the matrix up front with spec-kitty agent issue-verdict. The verdict values, which references gate, and the evidence-token rule for deferred-with-followup are documented once, in the Issue-Matrix Verdict Reference. The per-WP reviewer should refuse to fabricate verdicts for unfixed issues; the orchestrator fills them honestly and flips in-mission rows to a terminal verdict at accept.

The other close-out gates

  • Lane branches reject any kitty-specs/ change ("kitty-specs/ changes are not allowed on lane branches"). Before moving a task to for_review or approved, restore the planning artifacts from the planning branch and commit. The review-claim step can re-dirty them, so re-clean between claim and approve.
  • Record acceptance-matrix.json verdicts with spec-kitty agent acceptance-verdict; do not hand-edit the file. spec-kitty agent acceptance-verdict --mission <m> --criterion FR-001 --result pass --evidence <ref> writes the criterion through the matrix seam, commits it, and recomputes overall_verdict. Its negative-invariant mode (--negative-invariant <id> --description ... --verification-method grep_absence|route_check|custom_command --verification-command ...) registers an invariant through the same seam and runs it at once (--no-execute registers only). accept also fails on a dirty tree, so commit or clean the dossier state first.
  • spec-kitty consolidate refuses a dirty coordination worktree — commit the status files and clear ignored .kittify/ state in the coordination worktree, then --resume.
  • The graph-manifest check verifies the pack manifest, not just the graph files. Regenerating the reference graph alone leaves the manifest stale; run the full spec-kitty charter pack regenerate-graph, which regenerates both.
  • The post-merge stale-assertion analyzer flags test string-literals tied to removed code even when the test still passes. Confirm the test is green, then refresh the docstring or literal.

Contracts gates

The mission-status and mission-write contracts, their tools and the Mission Status API service live in spec-kitty-events (contracts/, service/; ADR 2026-10-29-1), which builds, checks and releases them on its service-v* line. Core keeps only the five handoff fixtures contracts/fixtures/fixture_0*.json and pins one release in pins/read-service.source.yaml (#6915). The contract tests in core read the pinned contract-bundles, contract-tools and conformance-fixtures extras, which make sync-services (make dev-setup fetches the extras only) and the .github/actions/sync-services action fetch and digest-verify; tests/pinned_service_assets.py fails closed when they are missing.

What runs where

  • tests (contract tools) (tests-contract-tools in ci-router.yml, a required check through router-gate): pytest -m "corpus and not windows_ci" tests/contract minus three --ignore modules, after the sync-services action. It runs when the corpus filter group matches (contracts/fixtures/**, pins/**) or the contract_tools group matches (tests/contract/**, pins/**, scripts/sync_services.py, the sync-services action, tests/pinned_service_assets.py, the lock and pytest configuration, tests/conftest.py and the src/ modules the mission-status reference reader imports, spelled **/<module path> so the group stays non-src). Unmarked tests/contract modules run in the module matrix.
  • The reality check and its payload helper (tests/contract/test_mission_status_reality.py and tests/contract/test_mission_status_payloads.py) read the committed corpus and run in tests (corpus-blocking) (tests-corpus-blocking), together with the example round trip, tests/integration/test_mission_review_contract_gate.py, tests/specify_cli/migration/test_corpus_wp_snapshot_parity.py and one performance class. The modules of both router jobs are deselected from the Packs advisory corpus run, so each runs once. tests/ci/test_contracts_routing.py fails when a corpus-marked tests/contract module is run by neither router job, or by both, and when Packs deselects a module that does not exist. The reality check reads the repository's primary worktree, not the checkout that imports it (oracles.primary_worktree_root; #6966). The Mission resolver answers a coord-less or un-backfilled Mission's read directory with the primary worktree, so a check rooted at the checkout would enumerate one kitty-specs/ tree and read Missions out of another whenever the checkout is a linked worktree — the drift read then refuses the whole report with 500 drift_scan_unreadable and the Project read reports an outside root fallback the oracle never derives. From a git worktree the check therefore judges the primary worktree's corpus, so a corpus change made in a linked worktree — committed or not — is not seen there; it is covered by the corpus-blocking CI job on the PR (whose checkout is the primary worktree) and after the change lands. From the primary checkout (CI, a plain clone) the root, the corpus and the pass counts are exactly as before. When git cannot name the primary worktree (no git, not a repository, an empty or failed listing) the checkout root is used and the linked-worktree mixing can return — the module must still collect there.
  • The nightly corpus compare (mission-status-corpus in ci-nightly.yml) starts the pinned native read-service binary and compares it with the Python reference over the whole corpus, with no exemption list (scripts/ci/mission_status_corpus_compare.py).
  • Pins. tests/ci/test_service_pins_gate.py checks every committed pin; release.yml fetches and checks the service pins before it builds (scripts/sync_services.py --service all --check) and asserts the wheel carries the pins and no service binary.
  • Absence. tests/architectural/test_events_cutover_absent.py (on the always-on architectural-fast roster) fails if services/, the removed contracts/ modules or the retired contracts.yml, contracts-release.yml and mission-status-api.yml workflows come back, or if a CI, build or script file names them.

A reader or test-only change selects no corpus job

  • Trips it: a pull request that edits a status reader the reality check imports (anything under src/specify_cli/status/**), the reality check's own test files or fixture, or a Mission's meta.json or status.events.jsonl. The router's corpus group matches contracts/fixtures/** and pins/** but none of those, so tests (corpus-blocking), the job that runs the reality check, is not selected. An edit under tests/contract/** selects tests (contract tools) through the contract_tools group, which does not run the reality check. The Packs run on a push to main does not help: packs.yml passes --deselect for every tests/contract module that runs in a router job, so the built-in-corpus-suite never runs them.
  • Symptom: a green pull request. The drift is first seen by the nightly run (ci-nightly.yml, job interpreter-matrix-shard-4, which runs tests/contract, scheduled daily at 03:17 UTC), up to a day later. This is an accepted risk, tracked in issue #5623: status-reader drift is caught by the nightly run, not on the pull request. It was seen on a real pull request, which needed a manual router run.
  • Fix / repro: run the check before pushing: PWHEADLESS=1 .venv/bin/python -m pytest -q tests/contract/test_mission_status_reality.py, and dispatch the router in full mode on the branch: gh workflow run ci-router.yml -f mode=full --ref <branch>.

Mission Status API workflow

Retired with core's copy of the service (#6915): the Mission Status API's build, native legs, lock-interop job and release run in spec-kitty-events (.github/workflows/service.yml, service-release.yml). Core runs the nightly corpus compare against the pinned binary (above).

What only a merge can confirm

  • The fleet-verdict job is triggered by workflow_run, and GitHub runs the copy of such a workflow that is on the default branch. A change to the workflows it lists cannot be tested from a pull request; confirm it on the first run after the merge.
  • The Packs built-in-corpus-suite does not run the reality check or the other contract tests on a push to main: it deselects them, because the router job tests (corpus-blocking) owns the reality check and the router job tests (contract tools) owns the rest. The runs after the merge that execute them are the router on main (each job runs only for a change that selects it) and the nightly shard 4.
  • A release tag is a maintainer act and is never pushed from a pull request.

See also