Research — Mission A — P0 Read/Write Consistency

Phase 0 output. Four resolution decisions, each grounded in a live trace against main. A pre-spec research squad (debugger-debbie · reviewer-renata · paula-patterns) and a Phase-0 #3334 trace produced the evidence below; contested findings and their dispositions are recorded per contracts/adversarial-evidence-contract.md conventions in the final section.

Supply-chain note: this mission adds/updates/removes no dependency — the supply-chain-install-safety directive is N/A (no lifecycle-script or registry surface is introduced).


R-01 — #3320 retrospect --update reports/emits the persisted record

after write_gen_record(...) returns the path, read the persisted record back via the existing read_gen_record(record_path) (src/specify_cli/retrospective/reader.py:329) and build the JSON counts/ findings_status and the emit_captured(...) payload from that read-back record — not from the pre-merge record.

and recomputes findings_status inside the writer, but returns only a Path (writer.py:498); the CLI reports from the stale pre-merge object (retrospect.py:405/423/442). Read-back is a no-op for overwrite/error/backfill (persisted == new) and corrects only the --update divergence.

(Path→record). Rejected — wide blast radius: ~4 callers use the Path (retrospect.py:733, agent_retrospect.py:310, …) and ~30 test assertions + mocks return a Path. If enrichment is ever wanted it must be additive ((path, record)), never a replacement (C-002).

emit_captured→None). Add a focused test asserting the emitted event payload equals the persisted record (FR-002 / NFR-002).

  • Decision: In create_cmd (src/specify_cli/cli/commands/retrospect.py),
  • Rationale: write_gen_record(mode="update") merges with the on-disk record
  • Alternatives considered: change write_gen_record to return the record
  • Guard beyond the repro: the committed repro asserts JSON only (it patches

R-02 — #3231 verdict special-cases only the empty scaffold placeholder

(src/specify_cli/acceptance/matrix.py:249-272), exempt a criterion from the pending-dominates rule iff description == SCAFFOLD_TODO_MARKER (the contentless placeholder), and only when at least one non-scaffold criterion exists. An all-scaffold matrix stays pending.

scaffold placeholder (AC-001) is a pending row. The scaffold builder writes the marker into two shapes: the empty placeholder with description == SCAFFOLD_TODO_MARKER (matrix.py:531) and one row per functional requirement with a real description=f"Verify {req_id}…" carrying the marker only in notes (matrix.py:517-520). Keying on description therefore uniquely targets the empty placeholder (CONFIRMED at matrix.py:517 vs :531).

false-accept**: it also exempts seeded-but-unauthored FR rows, so 9-of-10 unauthored requirements → verdict pass through the accept gate (gates_core.py:525-529). This is the load-bearing correction (C-003).

Rejected — the reconciler is correct (FR-008 row-union); a leftover scaffold in a hand-authored/non-merged matrix would still poison. The verdict property is the single authority and the right seam.

pending) and all-scaffold (→ pending) tests are mandatory (FR-004 / NFR-002); the repro pins only the empty-AC-001+all-pass case.

  • Decision: In AcceptanceMatrix.overall_verdict
  • Rationale: overall_verdict:263 lets any pending row dominate; the empty
  • Alternatives considered:
  • Key on notes == SCAFFOLD_TODO_MARKER. **Rejected — demonstrated
  • Fix the merge-driver reconciler to drop the scaffold row.
  • Guard beyond the repro: partial-authoring (9/10 FR rows pending

R-03 — #3334 failed upgrade leaves a recoverable project (Phase-0 trace)

ProjectMetadata round-trip spec_kitty.schema_versionload() (metadata.py:126) reads it into a model field; save() (:188-210) writes it back; remove the now-unneeded schema_version entry from _mask_volatile_metadata (:23-47). This stops the strip at its source, so a failed upgrade's save() preserves the loaded schema_version and a previously-healthy schema-N project stays schema-N. It subsumes the earlier minimal runner restore-on-failure patch (nothing to restore once save() no longer strips). Keep the runner success-path advance to REQUIRED_SCHEMA_VERSION (runner.py:189-190) and the dry_run-writes-nothing guard. pre_schema is None (genuine pre-3.x) → nothing to round-trip → stays NoneLEGACY-blocked (FR-006). Keep migration/runner.py:193 _update_schema_version consistent. Do not touch compat/planner.py or safety.py.

gated behind if not dry_run and result.success: (runner.py:181). On a migration failure, _apply_migration records the migration "failed"metadata.save() (runner.py:487-489/288) → ProjectMetadata.save() (metadata.py:188-210) rebuilds YAML from a fixed 3-key model with no schema_version, stripping it (the appended failed record changes migrations.applied, so the masked compare-before-write fires a real write). The restore is skipped. On re-run, has_migration() counts only result == "success" (metadata.py:242), so the migration re-fails and the restore is never reached — self-perpetuating. Exit-4 then surfaces two ways: the startup gate (migration/gate.py:146-154, wired __init__.py:140) for non-exempt commands, and upgrade --json --project (upgrade.py:688, 1136-1163) directly.

committed repro pins). Rejected — treats the symptom, duplicates schema truth into a drift-prone heuristic, risks weakening the genuine-pre-3.x LEGACY guard, trusts spoofable metadata, and still does not let spec-kitty upgrade complete (the runner keeps exiting without restoring).

