Implementation Plan: Canonical-State Integrity & Recovery
Branch: fix/canonical-state-recovery | Date: 2026-09-20 | Spec: spec.md Input: Feature specification; research in research.md; post-plan squad corrections folded (see Design decisions & tracer).
Summary
Two P0 defects share one shape: a documented CLI path drives the mission's canonical runtime state into a form every downstream gate refuses, with no operator command to repair it. This plan closes #4758's wedge by construction (legacy finalize never half-finalizes; move-task guards the planned boundary), makes the mission recoverable via one documented rebuild action homed in doctor mission-state --fix, and fixes #4786 at the read-root (derive durable implementer provenance from the immutable event log) rather than by re-writing a live-claim slot. Binding invariant: every refusing gate names a repair path; every repair path re-establishes the field the gate reads.
Technical Context
Language/Version: Python 3.11+. Primary Dependencies: typer, specify_cli (status, lanes, acceptance, cli/commands/agent, migration). Storage: append-only status.events.jsonl + reduced snapshot; lanes.json. Testing: pytest (ATDD red-first, @pytest.mark.regression); ruff + mypy; complexity ≤15. Project Type: single. Constraints: no new suppressions; diff-cover ≥90% new code; terminology canon; layer discipline (lanes/ is a pure domain layer — no CLI/console/policy imports).
Constitution / Charter Check
- Single canonical authority (DIRECTIVE_044): narrow shared "wedge predicate" (execution-begun AND lanes-absent) used by BOTH the finalize refusal and the recovery detector; the broad triple-authority collapse is rejected with rationale (see Design decision 5). ✅
- ATDD-first (C-003): issue-pinned regression repro RED through the pre-existing entry point before each fix. ✅
- Extend, don't invent (C-002): recovery is a new action inside the existing
doctor mission-statesurface; no new command family. ✅ - Layer discipline: the extracted lane-compute core stays pure in
lanes/; CLI wrapper stays inmission_finalize.py. ✅ - Complexity ≤15, tests-with-branches, real fixes over suppression, terminology canon. ✅
- No Complexity-Tracking violations.
Architecture / Design
Data model (canonical runtime state)
- Claim triple
agent+shell_pid+shell_pid_created_at(status/models.py:535-551) — a live current-actor marker that legitimately changes hands (implementer→reviewer). Released on rollback→planned viarelease_runtime_claim(:569). This is correct and stays untouched. - Durable implementer provenance — does NOT exist as a field today; lives only implicitly in the append-only event log (the implementer's claim event
policy_metadata,tasks_move_task.py:2567-2571). #4786's fix surfaces it as a read-side projection. lanes.json— computed execution-lane manifest; presence gates approval/implement (require_lanes_json,lanes/persistence.py:111).- Wedge predicate — the pair (
_execution_has_begunTrue ANDlanes.jsonabsent); the #4758 anti-state, shared by the finalize refusal and the recovery detector.
Design decisions (post-squad corrected)
1. #4758 close-by-construction (FR-001/FR-002). (a) Legacy agent tasks finalize-tasks co-locates the lanes.json write with the event-log bootstrap (or refuses to half-finalize and names the canonical command). (b) move-task refuses to move a WP out of planned when lanes.json is absent (via require_lanes_json at tasks_move_task.py:638-642), naming the repair. 1b lives in tasks_move_task.py = WP02. 2. Recovery homed in doctor mission-state --fix (FR-003, C-002). A new action inside run_mission_state/repair_repo (migration/mission_state.py:685, already --mission-scoped and primary-anchored at :501-532, with no coordination-partition gate). Rebuilds lanes.json from the event log when the wedge predicate holds, reusing WP01's extracted pure core. Not agent mission repair — its shas is None early return (mission_repair.py:286-292) short-circuits exactly the non-coordinated topologies (SINGLE_BRANCH/LANES/flat) where the #4758 wedge is reachable. Home change recorded in tracer; operator's scope ruling (a recovery command exists) preserved. 3. #4786 fixed at the read-root (FR-004/FR-005). Add _project_implementer_attribution to status/reducer.py, a sibling of the existing _project_cancellation_provenance (reducer.py:67-90): scan the raw event stream for the prior implementer-role claim and expose a derived attribution slot the fold/release never touches. The accept gate (summary_core.py:79/86-87) reads that derived slot. Do NOT edit tasks_move_task.py:2891/:2970 for attribution (that would be patch #5, fighting #4673) and do NOT touch the upstream spec_kitty_events.diary fold. Honest by construction: a never-owned WP has no claim event → projection yields nothing → the gate correctly refuses. 4. Read-side family + honest messages + latent guard (FR-006/FR-007/FR-008). summary_core.py:86-98 reads agent/assignee/shell_pid through one shared accessor; the doctor.py:215-240 blanked-slot detector is narrowed to genuine on-disk agent: "" blanks (its true #2960 remit) and its recommended-action reworded (a released-but-historically-owned WP is no longer a finding — the projection makes the gate pass). Every #4758 refusal (finalize refusal mission_finalize.py:2210-2230, MissingLanesError persistence.py:115, protected-branch tasks_transition_core.py:407, move-task guard) names its repair. merge.py:259/456 (mission or "").strip() guarded against an unresolved OptionInfo default. 5. Narrow wedge-predicate fold ONLY (C-001). Introduce one named predicate for (execution-begun AND lanes-absent) shared by the finalize refusal and the recovery detector so detect≡cure. The broad "single execution-ready predicate across ~18 gates" is rejected/deferred: file-existence gates consume the LanesManifest object (not a bool), the file-vs-log disagreement is the #4758 signal itself, and ~6 soft read_lanes_json→None sites are legitimately None-tolerant (coord husks, topology probes — workflow.py:214-232, lifecycle_sync.py:144, backfill_topology.py:56).
Structure Decision
Single project. Touched source: cli/commands/agent/{tasks_finalize,mission_finalize,tasks_move_task,tasks_transition_core,tasks}.py, new lanes/compute_and_persist.py, lanes/persistence.py (message), migration/mission_state.py + cli/commands/_mission_state_doctor.py, status/reducer.py, status/doctor.py, acceptance/summary_core.py, cli/commands/merge.py. status/models.py is off-limits (no WP needs it — verified). Tests mirror under tests/cli/, tests/status/, tests/acceptance/, tests/unit/migration/.
Parallel Work Analysis
Dependency Graph
WP01 (#4758 finalize minting + EXTRACT pure compute_and_write_lanes → lanes/ + wedge predicate)
│ (WP03 imports the pure core + shared wedge predicate)
▼
WP03 (recovery: lanes.json rebuilder in doctor mission-state --fix)
WP02 (#4758 move-task planned-boundary guard) ─┐ independent write-scopes,
WP04 (#4786 attribution projection + read-side + folds)─┘ parallel with WP01 and each other
- Sequential: WP01 → WP03.
- Parallel: WP02 ∥ WP04 ∥ WP01.
Work Distribution (owned files — write-scope-disjoint)
- WP01:
cli/commands/agent/tasks_finalize.py,cli/commands/agent/mission_finalize.py(CLI wrapper + finalize-refusal message + wedge predicate), newlanes/compute_and_persist.py,lanes/persistence.py(MissingLanesError message),cli/commands/agent/tasks.py(registration, only if signature changes). - WP02:
cli/commands/agent/tasks_move_task.py(planned-boundary guard only — NOT attribution),cli/commands/agent/tasks_transition_core.py(protected-branch refusal message). - WP03:
migration/mission_state.py,cli/commands/_mission_state_doctor.py(new lanes-rebuild action; imports WP01 pure core + shared wedge predicate read-only). - WP04:
status/reducer.py(_project_implementer_attribution),acceptance/summary_core.py(read derived slot + family accessor + name repair),status/doctor.py(narrow detector + reword),cli/commands/merge.py(OptionInfo guard).
Coordination Points
status/models.py— untouched by all WPs (highest latent collision if any WP touches the reducer release-order; watch it).- WP01 lands the pure-core extraction before WP03 builds on it (enforced by the lane dependency).
- Integration checks: after WP01+WP03 the #4758 wedge is repairable end-to-end; after WP04 the rejection→re-review→approve repro reaches
acceptclean via derivation.
Risks
- R1 — legacy-finalize contract change: prefer refuse-and-delegate if any consumer depends on events-only; verify with
tests/cli/finalize coverage. - R2 — recovery must not rewrite existing lanes: rebuild only when the wedge predicate holds (lanes absent); preserve #3311's guard. NFR-004 idempotence test.
- R3 — projection honesty: derive attribution only from a real implementer claim event; never fabricate. Covered by the honest-gate edge case + the never-owned test.
- R4 — extraction layer inversion: extract the pure core only (no console/JSON/policy into
lanes/); keep the wrapper inmission_finalize.py; passplanning_commit_sha+mission_idas resolved inputs. - R5 — doctor mission-state action scope: the new action must not disturb
repair_repo's existing JSON-shape canonicalization; add as a distinct, independently-tested branch.
Test Strategy (ATDD red-first)
- Per defect: issue-pinned
@pytest.mark.regressionRED through the pre-existing CLI entry point (#4758 wedge repro; #4786 rejection-cycle repro) before the fix. - NFR-001 matrix over the documented command surface: no state refuses both advance and repair.
- NFR-002 parity: every detected canonical-state condition has a clearing repair (or is no longer a finding under the projection).
- NFR-004: determinism (same log → byte-identical lanes) + idempotence (repair on healthy = no-op); projection idempotent/read-only.