Contracts
catalog-mission-accessor-contract.md
Contract — shared catalog.mission accessor (Finding B)
Surface: new charter/activation/charter_yaml_io.py::read_catalog_field(repo_root, field) (+ thin read_catalog_mission(repo_root)). Callers: charter/generate.py::_read_catalog_mission_from_charter_yaml, charter_runtime/preflight/references_refresh.py::_read_catalog_mission_and_template_set.
Guarantees
1. Single read path. Exactly one accessor reads charter.yaml catalog.<field>; both former readers delegate to it. A repo grep for direct catalog→mission reads returns 0 outside the accessor. (NFR-003 / SC-002) 2. Canonical parser. The accessor uses load_charter_yaml (ruamel round-trip, preserve_quotes) — the same parser as the rest of the charter tooling. No second parser (YAML(typ="safe")) survives for this field. 3. Absent/malformed result is uniform. Missing file, absent catalog, absent field, or a malformed document yields the same well-defined absent result (None) to every caller, guarded by (YAMLError, OSError, UnicodeDecodeError). 4. No boundary violation. Both callers already reach charter.activation.* (lazily); the delegation adds no new forbidden import edge. 5. Behavior parity. Each caller's observable behavior is unchanged for all valid documents (the fix removes divergence risk, not existing behavior).
Acceptance
- AC-B1: both callers resolve the identical
catalog.missionvalue through the accessor for a normal charter. (SC-002) - AC-B2: for a
charter.yamlwith nocatalog.mission(and for a malformed doc), the accessor returns the same absent result to every caller. - AC-B3: grep proves exactly one
catalog.missionreader remains. (NFR-003) - AC-B4 (optional campsite):
language_scope._read_compiled_languagesreadscatalog.languagesthrough the same accessor with no behavior change.
repair-preservation-contract.md
Contract — mission-state repair preservation (Finding A)
Surface: migration/mission_state.py — _is_preserved_non_lane_row, _rule_reject_non_status_event, _canonicalize_status_row, _registry_authoritative_quarantine_violations. Reference contract: status/store.py::is_non_lane_event.
Guarantees
1. Preserve-by-default, by DELEGATION. _is_preserved_non_lane_row DELEGATES to the reader: (row.get("kind") == ANNOTATION_KIND) or is_non_lane_event(row) (minus the empty denylist). The reader is the single authority — the repair is not a parallel classifier. The annotation OR-clause is the only intended divergence (reader returns False to route annotations to their own partition; repair returns True to keep them on disk). (Squad fold: re-enumerating the reader's branches would recreate the two-classifiers-in-lockstep mechanism that generated the whack-a-field class.) 2. Empty denylist. The only rows pruned from the non-lane path are those on an explicit prunable-mirror denylist. That denylist is empty; it is a named, documented constant, so any future entry is a reviewed decision. 3. Lane passthrough precedence. A flat legacy typed lane transition (WPStatusChanged + top-level wp_id/from_lane/to_lane) is classified as a lane transition and canonicalized into status.json (with event_type stripped) — evaluated before the delegated preserve call. (Protects #3066; delegation is behavior-identical precisely because this runs first.) 4. Quarantine scope. Only rows for which is_non_lane_event is False AND that are not valid lane rows are quarantined ("genuinely non-status" — in practice event_name-only non-retrospective rows). 5. Fail-closed guard (strengthened, reader-keyed). _registry_authoritative_quarantine_violations fails the repair (non-zero / recorded error, never errors=0 success) if any row where is_non_lane_event(obj) is True is quarantined — keyed on the reader predicate, NOT "event_type" in obj. This is a genuine reader==repair check and covers reader-preserved rows with no event_type (retrospective event_name), which an event_type-keyed guard would silently miss. 6. Duplicate-event_id carve-out (preserved). A benign duplicate-event_id drop whose survivor stays in canonical_rows (via surviving_event_ids) does not trip the guard (regression folded in #4938 stays green; every duplicate has a prior survivor, so the carve-out is provably sufficient). 7. Known disposition edges (safe, ADR-noted). The nested-payload WPStatusChanged replay envelope and a partial-field WPStatusChanged corruption shape shift from quarantine to preserve-verbatim under the inversion; both align with the reader's existing behavior (fail-closed toward retention) and are pinned by ACs + docstring notes, not regressions.
Behavioral change (ADR-recorded)
Before: an event_type absent from AUTHORITATIVE_NON_LANE_EVENT_TYPES (and not retrospective/annotation) was quarantined and dropped, exit-0. After: it is preserved. This changes what --fix prunes — recorded in the FR-003 ADR under docs/adr/3.x/.
Acceptance (red-first)
- AC-A1: a row with an authoritative-shaped
event_typeNOT in the registry survives--fix(present in active state, not quarantined). Fails against pre-inversion repair, passes after. (SC-001) - AC-A2: a repair that would quarantine any
event_type-bearing row reports a non-zero/error outcome (guard). Fails against the registry-only guard for an unregistered type, passes after. - AC-A3: legacy
WPStatusChangedlane row is still canonicalized intostatus.json, not preserved verbatim (no #3066 regression). - AC-A4: benign duplicate-
event_idmission dedupes without the guard hard-erroring (no #4938 regression). - AC-A5: all previously-preserved classes (annotation, retrospective, lifecycle, DecisionPoint, WPStatusChanged-as-lane, review_result) remain handled identically (NFR-002 / SC-005).
traces-merge-contract.md
Contract — traces merge-driver hardening (Finding C)
Surface: cli/commands/merge_driver.py — _TRACE_FENCE_MARKER, _split_trace_blocks, _trace_block_key, _drop_stale_theirs_trace_blocks. Out of scope (C-002): union_trace_texts whole-block dedup and the overall fence/section contract.
Guarantees
1. Tilde fences recognized. Fence detection matches ~~~ equivalently to ``` ` ``. A heading-like line inside a ~~~-fenced block is not treated as a section boundary. 2. Non-colliding base-comparison key. In the 3-way base-aware stale-drop, each block's base-comparison key is the <!-- section:ID --> id when present, else (first_line, occurrence_ordinal). Two sections sharing a heading get distinct keys; a section whose body changed keeps its key (so theirs_unchanged/ours_diverged still fires). 3. No data loss (NFR-004). Merged output loses 0 sections and preserves within-section content byte-for-byte. Over-inclusion is acceptable; loss is not. 4. Whole-block dedup untouched. union_trace_texts`'s byte-identical full-block dedup is unchanged; the base-comparison key is a separate, body-insensitive key (no full-block hash there).
Acceptance (red-first)
- AC-C1: a traces block fenced with
~~~containing a heading-like line is not split at that line. Fails against the backtick-only regex, passes after. (SC-003) - AC-C2: two distinct sections with an identical heading are keyed distinctly; neither is dropped or mis-attributed in the stale-drop. Fails against the
setdefault(block[0])collision, passes after. (SC-003) - AC-C3: an unchanged-theirs / diverged-ours single-heading section is still correctly stale-dropped (3-way base-awareness intact — no regression from the key change).
- AC-C4: byte-identical whole-block dedup in
union_trace_textsis unchanged (existing #4894 section-union test stays green).