Contracts

commit-outcome.md

Contract: per-surface commit outcome

Home: src/specify_cli/coordination/commit_outcome.py (new): PathFate, SurfaceOutcome, render_commit_outcome, commit_outcome_payload. CommitRouterResult (commit_router.py:174-205) gains surfaces. Requirements: FR-006, FR-007, FR-007a, FR-007b, FR-005; SC-003.

Types

@dataclass(frozen=True)
class PathFate:
    path: str                 # as the caller passed it (repo-relative)
    reason: str               # see "Reason codes"
    owning_path: str | None = None

@dataclass(frozen=True)
class SurfaceOutcome:
    surface: Literal["primary", "coordination"]
    branch: str
    status: Literal["committed", "unchanged", "refused", "error"]
    commit_hash: str | None
    committed: tuple[str, ...] = ()
    skipped: tuple[PathFate, ...] = ()
    refused: tuple[PathFate, ...] = ()
    diagnostic: str | None = None

# CommitRouterResult: existing fields unchanged +
    surfaces: tuple[SurfaceOutcome, ...] = ()

Rules

1. surfaces has one entry per partition group the request touched, in the order PRIMARY then coordination. 2. Owning surface. A COORD record passed by its repository-root path is judged on its coordination copy. A dirty coordination copy is committed; a clean one is unchanged. Otherwise it is listed in refused with a named reason. It never lands in skipped while the owning copy is dirty. 3. skipped holds only genuine no-ops: clean on the owning surface, or COORD_RECORD_IN_ROOT_CHECKOUT (a root-checkout copy of a COORD record that is never committed to the target branch). 4. Legacy fields. The top-level status, placement_ref, commit_hash, diagnostic and reason keep today's caller-surface selection (_merge_group_results, L802-844). commit_hashes keeps its union. 5. Exit-code rule for consumers. Exit non-zero if any surface is refused or error. skipped and unchanged exit 0 and are rendered. 6. Every consumer renders through render_commit_outcome (text) or commit_outcome_payload (JSON). No consumer formats surface outcomes by hand. A test pins the consumer list from research D8.

Reason codes

