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.mission value through the accessor for a normal charter. (SC-002)
  • AC-B2: for a charter.yaml with no catalog.mission (and for a malformed doc), the accessor returns the same absent result to every caller.
  • AC-B3: grep proves exactly one catalog.mission reader remains. (NFR-003)
  • AC-B4 (optional campsite): language_scope._read_compiled_languages reads catalog.languages through 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_type NOT 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 WPStatusChanged lane row is still canonicalized into status.json, not preserved verbatim (no #3066 regression).
  • AC-A4: benign duplicate-event_id mission 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_texts is unchanged (existing #4894 section-union test stays green).