Contracts
behavioral-contracts.md
Behavioral Contracts — Mission A
Four observable contracts. Each is pinned by a red-first reproduction (existing or replaced) plus the mandatory green-wash guard tests. These are behavior contracts, not HTTP APIs — the mission adds no endpoints.
Regression-exit (NFR-005/SC-006), applies to all four: when a reproduction turns green, its WP relocates it to the functional-slice home named below (or, for #3334, lands the replacement there), drops @pytest.mark.regression, adds the canonical unit/integration marks + a guard docstring, so that at mission completion pytest tests/ -m regression shows none of the four green.
C#3320 — Retrospect --update agrees with disk
retrospect create --update --json runs with a generator yielding ran_no_findings, then reported findings_status == "has_findings" and counts.gaps == 1, and the emitted RetrospectiveCaptured event carries the same merged record.
error/backfill unchanged. write_gen_record return type unchanged.
tests/cli/commands/; + new: patch emit_captured with a spy and assert the captured event's findings_status/gap-count equal the values read back from record_path on disk (not the reported JSON — both could be wrong-and-equal; not the repro's emit_captured→None patch).
- Given an on-disk retrospective record
has_findingswith 1 gap, when - Invariant: report ≡ event ≡ persisted file, for
--update.--overwrite/ - Tests:
tests/regression/test_issue_3320_*(JSON) → on green, relocate to
C#3231 — A scaffold placeholder cannot flip acceptance
placeholder (description == SCAFFOLD_TODO_MARKER), when overall_verdict is computed, then it is not pending.
real description), or an all-scaffold matrix, then overall_verdict is pending.
seeded-but-unauthored requirement always blocks; an un-authored matrix never reads pass.
on green relocate to tests/acceptance/; + new (non-fakeable): partial-authoring (9/10 FR pending)→pending; single-row empty-AC-001-only →pending (the "no non-scaffold criterion" branch); single REAL AC-001 (real description, pending, no marker)→pending (defeats a criterion_id=="AC-001" shortcut, pins the description discriminator).
- Given a matrix whose real criteria are all
passplus the emptyAC-001 - Given a matrix with a seeded FR row still
pending(marker innotes, - Invariant: only the contentless empty placeholder is verdict-exempt; a
- Tests:
tests/regression/test_issue_3231_*(empty-placeholder+all-pass) →
C#3334 — A failed upgrade leaves a recoverable project
success history), when MigrationRunner.upgrade(target) hits a failing migration, then get_project_schema_version() == 3 afterward (not None), and the real startup gate check_schema_version(project_root, "plan") does not raise SystemExit.
history), when the gate runs for an unsafe command, then it classifies LEGACY and raises SystemExit(4).
C-008) so no save() caller strips it; a failed upgrade is non-destructive to the stamp; the genuine-pre-3.x LEGACY guard is unchanged; the classifier (planner.py, safety.py) is untouched.
(same PR as the fix; it perma-reds NFR-001 otherwise) — drive real MigrationRunner.upgrade() (stub failing migration via MigrationRegistry), fixture schema_version present + version behind + 3.x success history. Assert: (1) post-failure schema_version == the captured pre_schema, using a non-REQUIRED (STALE, < min_supported) fixture value — == 3 alone is faked by always-stamp-REQUIRED; (2) real gate no SystemExit; (3) upgrade(dry_run=True) against the failing migration leaves metadata.yaml byte-identical; (4) genuine pre-3.x still LEGACY + SystemExit(4). + direct unit test: ProjectMetadata round-trips schema_version through load→save (the root-fix guard — a save() after load preserves the on-disk value; the _mask_volatile_metadata change does not re-mask a real change).
- Given a healthy schema-3 project (
schema_version: 3, version behind, 3.x - Given a genuinely pre-3.x project (
schema_versionabsent, no 3.xsuccess - Invariant:
ProjectMetadataround-tripsschema_version(root fix, - Tests (non-fakeable — renata): replace
tests/regression/test_issue_3334_*
C#3311 — Re-finalize after execution preserves provenance
and ≥1 WP past planned in the status event log, when finalize-tasks re-runs after an ownership-only owned_files amendment, then planning_commit_sha is preserved (or the run refuses before writing) — even when the current branch tip differs (non-None).
finalize-tasks re-runs, then lanes regenerate and the run does not refuse.
coord-aware surface (resolve_status_surface_with_anchor().read_dir → has_event_log guard → get_all_wp_lanes; never reducer.materialize()), never on lanes.json/planning_commit_sha presence; single-write provenance freeze (ADR 2026-07-29-1/FR-009) preserved.
(None-tip) → on green relocate to tests/specify_cli/cli/commands/agent/; + new: (a) non-None-tip preservation (branch-tip differs, SHA still preserved); (b) benign pre-execution re-finalize must assert regeneration actually ran — make the owned_files amendment observable and assert the regenerated lanes.json reflects it (two lanes union into one) or planning_commit_sha re-captured to the new tip; "did not refuse" alone is faked by an always-preserve impl.
- Given a mission with materialized lanes, a recorded
planning_commit_sha, - Given a mission with all WPs at
planned(no execution begun), when - Invariant: preserve/refuse triggers on execution-state via the resolved
- Tests (non-fakeable — renata):
tests/regression/test_issue_3311_*