Contracts
gate-execution-context.md
Contract — Gate Execution Context
Discharges: FR-001, NFR-001, NFR-003, C-004 Consumers: acceptance negative-invariant gate, consolidation readiness preview gate, post-consolidation verification
This is a behavioural contract. It states what must be observably true, not which functions exist. Tests written against it must assert behaviour on realistic fixtures (NFR-008).
C1 — A gate judges the surface it was handed, not an ambient one
Rewritten 2026-07-23. The prior form asserted "does not construct one from repo_root/cwd", whose stated signal was a single-construction-site source scan — a code-shape assertion that NFR-008 forbids, and one a gate constructing an identical context would pass anyway.
Given a gate handed a context whose surface is a directory distinct from both repo_root and the process working directory And all three locations hold different seeded answers When the gate runs Then the verdict reflects the answer seeded at context.surface.
Why this is stronger: it kills the ambient-derivation mutant behaviourally, and it is refactor-stable. Prior art to reuse rather than reinvent: the decoy-marker idiom in tests/integration/coord_topology_fixture.py (a distinct marker seeded on the primary copy so a wrong-leg read returns a wrong value, not merely a wrong path), and test_placement_partition_golden_path.py::test_cwd_independence_resolves_identical_authority for the cwd leg — already parametrised over coord and SINGLE_BRANCH, unpatched, with a non-vacuity assertion.
C2 — Refuse when the surface cannot hold the fact
Given a gate whose subject cannot exist in context.surface at context.phase When it runs Then it returns a distinguishable cannot-evaluate outcome naming the reason And it returns neither a pass nor a fail.
Failure this prevents: an invariant asserting a file the mission adds, checked at ACCEPT against the pre-consolidation primary surface, reported as violated.
C3 — Four declared states, four declared answers
Corrected twice. The first form said "cannot be resolved → raises", collapsing distinct states. The second form (2026-07-23) modelled three and omitted EMPTY — but the live classifier CoordState (missions/_read_path_resolver.py:278-281) enumerates four non-NONE states, and the data model, NFR-001 and the gate all reference four. Because this contract is the behavioural test spec, an omitted state ships untested. All four below.
Given a mission declaring a coordination home whose branch is absent from git (DELETED) When a gate builds a context for that kind Then it raises CoordinationBranchDeleted (error_code = "COORDINATION_BRANCH_DELETED") And it does not read from the primary surface instead.
Given a mission declaring a coordination home whose branch exists in git but whose worktree is not yet materialised (UNMATERIALIZED — the #1718 create-window) When a gate builds a context for that kind Then it resolves the primary mission directory affirmatively And stamps surface_kind = PRIMARY on the verdict (C6) And does not raise — this is a declared answer for that state, not a degradation.
Given a coordination worktree root that exists but whose mission directory is absent (EMPTY — #1716) When a gate builds a context for that kind Then it resolves the primary mission directory affirmatively and stamps surface_kind = PRIMARY And does not raise, but this stamp is load-bearing: EMPTY is the split-brain-risk state (#1589/#1821), so a gate judging a COORD-homed kind against this stamped-PRIMARY surface must return cannot-evaluate (GEC-5 / C2), never a verdict.
Reconciling the "one declared answer" rule with EMPTY's pinned topology-dependent behaviour. The live resolver (surface_resolver.py) treats EMPTY differently by topology: a solo (no-lanes) coord mission gets a quiet primary fallback (the write self-materializes the worktree, #2533 / WP08 T029-T031), while a LANES_WITH_COORD mission gets a loud primary fallback with _COORD_EMPTY_FALLBACK_WARNING (pinned by WP08 T031, which NFR-004 forbids modifying). This does not violate the one-answer rule and does not re-introduce topology-conditioning (C-004): the answer is the same in both — resolve primary, stamp PRIMARY, GEC-5 governs the gate — and the loud/quiet difference is warning verbosity in the underlying resolver, a diagnostic concern orthogonal to the gate's verdict. The gate-execution-context layer must consume that existing behaviour, not re-derive it, and must not condition its own verdict on which topology produced the EMPTY. (This is the same reconciliation the UNMATERIALIZED correction made, one state over — flagged by the second independent review so the collision is not repeated.) Note the live doc-level contradiction this contract must not inherit: CoordState's own docstring calls EMPTY "a fail-closed condition, never a silent primary fallback" (_read_path_resolver.py:265-266) while its consumer (surface_resolver.py) implements a loud primary fallback (ADR 2026-06-19-1). The mission resolves this in favour of resolve-primary-and- stamp, with GEC-5 supplying the fail-closed behaviour at the consumer (the gate refuses) rather than at the resolver.
Given a materialised coordination worktree When a gate builds a context Then it resolves the coordination surface and stamps surface_kind = COORD.
Relationship to NFR-001. "No silent substitution" is satisfied because each of the four states has a declared answer that the verdict names. What NFR-001 forbids is an undeclared fallback — reading somewhere the caller never asked for and not saying so. Resolving UNMATERIALIZED or EMPTY to primary and stamping it is declared and visible; that is the difference.
This is what resolver totality means in practice: four states, four defined answers, consuming the existing CoordState classifier rather than a new one, and None retained for nothing.
C4 — Total resolution, observed through the stamp
Rewritten 2026-07-23. Two of the three original clauses ("not expressed as a None-fallback", "no branch reads flattened") were assertions about source text, not behaviour.
Given a mission on flat / SINGLE_BRANCH / LANES topology When a home is resolved for any artifact kind Then the primary mission directory is returned affirmatively as that topology's declared home And the resulting context carries surface_kind = PRIMARY (observed via C6's stamp, not by reading the resolver's source).
The two source-shape properties move to mission negative invariants, where scoped grep_absence over src/specify_cli/acceptance/ and src/mission_runtime/ is the right instrument and is judgeable pre-consolidation (provenance contract C9): no None-as-fallback in home resolution, and no new read of the flattened flag. That is the refactor-stable negative form, rather than a positive assertion about how the code is written.
C5 — Ref agreement
Given a context whose surface is not at ref When a gate runs against it Then it raises rather than judging.
C6 — Every verdict names its surface
Given any gate verdict or recorded judgement When it is emitted, recorded, or rendered in JSON output Then it carries a resolvable identifier of the surface and ref it was derived from.
C7 — Topology neutrality
Given the identical defect condition on a coordination mission and on a flat mission When the gate runs in each Then both produce the same outcome.
Failure this prevents: a coord-named fix that leaves the flat case broken. #1834 reproduces on both.
Non-goals
research/sibling-mission-coordination.md).
- Does not change which gates exist or when they run.
- Does not migrate
review/pre_review_gate.py— explicitly out of scope this mission (see - Does not remove
flattenedfrom the codebase; only forbids new dependence on it.
negative-invariant-provenance.md
Contract — Negative-Invariant Provenance and Deferral
Discharges: FR-002, FR-003, FR-004, FR-005, FR-010, FR-014, FR-015 Surfaces: acceptance/matrix.py, acceptance/gates_core.py, acceptance/post_consolidation.py
C1 — Provenance is mandatory on any judgement, with one typed legacy escape
Given a negative invariant with a result other than pending and provenance_origin of recorded When the acceptance matrix is validated Then it must carry both verified_ref and verified_surface_kind And a provenance-less recorded result is reported as a validation error.
Given a negative invariant with provenance_origin of legacy_unrecorded (the sentinel the FR-014 migration writes for results recorded before provenance existed) When the matrix is validated Then null verified_ref and null verified_surface_kind are accepted for that origin only And legacy_unrecorded is a provenance_origin value, never a TopologySurface member — the surface enum's anti-phantom rule forbids an unresolvable member.
This clause and data-model NI-1 must state the same rule; an earlier revision left C1 unconditional while NI-1 carried the escape, giving validate_matrix_evidence two contradictory specs. Reconciled 2026-07-23.
C2 — Provenance round-trips
Given an invariant carrying provenance When the matrix is serialised and re-read Then verified_ref, verified_surface_kind, deferred_reason and deferred_to_phase survive unchanged.
C3 — A recorded judgement is never overwritten
Given an invariant already recorded as confirmed_absent, still_present or verification_error When any later gate runs in any phase Then the recorded result and its provenance are preserved verbatim And no re-execution occurs.
Status: already landed (b918e66df). This contract pins it as a ratchet so it cannot regress.
C4 — Unjudgeable pending invariants defer
Given a pending invariant whose subject cannot exist in the current surface When the acceptance gate runs Then its result becomes deferred_to_consolidation And deferred_reason names why And deferred_to_phase is POST_CONSOLIDATION And the result is never still_present.
C5 — Deferral does not block acceptance
Given a mission whose only unresolved invariants are deferred_to_consolidation When acceptance runs Then overall_verdict does not compute fail on their account And acceptance is not blocked by "criteria or invariants have not been verified".
Failure this prevents: the renamed failure mode — deferral must not simply relocate the block from fail to pending.
C6 — Deferral is honoured post-consolidation
Given an invariant recorded deferred_to_consolidation When the post-consolidation verification op runs on the consolidated mission branch Then it is judged against that consolidated tree And its outcome is written back with verified_surface_kind = CONSOLIDATED and the consolidation commit as verified_ref.
C7 — A violated deferred invariant fails the op, NOT the consolidation
Given a deferred_to_consolidation invariant that proves still_present on the consolidated tree When the post-consolidation verification op runs Then the op fails and names the specific invariant And lane consolidation itself is unaffected — it has already completed cleanly And no rollback of the consolidation is attempted.
Why the separation is load-bearing: folding this into spec-kitty merge would introduce a new abort trigger into the consolidation transaction at exactly the time IC-06 is collapsing its two compensators into one. Keeping verification as a separate on-branch op after consolidation means there is no new abort path, nothing half-applied, and no interaction with the rollback rework. The blast radius is contained because the consolidated tree is the mission/PR branch, not origin/main — a violation blocks the PR, which is where it should block.
C8 — Single authoritative copy
Given a mission on any topology When the acceptance matrix is authored Then exactly one file exists, at its declared home And no primary-scaffold second copy is created.
Failure this prevents: the provenance fields added by C1 diverging between two copies.
C9 — Scoped absence checks remain judgeable pre-consolidation
Given an invariant using grep_absence scoped to a source directory that already exists on the primary surface When acceptance runs pre-consolidation Then it is judged normally rather than deferred.
Rationale: this shape is verifiable pre-consolidation and is the one #1834 never broke. It is the preferred shape for this mission's own invariants — see quickstart.md.
C10 — Archiving refuses to hide a live problem
Given a mission that is not terminal (merged / canceled) When an operator attempts to archive it Then the operation is refused with a stated reason (AM-1).
Given a terminal mission with any invariant recorded still_present When archiving is attempted Then it is refused (AM-2) — a violation is resolved, not filed away.
Given a terminal mission with no still_present invariant When it is archived Then the ArchivedMission record names archived_by, archived_at and reason; the mission is excluded from live validation; and it stays enumerable (AM-3).
Given the FR-014 migration encountering a mission it cannot bring onto the schema When the migration runs Then it does not auto-archive (AM-4) — archiving is operator-invoked only.
tool-artifact-owner.md
Contract — Tool-Artifact Owner
Discharges: FR-007, FR-008, FR-009, FR-012, FR-013, NFR-002, NFR-006, C-010 Surfaces: coordination/transaction.py, merge/bookkeeping_projection.py, merge/executor.py, mission_runtime/artifacts.py, status/__init__.py, coordination/commit_router.py, cli/commands/implement_cores.py, bulk_edit/diff_check.py, git/ref_advance.py, cli/commands/agent/tasks_move_task.py, merge/ordering.py, lanes/merge.py, review/dirty_classifier.py, merge/git_probes.py, cli/commands/agent/mission_record_analysis.py, acceptance/__init__.py, coordination/coherence.py, cli/commands/implement.py, lanes/auto_rebase.py
(Corrected 2026-07-23: merge/ordering.py and lanes/merge.py are live COORD_OWNED_STATUS_FILES consumers that the first surface list omitted, along with the other consumer modules. Both are in the merge/ package.)
C-001 ↔ IC-07(c) tension, resolved. Retirement group (c) — COORD_OWNED_STATUS_FILES and its consumers — is inseparable by C5 (retiring one consumer leaves the set alive), yet two of its consumers (merge/ordering.py, lanes/merge.py) sit in the merge/ package that C-001 gates. The "merge-package work last" rule therefore cannot mean "split group (c) so the merge parts come later" — the contract forbids that split. It means: schedule the whole of group (c) after the merge/-package rebase point, as one work package. C-001's precondition (in-flight PRs landed, branch rebased) is already discharged, so the residual requirement is only "re-fetch before starting group (c)".
C1 — Exactly one owner per generated write, bounded by a tool-derived inventory
Rewritten 2026-07-23. "Any path spec-kitty writes" is an unbounded universal with no oracle.
Given the enrolment inventory — a tool-derived list of generated-write sites that self-asserts in BOTH directions (no discovered sink missing from it; no row without a live sink) When the inventory is regenerated Then every discovered write site is enrolled in exactly one transaction And every inventory row still maps to a live site.
Reuse, do not reinvent: tests/architectural/untrusted_path_audit/ already implements exactly this mechanism — a tool-derived inventory with an undercount arm and an overcount/ghost arm, keyed by a drift-proof composite key. Clone that shape. Never hand-write the list; a hand-derived inventory is the same failure that produced three successive wrong exemption censuses.
C2 — Committed or reverted, never orphaned
Given an enrolled generated write When the step completes Then the write is committed to its declared home When the step fails or is interrupted Then the write is restored to its pre-transaction bytes And no third, partially-applied state is observable.
Observable signal: interrupt tests on each write path assert the artifact is byte-identical to either its pre-state or its committed post-state.
C3 — Subprocess byproducts are enrolled
Given a lifecycle step that spawns a child process which creates or modifies files When the step completes or aborts Then those bytes are committed or reverted like any other generated write And they are not detected, warned about, and abandoned.
Failure this prevents: the current "Pre-review tests created or changed additional paths; preserved without cleanup" behaviour, which manufactures precisely the orphan a later gate must then be taught to ignore.
C4 — One compensator (split into a behavioural arm and a negative arm)
Rewritten 2026-07-23. "When rollback implementations are enumerated" needed an oracle nobody had written.
C4a (behavioural). Given each of the enumerated failure-injection scenarios, when the step fails, then the bytes at every enrolled path are byte-identical to the pre-transaction snapshot.
C4b (negative, mission invariant). A scoped grep_absence over src/specify_cli/merge/ for the named retired symbols (_capture_bookkeeping_snapshots, _restore_final_bookkeeping_snapshots, and their confinement helpers). This is a source scan, but a negative one over symbols the mission intends to delete — the refactor-stable form NFR-008 permits, unlike a positive count of implementations.
C5 — Every enumerated retirement symbol is absent
Given the codebase after this mission When each symbol on the retirement list is searched for in src/ Then every one is absent — verified per named symbol, not by counting.
The retirement registry (the enumerated rows). The eight original symbols are listed below; the four additions R-014 found (ACCEPT_OWNED_PATHS, the dirty_classifier bundle, _exclude_coord_owned, the dead ignores_primary_coord_residue) are equally on the registry and retired by IC-07 group (g). No count is normative — the registry, derived by rule (R-014), is the authority, and IC-08 asserts it reaches zero rows. The original eight:
1. is_self_bookkeeping_path and its filename/suffix sets — 4 consumers 2. is_coordination_artifact_residue_path — 7 consumers 3. + 4. COORD_OWNED_STATUS_FILES, its advance_branch_ref parameter, and the coord-staging skip of the same set — one mechanism with eight consumer sites; cannot be split across work packages, since retiring one consumer leaves the set alive 5. _drop_vcs_lock_only_meta 6. new_checkout_paths "preserved without cleanup" — ~10 sites, :1115-1632 7. RUNTIME_STATE_ALLOWLIST / _runtime_state_exemption 8. _drop_runtime_frontmatter_only_wp + _is_wp_filename / _WP_FILENAME_PATTERN — the structural twin of #5; deduplicate rather than retiring one and leaving the other
Explicitly NOT on this list: the non-pending preservation branch in acceptance/matrix.py. It is a state guard, not a filename or path match, and this mission requires it to survive (NI-2 pins it; NFR-004 forbids regressing it). An earlier draft of this contract listed it as retired while the provenance contract pinned it — a direct contradiction, now resolved. See research R-012.
Why enumerated and not counted: the original count was hand-derived, wrong (nine mechanisms, not eight), and needed an oracle nobody wrote. Per-symbol absence is decidable; a total is an artefact of the list.
C6 — Retirement preserves behaviour the exemption got right
Given an exemption that encoded genuinely correct behaviour When it is retired Then that behaviour is preserved through the owner And no operation that previously succeeded now fails.
This is the primary regression risk of the whole mission. Each retirement lands in its own work package specifically so a reintroduced false block is localisable (C-010, decision 01KY7AKZBQCM7X4MV2C0101WKZ).
C7 — Cross-gate agreement (RED on the current base)
Rewritten 2026-07-23 into a behavioural form that is falsifiable today.
Given the same corpus of paths When every gate that classifies toolchain churn is asked to classify it Then all gates return the identical classification.
This test goes red immediately on the mission base, which is exactly what red-first discipline wants: merge/git_probes.py:173 exempts a tracked-modified meta.json via is_self_bookkeeping_path (_SELF_BOOKKEEPING_FILENAMES = {"meta.json"}), while git/ref_advance.py never consults that predicate and its excluded_filenames escape applies only to untracked entries — so the same file is invisible to one gate and fatal at another. That disagreement is FR-012 stated as a live defect rather than a design aspiration, and it is the concrete repro for #2795.
C8 — Rename-invariance (the clause that makes "by kind, not filename" mean something)
Rewritten 2026-07-23. "Uses the declared kind and not a filename match" is a statement about implementation; as written it would be quietly satisfied by one bigger filename list.
C8a. Given a generated artifact of declared kind K classified as generated, when the same bytes and kind are written to a path with a different basename, then the classification is unchanged.
C8b. Given an operator-authored file whose basename collides with a generated artifact's basename, then it is not classified as generated.
A filename-based classifier cannot pass either arm. Together they kill the mutant this contract names by hand — replacing eight lists with one larger list — behaviourally rather than by inspection.
C9 — A ninth exemption is refused
Given a contributor adding a new filename-based exemption to any dirty-state gate When the architectural suite runs Then it fails And the failure names the owner as the supported route.
Constraint: pin a behavioural invariant, not a literal source scan (NFR-008), so a later refactor neither false-reds nor false-greens it.
C10 — Size discipline
Rewritten 2026-07-23. "Roughly 1500" is unmeasurable, and "split out before the generalisation landed" asserts commit ordering — unassertable post-merge and erased by this project's history-compression practice.
Given the mission is complete When coordination/transaction.py is measured Then it is ≤ 1000 LOC (measured baseline 1345; IC-12's extraction targets ~914, leaving headroom for the owner's three new capabilities).
The sequencing requirement — extraction precedes generalisation — is a plan constraint (IC-12 before IC-06), not an acceptance criterion. Recorded there, removed from here.