Research — Silent-Write Hardening Residuals

Grounding: PR #4938 landing-pass second-opinion squad (correctness, design/authority, SSOT/whack-a-field), verified still-live on main @ 138d25dd0f. Prior mission grounding: kitty-specs/silent-destructive-write-hardening-01M355VK/research.md. No dependency changes → Supply-Chain Security / Adversarial Evidence section: N/A (no added/upgraded/removed dependency; ruamel.yaml is already present).

Operator Decision Moment DM-01M37QQ62T11G9JPZQJE2GYSYH: ship the full Finding-A inversion this mission, gated by an ADR.

Finding A — mission-state repair: preserve-by-default (denylist = EMPTY)

  • Decision: Invert migration/mission_state.py::_is_preserved_non_lane_row from a registry allowlist to preserve-by-default: preserve any row the durable reader status/store.py::is_non_lane_event treats as non-lane (annotation branch, retrospective event_name, retrospective type-envelope, and — the new behavior — any row carrying an event_type). Prune only an explicit denylist of known-prunable mirror event_types. That denylist is EMPTY.
  • Rationale: An empirical writer census of status.events.jsonl shows every event_type present is either (a) a lane transition — modern flat rows carry no event_type; the legacy typed WPStatusChanged row is owned by the lane reducer — or (b) an authoritative canonical record (lifecycle / DecisionPoint / retrospective) whose sole per-mission home is this log. Derived folds (decisions/index.json) are separate files rebuilt FROM the log (decisions/index_fold.py::fold_events), never rows inside it; review_result (#3541) is a nested field on a lane StatusEvent, not a standalone row. So no event_type-bearing row is a prunable mirror — the safe rule is "preserve everything the reader preserves", which collapses the reader's permissive contract and the repair's preserve contract into one rule and closes the whack-a-field class (#2376 → #3066 → #3541 → #4897 → the unregistered-future case) structurally.
  • Guardrail (must not regress #3066): Keep the _is_legacy_typed_lane_transition passthrough (WPStatusChanged + top-level wp_id/from_lane/to_lane) FIRST, before preserve-by-default, so a legacy typed lane row is canonicalized into status.json (event_type stripped) rather than preserved verbatim. WPStatusChanged is a lane row, not a prunable mirror; the denylist does not name it because it never reaches the non-lane path.
  • Quarantine after the inversion: only rows with no event_type that are genuinely non-status. The existing "event_name" in row quarantine branch stays (a non-retrospective event_name-only row is not non-lane to the reader either — it flows to StatusEvent.from_dict and fails loud per FR-010). These are "genuinely non-status", not a mirror denylist.
  • Guard extension (FR-002): strengthen _registry_authoritative_quarantine_violations from "no registry-authoritative row is quarantined" to the full reader==repair invariant: no event_type-bearing row is quarantined at all. This makes the guard catch the unregistered-future case it structurally could not before (it was powered by the same registry). Preserve the #4938-folded carve-out: a benign duplicate-event_id drop whose survivor stays in canonical_rows must NOT trip the guard.
  • Alternatives considered: (a) add the next missing type to the registry — REJECTED, the whack-a-field patch this mission exists to end; (b) a non-empty denylist naming DecisionPoint/lifecycle as "mirrors" — REJECTED, the census proves they are canonical, not mirrors; (c) conservative interim (guard-only, keep pruning) — REJECTED by operator decision (ship the inversion).

Finding B — single catalog.mission accessor

  • Decision: Add a shared accessor (e.g. read_catalog_field(repo_root, field) / a thin read_catalog_mission(repo_root)) to src/charter/activation/charter_yaml_io.py, alongside the canonical load_charter_yaml. Collapse both cli/commands/charter/generate.py::_read_catalog_mission_from_charter_yaml and charter_runtime/preflight/references_refresh.py::_read_catalog_mission_and_template_set onto it. Optionally (campsite) refactor language_scope.py::_read_compiled_languages to become a client of the same accessor.
  • Rationale: load_charter_yaml is the canonical loader (ruamel round-trip, preserve_quotes, empty→CommentedMap(), missing→FileNotFoundError). generate.py already imports it; references_refresh.py hand-rolls a divergent YAML(typ="safe") reader (:122) — the exact drift. language_scope._read_compiled_languages already models the target shape (load_charter_yaml → document.get("catalog") → catalog.get(field) with a (YAMLError, OSError, UnicodeDecodeError) guard). Both callers already reach charter.activation.* lazily elsewhere, so no import-layer/boundary violation.
  • Correction to #4993: the issue says "three catalog.mission readers" — the true count is two. The claimed third (language_scope._read_compiled_languages) reads catalog.languages, a different field. The mission corrects this in the #4993 close-out.
  • Alternatives considered: (a) point references_refresh at load_charter_yaml inline without a named accessor — leaves two copies of the catalog-walk shape; the named accessor is the SSOT; (b) a new module — unnecessary, charter_yaml_io owns the loader.

Finding C — traces merge-driver hardening (bounded)

  • Decision: In cli/commands/merge_driver.py: (1) widen _TRACE_FENCE_MARKER from re.compile(r"^`") to re.compile(r"^(?:`|~~~)") (optionally r"^(?:{3,}|~{3,})") — the single fence-toggle site; in_fence then propagates through _split_trace_blocks. (2) Replace the colliding _trace_block_key (block[0]) + setdefault maps in _drop_stale_theirs_trace_blocks with a non-colliding base-comparison key: prefer the <!-- section:ID --> id when the block opens with one; else fall back to (block[0], occurrence_ordinal)` so duplicate headings get distinct keys.
  • Rationale: ~~~ is valid CommonMark fencing; a heading-like line inside a tilde block is currently misread as a section boundary. setdefault(block[0], …) is first-occurrence-wins, so two sections sharing a heading collide in the base/ours maps. The <!-- section:... --> delimiter is the author-supplied stable id the driver's own boundary regex already recognizes.
  • Must NOT change (C-002 / NFR-004): the whole-block byte-identical dedup in union_trace_texts (full-tuple seen) stays as-is — it is correct record-granularity dedup. Do not use a full-block hash for the base-comparison key: base-awareness must detect "same section, body changed" (theirs_unchanged vs ours_diverged); a body-sensitive key defeats the 3-way match. The two keys are intentionally different.
  • Alternatives considered: (a) fail-closed on repeated headings like the acceptance/issue-matrix drivers — REJECTED (aborts real keyless append-union merges, per the prior mission's C-003); (b) broad parser refactor — REJECTED by C-002.

Cross-cutting

  • These are three independent seams with no shared guard (prior mission's cross-cutting finding). Do NOT route any through the asset_preservation guard (filesystem-path granularity; category error — spec C-001).
  • Only Finding A is a recurring whack-a-field class warranting the shared-rule collapse; B and C are first-order SSOT/correctness fixes.
  • Concurrent PR #4950 (coordination-doctor branch-safety): verified disjoint source + zero semantic coupling; shared surfaces (_completion_manifest.json, docs/api/cli-commands.md, CHANGELOG.md) are landing-time regen, not lane contention.