unconditionally on failure). Subsumed by the chosen save() root fix — once save() stops stripping there is nothing to restore. Was the earlier minimal design; the operator pulled the durable save() fix into scope, so the root fix is preferred (paula/architect flagged the save() strip as a latent class-of-bug, not a one-off). Blast radius is contained: callers that re-stamp still write the same value; save() simply stops dropping the key.

tests/regression/test_issue_3334_failed_upgrade_wedges_repair.py (which drives compat plan() with a fabricated UNSAFE command and asserts on Decision) with a reproduction driving the real MigrationRunner(project_path).upgrade(target) (or the upgrade CLI via CliRunner) with a stub failing migration injected into MigrationRegistry, starting from schema_version: 3 present + version behind + 3.x success history. Post-conditions: (1) get_project_schema_version == 3 (not None) after the failed upgrade; (2) real gate check_schema_version(project_path, "plan") does not raise SystemExit; (3) negative guard — genuine pre-3.x still LEGACY and gate raises SystemExit(4).

  • Decision (root fix — save() IN SCOPE, operator directive): make
  • Rationale (traced live): the restoring re-stamp (runner.py:189-190) is
  • Alternatives considered:
  • Teach _scan_project to read migrations.applied (the classifier change the
  • Minimal runner restore-on-failure (capture pre_schema, stamp
  • Repro replacement (C-006): replace

R-04 — #3311 re-finalize preserves provenance once execution has begun

(src/specify_cli/cli/commands/agent/mission_finalize.py ~:1205-1252) on an "execution has begun" signal. When execution has begun, preserve the established planning_commit_sha (do not re-capture the branch tip), or refuse before writing any bytes. When it has not begun, keep today's recompute behavior (including re-capture) — the documented idempotent pre-execution re-finalize (mission_finalize.py docstring ~:326-327).

state (034+/060) — read through the resolved, coord-aware surface. Recipe (architect post-plan, HIGH — the established implement.py:1668-1680 pattern): coordination/surface_resolver.py::resolve_status_surface_with_anchor(repo_root, mission_slug).read_dirread-only status/lane_reader.py::get_all_wp_lanes( read_dir) (or reducer.py::materialize_snapshot), guarded by lane_reader.has_event_log(read_dir) (absent log ⟹ execution not begun). NEVER reducer.materialize() — it writes status.json to disk (reducer.py:751) and reading raw planning_dir is a PRIMARY-partition (split-brain) read. Execution begun ⟺ has-event-log ∧ any WP's current lane ∉ {planned}.

planning_commit_sha (runner/:1251), so a trigger keyed on file/SHA presence fires on every benign second finalize and would freeze/refuse a legitimate pre-implementation re-plan (renata, HIGH). The status event log is the correct authority for "has execution started." Respect ADR 2026-07-29-1 / FR-009 single-write freeze — no second commit, no re-capture on the execution-begun path (C-005).

not reproduce (debbie — live run shows lanes stable, collapsed:0); scope the fix to the confirmed planning_commit_sha clobber only. Do not promise topology preservation without first reproducing it (C-007).

re-captured tip — the repro only covers the None (non-git tmp) case, which a naive if sha is not None: would fake-green; (b) a benign pre-execution re-finalize still regenerates and does not refuse (FR-008 / NFR-002).

(vs. amending an existing one) is a supported re-plan or a refuse case. Default for this mission: the refuse/preserve path triggers only on execution-begun; a pre-execution add-WP re-finalize regenerates freely. Revisit only if a WP surfaces a concrete need.

  • Decision: Gate the recompute/overwrite in _compute_and_write_lanes
  • Signal: the append-only status event log — the sole authority for WP lane
  • Rationale: first-time finalize always writes lanes.json and sets
  • Scope correction: the "topology collapse / lane renumber" narrative does
  • Guard beyond the repro: (a) preservation must hold against a non-None
  • Open sub-question (non-blocking): whether re-finalize that adds a new WP

Adversarial evidence ledger

Per contracts/adversarial-evidence-contract.md. All contested findings were resolved before plan readiness — none silently dropped.

Finding (source)DispositionWhere reflected
#3231 notes-marker discriminator → false-accept (renata, CRITICAL)changedR-02, C-003, spec FR-004
#3334 repro pins wrong contract; classifier fix over-corrects (renata, HIGH; #3334 trace)changedR-03, C-006, IC-03 fix retargeted to runner.upgrade()
#3311 trigger must be execution-state, not file presence (renata, HIGH)changedR-04, C-005
#3311 topology-collapse narrative not reproduced (debbie, HIGH)changedR-04 scope correction, C-007
#3320 writer return-type change has wide blast radius (renata, MED)accepted (steer to read-back)R-01, C-002
"One shared root cause" framing partially forced; #3231 is the odd member (paula, HIGH)acceptedreframed to per-fix ICs; C-001 no-shared-helper non-goal
Green-wash: 3 of 4 repros pin only the positive half (debbie)acceptedNFR-002 mandatory guard tests in every IC
#3334 durable save()-preserves-schema fix vs minimal runner restorechanged — pulled into scope (operator directive)R-03 root fix = ProjectMetadata round-trips schema_version (subsumes runner restore); second writer migration/runner.py:193 kept consistent; mission partially closes Epic #3347; direct load→save round-trip unit test replaces the earlier characterization/gate test
Regression-exit as mission-completion invariant — each repro relocated/replaced out of tests/regression/, marker + red-first docstring swapped for canonical ones; no regression test green (operator directive)changedNFR-005 + SC-006 + per-WP DoD in the IC-map intro; contracts note the functional-slice home per fix
Guard tests fakeable as written — #3334 always-stamp-REQUIRED, #3311 always-preserve, #3231 criterion_id shortcut, #3320 wrong-and-equal (renata post-plan, HIGH×2/MED×2)changedstrengthened non-fakeable assertions folded into IC-01/02/03/04 + contracts (STALE pre_schema equality, observable regeneration, real-AC-001→pending, on-disk emit spy, dry_run byte-identical)
#3311 reader named reducer.materialize() — a disk-writing, partition-blind call (architect post-plan, HIGH)changedR-04 + IC-04 + A-04: use resolve_status_surface_with_anchor().read_dirhas_event_log guard → get_all_wp_lanes/materialize_snapshot; never materialize()