Data Model — Silent-Write Hardening Residuals

No new persisted schema. This mission changes how three seams classify and preserve existing records. The model below captures the classification rule this mission makes canonical.

1. Status event classification (Finding A)

status.events.jsonl rows fall into exactly one class. The mission makes the reader and the repair agree on the class boundary.

flowchart TD
  Row["status.events.jsonl row"] --> Ann{"kind == annotation?"}
  Ann -- yes --> Keep["PRESERVE (annotation path)"]
  Ann -- no --> LaneLegacy{"legacy typed lane row?\n(WPStatusChanged + wp_id/from_lane/to_lane)"}
  LaneLegacy -- yes --> Canon["CANONICALIZE into status.json\n(event_type stripped) — lane reducer owns it"]
  LaneLegacy -- no --> NonLane{"reader is_non_lane_event == True?\n(retrospective event_name / type-envelope,\nOR any event_type present)"}
  NonLane -- yes --> Keep2["PRESERVE (preserve-by-default)"]
  NonLane -- no --> Quar["QUARANTINE (genuinely non-status:\nno event_type, not a valid lane row)"]

Classes

ClassDefinition (post-inversion)Repair action
Annotationkind == "annotation"Preserve (own branch)
Legacy typed lane transitionevent_type == "WPStatusChanged" and top-level wp_id,from_lane,to_laneCanonicalize into status.json (strip event_type) — checked FIRST, protects #3066
Non-lane authoritativereader is_non_lane_event is True: retrospective event_name/type-envelope, or any event_type present (lifecycle, DecisionPoint, and any future authoritative type)Preserve (preserve-by-default; denylist EMPTY)
Genuinely non-statusno event_type, not a valid lane row (e.g. a stray event_name-only row)Quarantine

Invariant (the new contract): for every row, repair.preserves(row) ⇔ reader.is_non_lane_event(row) (modulo the lane-transition passthrough, which both treat as lane, not non-lane). The fail-closed guard _registry_authoritative_quarantine_violations enforces the stronger form: no event_type-bearing row is ever quarantined. Carve-out preserved from #4938: a benign duplicate-event_id drop whose survivor remains in canonical_rows does not trip the guard.

Denylist: EMPTY. Represented as an explicit, named empty constant (documented) so a future prunable mirror is a deliberate, reviewed addition — not a silent default.

2. catalog.mission read path (Finding B)

Before: two readers over one field.

ReaderParser
charter/generate.py::_read_catalog_mission_from_charter_yamlruamel round-trip via load_charter_yaml
charter_runtime/preflight/references_refresh.py::_read_catalog_mission_and_template_setad-hoc YAML(typ="safe")

After: one accessor in charter/activation/charter_yaml_io.py, both callers delegate.

read_catalog_field(repo_root, field) -> Any | None
  charter.yaml missing/absent field/malformed  -> None (via load_charter_yaml + (YAMLError, OSError, UnicodeDecodeError) guard)
  present                                       -> catalog[field]

Contract: identical value and identical absent-result for every caller; one parser (canonical ruamel round-trip). read_catalog_mission(repo_root) = read_catalog_field(repo_root, "mission").

3. Trace block identity (Finding C)

A traces/<category>.md is a sequence of blocks, each opened by a boundary line: a markdown heading #{1,6} … or a <!-- section:ID --> delimiter. Fenced code ( ` or ~~~) inside a block must not be read as a boundary.

Two distinct keys, intentionally different:

KeyUsed byDefinition
Whole-block identityunion_trace_texts dedupfull block tuple (byte-identical) — unchanged
Base-comparison key_drop_stale_theirs_trace_blocks (3-way)<!-- section:ID --> id if present, else (block[0], occurrence_ordinal) — new, non-colliding

Invariant: two sections sharing a heading get distinct base-comparison keys; a section whose body changed keeps the same base-comparison key (so theirs_unchanged/ours_diverged still detects it).