CodeListMeaningNew or existing
no_op_already_committedskippedowning copy cleanexisting (_REASON_ALREADY_COMMITTED, L166)
no_op_no_changesskippednothing stagedexisting (L167)
COORD_RECORD_IN_ROOT_CHECKOUTskippeda COORD record dirty only in the repository root checkout; never committed to the target branchnew
PROTECTED_BRANCH_REFUSEDrefuseddestination ref is protectedexisting (coordination/types.py:90)
STATUS_LOCK_HELDrefusedstatus lock held by another writernew code (maps FeatureStatusLockTimeoutError)
COORDINATION_BRANCH_DELETEDrefusedcoordination branch missingexisting
COORDINATION_WORKTREE_UNMATERIALIZEDrefusedcoordination branch exists only on the remote (#4970 parity), surfaced from write_direxisting
COORD_SEED_FORK_REFUSEDrefusedestablishing the coordination surface found a forked log (contracts/seed.md)new
PATH_UNROUTABLErefusedpath outside the Mission, or kind not classifiablenew
WRONG_SURFACErefusedtoday's no_op_wrong_surface situationexisting semantics; new name in surfaces only

JSON shape changes (additive, backward compatible)

spec-commit (spec_commit_cmd.py::_payload, L70-95): the existing keys are unchanged (result, success, committed, placement_ref, commit_hash, error, diagnostic, reason, error_code). New keys:

{
  "surfaces": [
    {"surface": "primary", "branch": "topic", "status": "committed", "commit_hash": "abc…",
     "committed": ["kitty-specs/m/spec.md", "kitty-specs/m/decisions/index.json"], "skipped": [], "refused": []},
    {"surface": "coordination", "branch": "kitty/mission-m-01ABCDEF", "status": "committed", "commit_hash": "def…",
     "committed": ["kitty-specs/m/traces/approach.md", "kitty-specs/m/status.events.jsonl"], "skipped": [], "refused": []}
  ],
  "arguments": [
    {"path": "kitty-specs/m/spec.md", "fate": "committed", "surface": "primary"},
    {"path": "kitty-specs/m/status.events.jsonl", "fate": "committed", "surface": "coordination"}
  ]
}

arguments lists every input path exactly once, with fate one of committed, unchanged, skipped or refused, plus reason when not committed (FR-007a). success becomes false when any surface is refused or error.

accept (--json): adds residual_commit: {"surfaces": [...]}. finalize-tasks (--json): adds commit_surfaces: [...] beside the existing commit_hashes (emitted at mission_finalize.py:3746). It also adds planning_commit_refresh: {status: "refreshed" | "preserved" | "kept_with_warning", recorded, candidate, pin_class, reason}.

retrospect, setup-plan, record-analysis: add surfaces to their existing commit payloads. implement receipts ({"commits": [...]}, workflow.py:837): every entry carries a non-null sha (FR-013).

  • kept_with_warning (exit 0) applies only when the automatic refresh cannot prove a safe advance for a non-orphan pin (FOREIGN/INDETERMINATE; ruling Q6).
  • An orphaned pin keeps failing closed before any write with the existing error envelope (#4827, test_plain_finalize_fails_closed_on_orphaned_pin); this field is not emitted then.
  • An explicit --refresh-planning-commit keeps its existing refusals, which also fail (research D16).

Text (one line per surface, then one line per refused or skipped path):

✓ primary (topic): committed abc1234 — 2 files
✗ coordination (kitty/mission-m-01ABCDEF): refused — status.events.jsonl: STATUS_LOCK_HELD

Compatibility

  • No existing key is removed or retyped. An old consumer reading only the top-level fields sees today's values.
  • success can now be false where it was true before. That happens exactly when a surface was refused, which is the defect being fixed.

doctor-decisions-fork-report.md

Contract: doctor decisions fork report, repair, and verify

Detector: src/specify_cli/decisions/fork.py (new), detect_decision_forks(repo_root, mission_slug) -> DecisionsForkReport and coordination_only_ledger(repo_root, mission_slug) -> LedgerHomeFinding. Consumers:

  • cli/commands/_decisions_doctor.py: _diagnose (L372), _repair (L404), run_decisions_reconciliation (L529);
  • decisions/verify.py::verify (L104), via decision verify (cli/commands/decision.py:567);
  • coordination/teardown.py::teardown_coordination_topology (L222);
  • consolidation/executor.py::_pre_mutation_safety_preflight (L3450).

Requirements: FR-009c, FR-010, FR-010a, FR-011; SC-004; NFR-002. Decisions: forked-log-recovery, fork-check, legacy-ledger.

Inputs read

SurfaceWorktree (preferred when present)Ref (fallback, fresh clone)
PRIMARYthe repository root checkout's Mission dir (read_dir(PRIMARY_METADATA))git show <target_branch>:kitty-specs/<dir>/<file>
COORDthe coordination worktree's Mission dir (CoordinationWorkspace.worktree_path)git show <coordination_branch>:kitty-specs/<dir>/<file>

The prefix/fork comparison reuses the pure classifier in coordination/event_prefix.py; there is no second classifier. The streams are status.events.jsonl (decision events filtered by event_type) and decisions.events.jsonl. The ledger is decisions/index.json plus decisions/DM-*.md.

The detector never writes and never materializes; it is read-only and safe on any checkout.

Report

The value objects are defined in data-model.md §4. The JSON envelope adds fields to the existing doctor decisions --json output:

{
  "mission_slug": "m-01ABCDEF",
  "forked": true,
  "streams": [
    {"stream": "status.events.jsonl", "state": "forked",
     "primary": {"source": "worktree", "path": "...", "event_count": 4},
     "coordination": {"source": "ref", "ref": "kitty/mission-m-01ABCDEF", "event_count": 3},
     "decisions_only_on_primary": ["01J…A"], "decisions_only_on_coordination": ["01J…B"], "decisions_on_both": []}
  ],
  "ledger": {"state": "primary", "entries_only_on_coordination": [], "dm_files_only_on_coordination": []},
  "orphaned_in_index": [],
  "missing_from_index": [],
  "reconcile_steps": ["…"]
}

state values: single_home, prefix, forked, absent. Text output prints one line per stream and per surface, then the reconcile steps.

Rules

1. Orphan rule. An index entry is orphaned only when its decision id is absent from every stream on both surfaces. 2. --repair never removes an entry whose events exist on either surface (FR-011). When forked is true, --repair performs no index rewrite for the forked decisions. It prints reconcile_steps and exits 1. There is no automatic re-sequencing (C-003). 3. --repair on an unforked Mission still repairs a genuine orphan (positive control) and still adds missing_from_index entries, as today. 4. Coordination-only ledger. When ledger.state == "coordination_only" (or both with entries only on the coordination side), doctor decisions reports DECISION_LEDGER_ONLY_ON_COORDINATION. --repair copies the missing DM-*.md files and merges the index entries into the PRIMARY ledger dir with the shared pure union_decision_index(ours, theirs) (consolidation/drivers.py, the same function as the merge driver). The merge is additive and runs under the ledger's index.json.lock. The repair prints the commit hint (spec-kitty spec-commit -m … kitty-specs/<dir>/decisions/) and creates no commit (FR-009b; the ledger committers are spec-commit and accept). 5. decision verify adds the finding DECISION_LOG_FORKED whenever detect_decision_forks(...).forked. It exits 1 by default and appears in findings[] of the JSON output. 6. Teardown and consolidation call coordination_only_ledger. A non-empty result raises ProjectionTeardownAbort with error_code="COORDINATION_LEDGER_UNREPAIRED" and the hint spec-kitty doctor decisions --repair.

Error and finding codes

CodeKindNew or existing
DECISION_LOG_FORKEDdoctor finding plus verify findingnew
DECISION_LEDGER_ONLY_ON_COORDINATIONdoctor findingnew
COORDINATION_LEDGER_UNREPAIREDteardown and consolidation refusal (ProjectionTeardownAbort)new code on an existing exception
DEFERRED_WITHOUT_MARKER, MARKER_WITHOUT_DECISION, STALE_MARKERverify findingsexisting, unchanged

Compatibility

  • Additive JSON keys only.
  • decision verify can newly exit 1, for forked Missions only (FR-010a).
  • doctor decisions --repair can newly exit 1, for forked Missions only.

seed.md

Contract: seed (one-time carry-over to the coordination surface)

Operation: seed_coord_surface(request: SeedRequest) -> SeedReport (new, src/specify_cli/coordination/coord_seed.py). The only caller is establish_coord_write_location (see write-location-accessor.md). It is never called on a read path. Requirements: FR-002, FR-004, FR-004a, FR-004b, FR-003a; C-003, C-004. Decisions: specify.design.fork-check, specify.design.seed-mechanism.

Preconditions

  • The Mission is coordination-routed (coord or lanes_with_coord, owned or not).
  • The coordination worktree exists and the state is EMPTY: the worktree root exists and the Mission dir is absent.
  • The caller holds no workspace lock. It may hold the status lock, which is reentrant.

Procedure

1. Lock. Acquire feature_status_lock(lock_root, coord_mission_dir_name(slug, mid8), timeout=BOUNDED_STATUS_LOCK_TIMEOUT_SECONDS). lock_root is owned.owned_root when owned, else repo_root (as transaction.py:290-291). The key matches coord_status_lock and commit_router._coord_status_locks. The timeout is bounded (status/locking.py:54, 10 s); the default -1 would wait forever and make STATUS_LOCK_HELD unreachable. 2. Re-check. Re-probe the state. If it is MATERIALIZED, return an empty report (another writer seeded first, so the seed is idempotent), unless a seed commit is pending: a seed commit is pending iff the coordination branch history has no Spec-Kitty-Coordination-Seed: <mission_id> trailer and the coordination tip has no COORD-kind blob under the Mission dir, i.e. the Mission dir is wholly untracked, which only a refused seed leaves. A never-seeded pre-fix MATERIALIZED Mission (#5519 shape) has committed COORD blobs at the tip, so it never matches. In that case, run step 9 only (the commit with the trailer) and return. 3. Clean up. Remove stale kitty-specs/.<dir>.seed- temp dirs. 4. Collect. Gather root COORD records from the repository root checkout's Mission dir. These are the files whose kind_for_mission_file is a COORD kind: the status log, decisions.events.jsonl, traces/.md, tasks//review-cycle-.md, issue-matrix.json and acceptance-matrix.json. PRIMARY files are never collected. 5. Read the coordination side. The coordination-side content is the branch-tip blobs of COORD-kind files under kitty-specs/<dir>/ (classified with kind_for_mission_file), if any; otherwise it is empty. PRIMARY files on the branch tip (a pre-fix branch cut after the first target commit) are ignored. When the branch carries the seed marker (post-fix regression), the accessor first restores the COORD-kind paths from the tip, with an explicit pathspec rather than a whole-dir checkout, and that restored content is the coordination side. 6. Apply the prefix rule per log stream, on event-id sequences R (root) and C (coordination):

7. Non-log records. Take the coordination copy if present, else the root copy. When both exist and differ, add a warning; the root copy is kept untouched. 8. Write. Write the output tree into kitty-specs/.<dir>.seed-<pid>-<ulid>/, then os.rename it to kitty-specs/<dir>/. The rename is atomic within the worktree's filesystem. 9. Commit. Commit on the coordination branch through commit_for_mission(kind=STATUS_STATE, files=<coordination paths>), with the message chore(<mission>): seed coordination surface and the trailer Spec-Kitty-Coordination-Seed: <mission_id>, built from the one shared constant COORD_SEED_TRAILER in coord_seed.py, which create's commit also uses (Decision Moment plan.design.seed-trailer-ownership). The trailer is the post-fix discriminator (research D4). Then record coord_commit. 10. Restore the root checkout. For each carried root file: if it is tracked and dirty, run git checkout -- <path>; if it is untracked, remove it. Committed history is never rewritten (C-004). 11. Release the lock and return the SeedReport.

  • C empty, or C a proper prefix of R: the output is C + R[len(C):], using the root lines.
  • R a prefix of C, or equal: the output is C.
  • Otherwise: release everything and raise COORD_SEED_FORK_REFUSED.

Create-time seed: steps 4-7 find nothing (the scaffold no longer writes the status log to the root checkout), so step 8 creates an empty Mission dir. Create then emits the creation events into it and commits them (IC-05).

Postconditions

  • The coordination Mission dir exists and holds every carried record. The state is MATERIALIZED.
  • In each log, no event id appears twice.
  • The coordination branch carries the seed commit, so a later fresh clone that materializes the worktree sees MATERIALIZED and never seeds again.
  • No PRIMARY-partition file exists in the coordination Mission dir (US1.3).
  • The coordination branch carries a commit with the Spec-Kitty-Coordination-Seed: <mission_id> trailer, unless the seed commit was refused (see Errors). In that case the next seed attempt re-commits the seed with the trailer.

Errors

CodeConditionState after
COORD_SEED_FORK_REFUSED (new)both logs non-empty, and neither event-id sequence is a prefix of the otherunchanged. The message names both paths, both refs, the first diverging event id on each side, and the reconcile steps: inspect with spec-kitty doctor decisions; keep the coordination log; re-open any root-only decisions with spec-kitty agent decision open ...; then remove the root copy.
STATUS_LOCK_HELDlock timeoutunchanged
commit refusal (PROTECTED_BRANCH_REFUSED on the coordination ref, …)step 9 refusedMission dir present and uncommitted (records are not lost). The triggering write proceeds. A loud warning names the Mission, the refused ref and the reason. The next seed attempt re-commits it with the trailer: the next COORD write finds the seed pending (a seed commit is pending iff the coordination branch history has no Spec-Kitty-Coordination-Seed: <mission_id> trailer and the coordination tip has no COORD-kind blob under the Mission dir, i.e. the Mission dir is wholly untracked, which only a refused seed leaves. A never-seeded pre-fix MATERIALIZED Mission (#5519 shape) has committed COORD blobs at the tip, so it never matches) and retries the commit before its own write. The commit router is not changed. If the ref is still protected, the warning repeats. Reported in SeedReport.warnings (spec edge case "Refused seed commit").

Compatibility

  • Pre-fix Missions heal at their first COORD write. Their history on the target branch is untouched.
  • A Mission already forked and MATERIALIZED (the #5519 shape) never reaches the seed, so it is not locked out. doctor decisions reports it.

write-location-accessor.md

Contract: write-location accessor

Operation: PlacementSeam.write_dir(self, kind: MissionArtifactKind) -> WriteLocation Home: src/mission_runtime/resolution.py, class PlacementSeam (L2261), beside write_target (L2304) and read_dir (L2318). WriteLocation and Establishment live in src/mission_runtime/write_location.py, which is new. Delegate: for a COORD kind of a coordination-routed Mission, the work is done by specify_cli.coordination.coord_seed.establish_coord_write_location(repo_root, mission_slug, kind, *, owned) -> WriteLocation (new). The seam reaches it through a lazy import over the existing coordination ledger edge. Public surface: WriteLocation and Establishment are exported from the mission_runtime package root (__init__.py __all__, plus _PUBLIC_SURFACE in tests/architectural/test_mission_runtime_surface.py). Modules outside mission_runtime import only from the root (MR-1/MR-2). Requirements: FR-003, FR-003a, FR-004, FR-004a, FR-004b; C-001, C-002, C-008. Research D1, D4, D22, D23.

Signature

@dataclass(frozen=True)
class WriteLocation:
    path: Path
    surface_root: Path                    # root of the checkout holding `path`: the coordination worktree root,
                                          # or the repository root checkout; consumers never guess `.parent.parent`
    surface: Literal["primary", "coordination"]
    coord_state_before: CoordState | None
    establishment: Establishment          # NONE | MATERIALIZED | SEEDED | RESTORED_FROM_BRANCH
    seed: SeedReport | None = None

class PlacementSeam:
    def write_dir(self, kind: MissionArtifactKind) -> WriteLocation: ...

Preconditions

  • The PlacementSeam was built with placement_seam(repo_root, mission_slug, owned=...) (resolution.py:2835). The partition invariant was asserted at construction.
  • The Mission resolves: meta.json is readable on the PRIMARY partition.

Postconditions

CaseResult
kind is a PRIMARY-partition kindsurface="primary", path = the declared PRIMARY dir (identical to read_dir(kind) today), establishment=NONE. No side effects.
COORD kind, topology lanes or single_branchthe PRIMARY dir, as today (C-008). No side effects.
E2-eligible COORD kind (REVIEW_CYCLE, TRACER_FILE, ISSUE_MATRIX, ACCEPTANCE_MATRIX) of a PUBLISHED (post-consolidation) Mission (resolution.py:191-199, E2 short-circuit L1961-1963)surface="primary", the PRIMARY Mission dir, surface_root = the repository root checkout, establishment=NONE. This is checked before any coordination probe, so it never raises CoordinationBranchDeleted and never writes into a torn-down worktree (D23).
COORD kind, coordination-routed, state MATERIALIZEDthe coordination Mission dir. No side effects unless a seed commit is pending: a seed commit is pending iff the coordination branch history has no Spec-Kitty-Coordination-Seed: <mission_id> trailer and the coordination tip has no COORD-kind blob under the Mission dir, i.e. the Mission dir is wholly untracked, which only a refused seed leaves. A never-seeded pre-fix MATERIALIZED Mission (#5519 shape) has committed COORD blobs at the tip, so it never matches. When it is pending, the next seed attempt re-commits the dir with the trailer before the write (contracts/seed.md).
COORD kind, coordination-routed, state UNMATERIALIZED with a local branch (whether or not the branch already carries artifacts of this kind; D22)the worktree is materialized via CoordinationWorkspace.resolve; then the MATERIALIZED or EMPTY row applies
COORD kind, coordination-routed, state EMPTY, no seed marker on the coordination branch (pre-fix Mission, D4)seeded (contracts/seed.md); establishment=SEEDED
COORD kind, coordination-routed, state EMPTY, seed marker present (post-fix Mission; a regression)loud WARNING, then COORD-kind paths only are restored from the branch tip (never PRIMARY files), then any root-only records are seeded; establishment=RESTORED_FROM_BRANCH

The following hold in every case:

  • write_dir(kind).surface matches the partition of write_target(kind).ref: the coordination worktree's branch is write_target(kind).ref. A property test over every MissionArtifactKind pins this.
  • The returned path exists on return for COORD kinds of a coordination-routed Mission.
  • A seed is logged at WARNING with its SeedReport, so callers that do not render it still surface it.

Errors (raised; nothing is written)

CodeWhenException (existing unless marked)
COORDINATION_BRANCH_DELETEDmeta.json declares a coordination branch that does not existCoordinationBranchDeleted
COORDINATION_WORKTREE_UNMATERIALIZEDbranch is remote-only (#4970 parity), or materialization failed after one re-probeCoordinationWorktreeUnmaterialized via _raise_unmaterialized (surface_resolver.py:877)
COORD_SEED_FORK_REFUSED (new)seed found a true fork (contracts/seed.md)CoordSeedForkRefused(ActionContextError) (new)
STATUS_LOCK_HELD (new code on an existing exception)status lock timeout during the seedFeatureStatusLockTimeoutError (status/locking.py:59); the error_code attribute is added
OWNED_COORDINATION_WORKSPACE_UNAVAILABLEowned checkout whose coordination workspace cannot be establishedexisting OwnedRefusalCode

Every error message names the Mission, the coordination branch, and a recovery command.

Relationship to existing seams

  • assert_coord_write_materialized (write_target_degrade.py:157-261, formerly "the single decision locus for S-C"): absorbed (D22). It becomes a thin delegate to this accessor.
  • MATERIALIZED/EMPTY: no-op, except that a MATERIALIZED Mission with a pending seed commit (see the MATERIALIZED row above) gets its seed re-committed, as through write_dir.
  • UNMATERIALIZED with a local head: delegates to write_dir(kind), which materializes and then restores or seeds, instead of refusing when the branch already carries the kind.
  • Remote-only: still refuses with COORDINATION_WORKTREE_UNMATERIALIZED.

Its callers (resolve_write_target_or_degrade(..., terminus_write=True), write_seam.write_artifact) are unchanged. Its old local-head refusal pins are re-pinned deliberately.

  • resolve_placement_only / write_target: unchanged. They give the commit ref and are materialization-blind. write_dir gives the directory.
  • read_dir: unchanged. It keeps the EMPTY → PRIMARY read fallback (C-002). It is never a write location for a COORD kind; the FR-014 gate enforces that.
  • materialize_coord_surface_for_write (surface_resolver.py:903): becomes step 1 of establish_coord_write_location. Its two writer callers (decisions/service.py:501,696) switch to write_dir. The doctor coordination caller (_coordination_doctor.py:1424) keeps calling it, because it repairs a worktree and is not a writer.
  • resolve_status_surface_with_anchor(for_write=True): unchanged, and documented as "commit-ref oriented, not a write location". The read-side EMPTY warning goes loud for post-fix Missions in both coordination topologies.
  • status_transition._coord_feature_dir (L400) and transaction.py:490's inline composition: both are replaced by write_dir(STATUS_STATE).path. No second composer remains.

Compatibility

  • Additive API. No existing signature changes.
  • Writers migrate call by call (IC-04). The FR-014 gate ratchets the migration.
  • lanes and single_branch Missions: byte-identical behaviour (C-008).