Contracts

dependency-guard.md

Contract: Dependency Readiness Guard (WP04)

Owner: src/specify_cli/status/ (models.py, wp_state.py) · Verdict supplier: the two shells (contracts/emit-pipeline.md §2 step 3) Binding decisions: Q8 (01M1V8HVDQH36X06JDK22SZV02) · Constraints: C-004, C-005, NFR-002 · Non-goal 6: no edge/force/terminal changes

1. Field

# src/specify_cli/status/models.py — GuardContext (+1 field, established shape)
dependency_ready: bool | None = None

2. Polarity (tri-state, fail-OPEN on None)

dependency_readyplanned→claimed, claimed→in_progressall other edges
Nonepass (no verdict supplied)pass
Truepasspass
Falserefuse with message "Transition <from> -> <to> blocked: unsatisfied dependencies <ids>"; force with actor+reason bypasses (existing _check_force path)pass

Why fail-open (recorded so nobody "fixes" it to match subtasks_complete, wp_state.py:370): lanes/recovery.py:78 (crash-recovery progression probe) and agent/tasks_transition_core.py:337 (FR-015 force-free backward-edge probe) call validate_transition with self-built contexts on exactly these edges. Fail-closed on None silently breaks both. It is sound because after WP03 no durable write bypasses the shells, and the shells always supply a verdict.

3. Guard clause placement

