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_ready | planned→claimed, claimed→in_progress | all other edges |
|---|---|---|
None | pass (no verdict supplied) | pass |
True | pass | pass |
False | refuse 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)
| Case | Expected after WP04 | RED on main? |
|---|---|---|
direct verdict-less emit through a shell, planned→claimed, dep in_progress | refused | yes (succeeds today) |
GuardContext(dependency_ready=False) → validate_transition on entry edges | refused | n/a (field absent today) |
both probe sites with dependency_ready=None | pass | pin |
dep approved (not done) | claim allowed | pin |
dep in_progress + force with actor+reason | allowed, recorded | pin |
coord topology: readiness resolved against txn.feature_dir | verified by fake-surface test | pin |
implement re-invoked on in_progress WP | no-op resume, not re-gated | pin |
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/atgeneration (ULID + clock are injectable in tests). - G-3 Imports:
status.models,status.transitions,status.wp_state,status.emithelpers per Q6 — nevercoordination.
2. Shell contract (exactly two kinds)
Every shell MUST perform, in this order, with L1 held from step 1 through step 6:
| Step | Flat/primary shell (status/emit.py) | Transactional shell (coordination/status_transition.py) |
|---|---|---|
| 1 acquire | feature_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 readiness | dependency_readiness_for_wp(...) read from feature_dir | same, read from txn.feature_dir |
| 4 prepare | prepare_transition(...) | prepare_transition(...) |
| 5 persist | atomic append (store.append_*_atomic_verified) → materialize → mirror | txn.append_events([...]) |
| 6 release | lock released | txn commits (coord) then releases |
| 7 fan-out | immediately 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_transitionitself (P-2).
3. Lock rules encoded (WP01, FR-003)
| Rule | Where | Test |
|---|---|---|
| (a) L1 takes reachable from an L3 verdict-save-queue scope are bounded | cli/commands/agent/tasks_verdict_persistence.py ~:546-561, review/cycle.py:804,881 pattern reused | NFR-003 timeout test: contended lock ⇒ structured timeout error naming holder |
| (b) merge-path retrospective L1 take is finite | merge/executor.py retro path (touch) | contended ⇒ structured error, merge not refused repo-wide |
| (c) one lock key | status/locking.py:feature_status_lock_path keyed on feature_dir.name via resolve_status_lock_root | two 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) isindex.get(mission_id);mission_slugis never a lookup key (:618today). - Loud missing-state: if an entry exists and
run_dir/state.jsonis absent ⇒ raise aStructuredErrorsubclass (e.g.RunStateMissing(mission_id, run_id, run_dir)) with a recovery hint; never fall through to "start a new run" (:617-627today). - 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)
| Case | Expected | RED on main? |
|---|---|---|
two missions, equal slug, distinct mission_id | distinct runs | yes (:618) |
kill between old-truncate and new-write of state.json (simulated by raising inside the write) | old file intact or new complete; never partial | yes |
progress query on a mission with tracked status.json | bytes identical before/after | yes (materialize writes) |
index entry present, state.json deleted | structured error, no new run created | yes (silent fresh run) |
| index migration (per Q9) run twice | idempotent, no entry lost | pin |
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 ofstatus/__init__.__all__(:518-537) intostatus/_unsafe.pyas re-exports ofstatus/store.py. status/_unsafe.pydeclaresALLOWED_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.pyholds the committed baseline set; the test assertsALLOWED_CALLERS ⊆ BASELINEand that every module importingstatus._unsafe(AST scan ofsrc/) is inALLOWED_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 instatus.events.jsonl;Path(...).write_text/write_bytes/open(...)on such a path;os.open(...)withO_WRONLY|O_APPEND|O_TRUNCon 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
| Gate | Floor test | Mechanism |
|---|---|---|
| allowlist | test_allowlist_gate_is_not_vacuous | Inject 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-gate | test_writes_gate_is_not_vacuous | Feed 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).