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 thefastmarker is silently deselected there even though it passes when you name it directly. - Before declaring a branch green, run the relevant architectural tests over the
rebased tip. The
tests/architectural/suite is the whole safety net; running the targeted gate file first is faster, and the full suite is the final check.
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)
pytestmarkmarkers, so the shard meant to run it deselects it. Example: a test marked onlyunitwhen 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
fastplus the module's domain marker). Reproduce the shard's real selection locally — runpytest <dir> -m "fast and not windows_ci"— not justpytest <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
distributionande2e, 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.pygoes 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. Runpytest tests/architectural/test_marker_job_completeness.pybefore pushing.
A new test file must declare a marker
- Trips it: a new
tests/**/test_*.pyfile lands without a module-levelpytestmark. - 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 intests/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 corpus shard (
tests-corpus, driven byci-router.yml) runstests/contract/test_example_round_trip.py, which walks everykitty-specs/*/contracts/*.mdand collects each fenced```yamlblock 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 the corpus shard. A local run oftests/ci+tests/architectural(the obvious blast radius for a CI change) does not covertests/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.pylocally before pushing.
The pre-review gate can run the full suite and hang
- Trips it:
spec-kitty agent action implement WP##(andreview, andmove-task --to for_review) trigger the auto-scoped pre-review regression gate (src/specify_cli/review/pre_review_gate.py, baseline capture inreview/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=1on 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 toin_progressand the worktree is created — even if baseline capture times out. To make the baseline itself cheap, declare a fastreview.test_commandin.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 on CI's
architectural shards (a long-running job whose failure short-circuits 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 othersrc/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-testsrc/importer fails — even apython -mentry 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) outsidesrc/kernel/clock.pyfails. Import fromkernel.clockinstead. - Clock import-ban (
tests/architectural/test_clock_import_ban.py, distinct from the call-ban): a rawimport datetime/from datetime import ...anywhere outsidesrc/kernel/clock.pyfails. Import fromkernel.clock, or add an exemption line undertests/architectural/_exemptions/. - Charter facade table (
tests/architectural/test_charter_facades_reexport_doctrine.py): this self-discovers everysrc/charter/*.py, so a new charter facade that re-exports adoctrine.*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.
Renaming a symbol whose body is allowlisted
Renaming or editing a symbol tracked in the dead-symbol allowlist stales its content hash (the allowlist keys entries by a body hash). Refresh it with the project's dead-symbol hash-refresh tool, which is fail-closed — it only refreshes entries that are still genuinely dead and never adds new ones. 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— assertsruff 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:
- You run
ruff formaton an excluded file (making it clean) → its entry no longer reformats → red. - 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.
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.
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.
A new docs page needs triple registration plus a description band
Trips it: a new
docs/**/*.mdpage 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 curatedindex.md, which is why updating onlyindex.mdis not enough.Fix / repro: register the page in three places and satisfy the frontmatter band:
- Curated section index — hand-add the page to its section's
index.md(satisfies the index-completeness rule). - Page inventory — regenerate
docs/development/3-2-page-inventory.yamlviascripts/docs/inventory_lockfile.py. Its--writeguard refuses a path underdocs/, so write to a temp file and copy it over. - Retrieval index — regenerate
docs/development/3-2-docs-retrieval-index.yamlviascripts/docs/docs_index.py --write(this one writes in place). - Frontmatter
description— a hard 50–180 character band, enforced byscripts/docs/description_length_check.pyand the docs SEO tests. Both inventories derive from frontmatter and headings, so regenerate them after the frontmatter and headings are final.
Verify with
PYTHONPATH=. python scripts/docs/check_docs_freshness.py --ci(expecterrors=0; external-URL link-health warnings are fine).ADRs are tracked in all three surfaces too, and have a dedicated helper,
scripts/docs/freshen_adr_inventory.py, that adds the ADR index-table row and regenerates the page inventory in one step; then run the retrieval-index regeneration. An ADR's frontmatter needsdate:,updated:, and a 50–180 characterdescription.- Curated section index — hand-add the page to its section's
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 atests/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.
The GitHub Pages docsite build needs PYTHONPATH
- Trips it: the docsite deploy (
docs-pages.yml) runs Python post-processing steps that importfrom kernel.clock import ....kernelis a src-layout package, so a step that runs rawpython3with noPYTHONPATHand no installed package raisesModuleNotFoundError: 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.ymlhas nopull_requesttrigger, so a PR that changes it is not exercised by PR CI. - Fix / repro: ensure the build job exports
PYTHONPATH: .:srcat job scope (job, not step — several post-process modules import each other and thescripts.docs.*package). Verify a change to this workflow viaworkflow_dispatchon the branch, where the build runs and the deploy stays skipped offmain.
Accept-to-merge consolidation gates
The accept → merge close-out has several gates that block silently until fed
exactly what they expect.
The issue-matrix verdict gate fires on every WP approval
Trips it:
move-task <WP> --to approved— on every WP, including one with no issue references — requires a verdict for every#NNNreferenced anywhere in the mission'sspec.mdandresearch.md(including out-of-scope, deferred, and already-closed references).Symptom: the approval is blocked, and the error lists the missing rows.
Fix: this is orchestrator-level bookkeeping — seed the whole matrix up front. Set verdicts with
spec-kitty agent issue-verdict --mission <m> --issue "#NNN" --verdict <v> --actor <a> [--wp WP##] --evidence-ref "...". Verdict values:in-mission— the issue is owned and being fixed by a WP in this mission (the honest interim state while WPs are in progress; not fabrication).deferred-with-followup— out of scope; the evidence-ref must contain a#NNNorFollow-up:handle or the gate rejects it.verified-already-fixed— a closed root-cause issue the mission relies on.fixed— completed in-mission, set at accept once the WP is done.
The per-WP reviewer should refuse to fabricate verdicts for unfixed issues — that is correct behavior, not a blocker; the orchestrator fills them honestly. At accept, flip the
in-missionrows tofixed/verified-already-fixed. The matrix is a dict keyed by#NNNunderrowsinissue-matrix.jsonon the coordination partition (the.mdform is legacy — do not create it).
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 tofor_revieworapproved, 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. acceptance-matrix.jsonhas no CLI — edit it directly in the coordination worktree: set each criterion'spass_failto"pass"withevidence,verified_by, andverified_at, and setoverall_verdictto"pass".acceptalso fails on a dirty tree, so commit or clean the dossier state first.spec-kitty mergerefuses 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 doctrine 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.
See also
- Known Current Friction Points — the fast-drifting, time-stamped list of what is red today.
- Red Main and Release Readiness — what a red
mainmeans and why CI is the release authority. - Parallel testing — why the make targets are shaped the way they are.
- Landing contributor PRs — the maintainer landing runbook and red-classification step.