wp_state.py: one clause inside the existing guard_for for targets Lane.CLAIMED (from PLANNED) and Lane.IN_PROGRESS (from CLAIMED), via the subtasks_complete shape. Guards never consult ctx.force (existing rule, #1775 M2).

4. Resolution site (FR-013)

Shells compute, inside L1 / the transaction, against the shell's write surface:

readiness = dependency_readiness_for_wp(
    wp_id, dependencies_for(wp_id, feature_dir), wp_lanes_from(feature_dir), provenance_from(feature_dir)
)
  • Never before acquiring the lock (reproduces the pre-flight TOCTOU).
  • Never against the primary planning dir when the write surface is the coord worktree (stale state).
  • dependency_readiness_for_wp (core/dependency_graph.py:34) is reused as-is: approved OR done OR canceled-with-operator-provenance satisfies.

5. Demoted pre-flight sites (FR-014) — left in place, re-commented as UX

cli/commands/implement.py:1340, agent/workflow_executor.py:653, agent/tasks_status_view.py:228, orchestrator_api/commands.py:1115,1440, runtime/next/discovery.py:150.

6. Replay purity (C-005 / NFR-002)

reducer.py keeps zero references to validate_transition / GuardContext; validate.py:validate_transition_legality re-checks edge legality only. Test: a history containing a now-dep-illegal claim replays and audits with no guard finding.

7. Test matrix (RED-first then unit)

CaseExpected after WP04RED on main?
direct verdict-less emit through a shell, planned→claimed, dep in_progressrefusedyes (succeeds today)
GuardContext(dependency_ready=False) → validate_transition on entry edgesrefusedn/a (field absent today)
both probe sites with dependency_ready=Nonepasspin
dep approved (not done)claim allowedpin
dep in_progress + force with actor+reasonallowed, recordedpin
coord topology: readiness resolved against txn.feature_dirverified by fake-surface testpin
implement re-invoked on in_progress WPno-op resume, not re-gatedpin

emit-pipeline.md

Contract: Transition Pipeline and Composition Shells (WP01 / WP02)

Owner: Mission Management (src/specify_cli/status/) · Consumers: coordination/status_transition.py shells, status/emit.py shells, status/aggregate.py Binding decisions: Q4 (01M1V80R6F6RTMR7Y3C2WBKR32), Q1, Q5, Q7 · Constraints: C-001, C-006, C-007, C-008, NFR-001, NFR-004

1. Pure pipeline (status-owned; the named validation/build authority)

# src/specify_cli/status/<pipeline>.py   (module name chosen in WP02; must not import coordination)

@dataclass(frozen=True)
class PreparedTransition:
    event: StatusEvent | None        # None ⇒ alias-collapse no-op arm (mirror only)
    resolved_lane: str
    annotation: StatusEvent | None   # resolved-binding annotation, if any
    mirror_frontmatter_lane: bool    # True when the phase-gated frontmatter mirror must run

def prepare_transition(
    *,
    request: TransitionRequest,
    feature_dir: Path,                       # the shell's WRITE surface (txn.feature_dir under coord)
    mission_slug: str,
    mission_id: str | None,
    from_lane: str,                          # derived by the shell, in-lock, exactly once
    readiness: DependencyReadiness | None,   # WP04: resolved by the shell, in-lock, against feature_dir
    at: str | None = None,
    # injected I/O (defaults = today's helpers; tests pass fakes)
    resolve_subtasks_dir: Callable[..., Path] = resolve_subtasks_gate_dir,
    infer_subtasks_complete: Callable[..., bool | None] = _infer_subtasks_complete,
    infer_implementation_evidence: Callable[..., bool | None] = _infer_implementation_evidence,
) -> PreparedTransition: ...

Steps, in order (promoted verbatim from _prepare_event, coordination/status_transition.py:842-945): 1. alias-resolve to_lane; 2. infer review gates (subtasks_complete, implementation_evidence_present) only for in_progress→for_review without force; 3. alias-collapse check → return PreparedTransition(event=None, mirror_frontmatter_lane=True); 4. build done-evidence; 5. build GuardContext (incl. dependency_ready = readiness.satisfied if readiness is not None else None); 6. validate_transition(from_lane, resolved_lane, ctx) — the only call in the tree per emit; raise TransitionError on refusal; 7. build_status_event(...); attach annotation.

Guarantees

  • G-1 No file writes, no lock, no git, no network. Unit-testable with an in-memory feature_dir + fakes.
  • G-2 Idempotent for equal inputs except event_id/at generation (ULID + clock are injectable in tests).
  • G-3 Imports: status.models, status.transitions, status.wp_state, status.emit helpers per Q6 — never coordination.

2. Shell contract (exactly two kinds)

Every shell MUST perform, in this order, with L1 held from step 1 through step 6:

StepFlat/primary shell (status/emit.py)Transactional shell (coordination/status_transition.py)
1 acquirefeature_status_lock(resolve_status_lock_root(feature_dir, repo_root), feature_dir.name)BookkeepingTransaction.acquire(...) (holds L1)
2 derive_derive_from_lane(feature_dir, wp_id) — once_derive_from_lane(txn.feature_dir, wp_id) — once
3 readinessdependency_readiness_for_wp(...) read from feature_dirsame, read from txn.feature_dir
4 prepareprepare_transition(...)prepare_transition(...)
5 persistatomic append (store.append_*_atomic_verified) → materialize → mirrortxn.append_events([...])
6 releaselock releasedtxn commits (coord) then releases
7 fan-outimmediately after release (flat/LANES: no commit exists)txn.defer_outbound(...) / queue_saas_emission(txn, …) — fires only after commit success

Batch variants run steps 2–5 per request under ONE acquisition (FR-018) and apply the owned-mission check + effective_root exactly as the single variant (Q5).

Coord fallback arm (_fallback_emit_single._coord, :404-432): invokes the flat shell with fan_out=False, commits, then fans out; on commit failure _restore_coord_status_artifacts truncates and no fan-out occurs (SC-002). The flat shell therefore exposes a fan_out: bool = True keyword (or an equivalent deferred-callable return) — the exact shape is WP02's choice; the observable contract is "zero fan-out for a truncated event".

Prohibited

  • A third composition of lock + append anywhere outside these two modules (WP03 gate, R14).
  • Any shell resolving readiness before acquiring L1, or against a directory other than its write surface (FR-013).
  • Any shell calling validate_transition itself (P-2).

3. Lock rules encoded (WP01, FR-003)

RuleWhereTest
(a) L1 takes reachable from an L3 verdict-save-queue scope are boundedcli/commands/agent/tasks_verdict_persistence.py ~:546-561, review/cycle.py:804,881 pattern reusedNFR-003 timeout test: contended lock ⇒ structured timeout error naming holder
(b) merge-path retrospective L1 take is finitemerge/executor.py retro path (touch)contended ⇒ structured error, merge not refused repo-wide
(c) one lock keystatus/locking.py:feature_status_lock_path keyed on feature_dir.name via resolve_status_lock_roottwo colliding slugs ⇒ distinct files; bare legacy slug ⇒ same file as its ordinary writers

4. Writer hardening pattern (WP01, FR-002)

For each of families ④⑤⑥⑦ (data-model.md §2):

root = resolve_status_lock_root(feature_dir, repo_root)   # repo_root may be None
with feature_status_lock(root, feature_dir.name, timeout=<finite where rule (a)/(b) applies>):
    <any idempotency / Lamport read>                        # moved INSIDE the lock
    append_events_atomic_verified(feature_dir, rows)        # atomic primitive, one call for paired appends

The nullcontext() no-git degrade is a per-site conscious choice documented in a one-line comment at each site.

5. Plain-door semantics pin (C-008, SC-007)

test_plain_door_lanes_mission_gains_no_commits: a stored-LANES mission emitted through emit_status_transition (as the fallback arm does) leaves git rev-list --count HEAD unchanged and spawns no identity-resolution git subprocess. Rationale cites _fallback_emit_single._primary (status_transition.py:392), not the four docstring mentions (research.md §3 D-1).

6. Cost pin (NFR-004, Q7)

test_emit_reads_log_once: with _derive_from_lane and read_events wrapped by call counters, one emit through each shell ⇒ _derive_from_lane count == 1, full-log read count == 1.

run-state-store.md

Contract: Run-State Store Hardening (WP05)

Owner: src/runtime/next/ (_internal_runtime/engine.py, runtime_bridge_io.py, decision.py) · Sequencing: no dependency on WP01–WP04; merges FIRST (Q10 rider, 01M1V8HS2Q04T2VS1DCKHV2E1C) to free the four files shared with Mission B Requirements: FR-015, FR-016, FR-017, SC-006 · Constraint: C-003 (083 identity) · Deferred: Q9 (01M1V8J842E7CJR6MGZ0MW3DQF) index migration shape + legacy key

1. Index — .kittify/runtime/feature-runs.json (FR-016)

// after WP05
{
  "<mission_id ULID>": { "run_id": "…", "run_dir": "…", "mission_type": "software-dev", "mission_slug": "<display only>" },
  "legacy-<slug>":     { … }   // ONLY if Q9 adopts the precedent; never used for a mission that has a mission_id
}
  • Lookup in _load_or_start_run (runtime_bridge_io.py:611-660) is index.get(mission_id); mission_slug is never a lookup key (:618 today).
  • Loud missing-state: if an entry exists and run_dir/state.json is absent ⇒ raise a StructuredError subclass (e.g. RunStateMissing(mission_id, run_id, run_dir)) with a recovery hint; never fall through to "start a new run" (:617-627 today).
  • Migration shape (in-place rekey on first touch vs one-shot) and the legacy key: Q9, decided in WP05's design note; either shape must be idempotent and must not lose any existing entry.

2. Cursor + journal atomicity (FR-015) — _internal_runtime/engine.py

def _write_snapshot(run_dir: Path, snapshot: MissionRunSnapshot) -> None:
    target = run_dir / "state.json"
    tmp = target.with_suffix(".json.tmp")            # same directory ⇒ same filesystem
    with open(tmp, "w", encoding="utf-8") as fh:
        json.dump(snapshot.model_dump(mode="json"), fh, indent=2, sort_keys=True, default=str)
        fh.flush(); os.fsync(fh.fileno())
    os.replace(tmp, target)                            # atomic on POSIX and NTFS

(the reducer.materialize precedent, ~6 lines). _append_event (:110-119) hardens with the same discipline or an atomic-append helper; no L1 involvement (run journal is per-run, single-writer).

3. Pure progress read (FR-017) — decision.py:369-377

from specify_cli.status import materialize_snapshot replaces materialize; the surrounding try/except: pass becomes a logged fallback (no silent swallow — Sonar rule, CLAUDE.md).

4. Tests (RED-first then unit)

CaseExpectedRED on main?
two missions, equal slug, distinct mission_iddistinct runsyes (:618)
kill between old-truncate and new-write of state.json (simulated by raising inside the write)old file intact or new complete; never partialyes
progress query on a mission with tracked status.jsonbytes identical before/afteryes (materialize writes)
index entry present, state.json deletedstructured error, no new run createdyes (silent fresh run)
index migration (per Q9) run twiceidempotent, no entry lostpin

5. Ledger note

WP05 stays inside already-ledgered runtime subpackages; if any lazy reach into specify_cli is removed, the same PR updates _RUNTIME_ALLOWED_SPECIFY_CLI / the doctrine-scan baseline (test_runtime_ledger_has_no_stale_entries).

write-gates.md

Contract: Raw-Append Facade Strip and Write Gates (WP03)

Owner: src/specify_cli/status/ + tests/architectural/ · Depends on: WP01's fixed census Requirements: FR-010, FR-011, SC-004 · First instance of the census-gate rule (note to be posted on #3895 per the PR body)

1. Facade strip — status/_unsafe.py (FR-010)

  • Move the six append_event* names out of status/__init__.__all__ (:518-537) into status/_unsafe.py as re-exports of status/store.py.
  • status/_unsafe.py declares ALLOWED_CALLERS: frozenset[str] (dotted module paths), seeded from WP01's fixed census: families ②③⑤⑥⑦ (data-model.md §2) + status/emit.py + coordination/status_transition.py.
  • Shrink-only: tests/architectural/test_status_unsafe_allowlist.py holds the committed baseline set; the test asserts ALLOWED_CALLERS ⊆ BASELINE and that every module importing status._unsafe (AST scan of src/) is in ALLOWED_CALLERS. Adding a caller fails the build; removing one requires editing both sets (deliberate).

2. AST writes-gate (FR-011)

tests/architectural/test_status_events_writes_gate.py scans src/*/.py and FAILS when any module other than src/specify_cli/status/store.py contains:

  • open(<expr>, "a" | "a+" | "w" | "w+" | "ab" | "wb" …) where <expr> is a string literal, f-string, Path(...)/…, or name whose static text ends in status.events.jsonl;
  • Path(...).write_text/write_bytes/open(...) on such a path;
  • os.open(...) with O_WRONLY|O_APPEND|O_TRUNC on such a path.

Resolution is static (best-effort name tracking within the function); a dynamic path that the scanner cannot resolve is NOT a pass — the gate also asserts a positive census: the set of modules where the scanner finds an allowed write (store.py) is exactly the expected set.

3. Non-vacuity floors (SC-004) — mandatory for both gates

GateFloor testMechanism
allowlisttest_allowlist_gate_is_not_vacuousInject a synthetic module (tmp package on sys.path, or an AST fixture string) that imports status._unsafe and is NOT allowed ⇒ gate reports the violation. Also: len(ALLOWED_CALLERS) >= 1 and every listed module actually imports _unsafe (a stale allowlist entry fails).
writes-gatetest_writes_gate_is_not_vacuousFeed the scanner a synthetic source string with open(p, "a") where p = ".../status.events.jsonl" ⇒ violation reported. Feed it store.py ⇒ exactly the expected allowed sites found (count > 0). A scan matching zero writers anywhere ⇒ FAIL.

Also covered by the writes-gate (R14): a third feature_status_lock(...) + append composition outside status/emit.py / coordination/status_transition.py / the WP01-hardened writer modules is reported (the gate scans for feature_status_lock call sites and asserts the set equals the census).

4. Rollout

1. Land WP01 (census fixed) → 2. add _unsafe.py + move exports → 3. add both gates GREEN with floors → 4. post the census-gate-rule note on #3895 → 5. delete/relocate any transitional red-first repro (C-009).