Ledger correction consumers — which question each reader asks¶
Status: inventory + classification, GHI #611. Authored 2026-09-06 after the review of
47214176found that flippingLedger.read_all()to the corrected stream moved every consumer to the state reading, including consumers asking an evidence question.
gzkit.ledger_corrections derives two readings from the same append-only file,
and they answer different questions. Choosing between them is a per-consumer
judgment; the mistake this document exists to prevent is making it by default.
| Reading | Drops | Answers |
|---|---|---|
live_events — Ledger.read_all(), Ledger.query() |
void + discharged |
State — what is in force now |
evidence_events — Ledger.read_evidence(), query(stream="evidence") |
void only |
Evidence — what was ever true |
(none) — Ledger.read_history(), query(stream="history") |
nothing | History — every row is the subject |
void says a row records something that was never the case. discharged
says it was true when written and its condition later ended. A consumer that
cannot tell those apart forces one to be filed as the other — the collapse
GHI #823 names.
gzkit.ledger.read_corrected_rows is the tolerant counterpart for readers
that must not raise (pipeline gates, commit hooks): it parses the JSONL itself,
skips an undecodable line, and applies the same two readings.
The default is deliberately the narrowest¶
read_all() nets by default and that is correct: netting per call site is the
per-consumer hand-patching GHI #611 exists to end, and it fails open — a
consumer written next year is correction-blind unless its author remembers.
Defaulting to live makes forgetting safe.
But live is the narrowest reading, so the flip could only ever mis-serve
consumers in one direction: an evidence consumer silently loses discharged rows.
That is exactly what happened, and it is why evidence and history are named
opt-ins on the same call rather than separate methods a reader must know exist.
Classification¶
Evidence consumers — corrected on the evidence stream¶
| Consumer | Question it asks | Why live was wrong |
|---|---|---|
handoff_archive._locked_paths |
Did any token surrender ever cite this file? | A discharged obpi_lock_released vanished, so the exchange record a real surrender cited became archivable. Fixed: query(..., stream="evidence"). |
governance.trust_audits.lock_exchange_coupling |
Does this past release own a valid exchange record? | Same event, same question, one call site away. A discharged release still happened and still owes its coupling. Fixed: read_evidence(). |
pipeline_runtime.check_reconcile_receipt_gate |
Was this brief reconciled, and when? | Never reached the flip at all (raw JSONL reader) — a voided receipt still opened Stage 2. Fixed: read_corrected_rows(..., stream="evidence"). |
commands.obpi_complete._latest_reconcile_receipt |
Same, at Stage 5. | Same defect, same cause; a voided receipt still satisfied completion. Fixed identically, so the two ends of the pipeline agree about which receipts exist. |
State consumers — correct on the default¶
Everything reaching the ledger through Ledger.read_all() / query() and
asking what is in force now: gz state's artifact graph, gate readiness,
lifecycle status, OBPI park/block state, TASK liveness
(tasks.active_task_trailers, which applies live_events explicitly because it
runs in a commit hook where an exception blocks all work). These are served by
the default and need no change.
History consumers — read_history()¶
commands.ledger_correct (both the corrections census and subject resolution)
and replay-fidelity audits. Their subject genuinely is every row, corrections
included; netting would hide the thing they exist to read.
Unresolved semantic choice — surfaced, not decided¶
What discharged means on a brief_reconciled receipt is not settled, and
the repair takes the conservative reading rather than ruling on it.
The vocabulary was designed for rows recording a condition (a block, a lock, a
launch). A receipt is not a condition — it records that a run happened at a
time. void is unambiguous there (the run never happened). discharged has no
established meaning: read literally it says "the reconciliation was true and
stopped being true", which sounds like stale.
Implemented: the evidence reading — a discharged receipt still counts.
Recommended: leave it there, for three reasons.
- It is strictly the more conservative choice. It removes only what the ledger says never happened, and never silently weakens a gate.
- Reconciliation staleness already has a dedicated mechanism —
is_receipt_freshagainst the allowlist domain's mtimes. Lettingdischargedalso mean "stale" would build a second, weaker staleness channel beside it, and the two would disagree. - If the operator later rules that discharging a receipt should force re-reconciliation, that is a widening of the gate. Widening a gate later is safe; discovering that a gate was silently narrowed is not.
An operator ruling on this would be recorded here and in
gzkit.ledger.read_corrected_rows.
What the repair preserves¶
- Raw history is untouched. Corrections are append-only forward rows; every
subject row and every correction stays on disk, and
read_history()returns all of them. - Legitimate intervening work survives a correction. The reconciliation
readers take the newest matching receipt, so voiding the latest one falls
back to the real receipt beneath it rather than reporting "no receipt" — a
correction must never cause a larger outage than the error it repairs.
Asserted by
tests/test_ledger_correction_consumers.py::VoidedReconcileReceiptsStopCountingAsReceipts::test_an_earlier_receipt_resurfaces_when_the_later_one_is_voided.
Which corrections apply at all — one contract, both paths¶
Choosing a reading is the second question. The first is whether a correction
counts, and every reader answers it identically because they share one
predicate: is_well_formed plus the append-order rule in correction_state
(gzkit.ledger_corrections), which gz validate --ledger imports rather than
restates. A correction applies only when all of the following hold.
| Requirement | Refused when | Checked by |
|---|---|---|
Envelope — this ledger's schema tag, a non-empty id, a parseable ISO8601 ts, and a parent that is absent, null, or a string |
a foreign tag, a blank id, ts: "not-a-date", parent: 7 |
_has_valid_envelope |
Payload — seven str fields; disposition and cause in their closed vocabularies |
a wrong type, whitespace-only content, an unknown term | is_well_formed |
Subject resolution — the named (event, id, ts) triple exists |
nothing carries that identity | gz validate --ledger (it holds the whole file) |
| Append order — the subject appears earlier in the sequence | the correction stands ahead of the row it names | correction_state, and the validator by line number |
Two of these used to live only in the validator, so a correction it reported still voided its subject at replay — the operator saw the gate fire and the row was corrected anyway. That split is the defect; sharing the predicate is the fix.
The verdict describes the stored row, never the object holding it. The same
predicate is handed a raw dict by the tolerant reader and a LedgerEvent by the
strict one, and they are the same row, so they may not disagree — but they did,
in both directions a gap can open.
A default filled one. LedgerEvent supplies schema_ and ts from field
defaults, so a correction whose bytes carried neither arrived at replay
wearing this ledger's tag and a timestamp of this instant. The envelope check
read those manufactured values and passed the row: gz validate --ledger
reported it, read_corrected_rows left it inert, and the artifact graph applied
its void. As a reinstatement the same row revived a subject a valid correction
had voided. LedgerEvent.parse_stored_row is the repair, applied at the one
boundary that knows a row already exists (Ledger.read_history): an absent
envelope field reads as empty rather than as a default, because at read time the
envelope is a fact about the file. The constructor defaults are untouched — an
event being authored has no timestamp until something stamps one.
A missing check was the other. parent is the fifth envelope field. The
validator checks it and LedgerEvent refuses a non-string outright, so the
strict reader never applied parent: 7 — it raises, which is its documented
contract for a malformed row. That left the tolerant reader as the only path that
could apply it, and it did. A gap in a shared guard is not redundant coverage on
the one reader that is forbidden to raise.
Order is position, never the timestamp. The file is the append order. Two
rows may legitimately share a ts — the committed ledger already holds such a
pair — so a rule comparing timestamps reads an inversion between same-instant
rows as fine, and says nothing at all when either ts fails to parse.
A malformed row is a finding, never an exception. Values of the wrong
container type are ordinary malformed input: cause: [] cannot be tested
against a frozenset and id: [] cannot be hashed into a subject index. Both
raised TypeError and took down the reader that existed to report them —
including the tolerant reader, whose whole contract is that a pipeline gate or
commit hook must not raise. identity_key returns None for a row that has no
identity under the ledger's rule, and such a row is uncorrectable rather than
corrected: no well-formed correction can name it.
Subject resolution is the one requirement replay does not enforce, and deliberately: a caller holding only a window of the ledger would otherwise drop a correction whose subject sits outside it. Requiring the subject to have been seen covers the harmful half without needing global knowledge — a correction whose subject is outside the window nets nothing anyway, because nothing in the window carries its key.
Known limit¶
This inventory is a reading, not a mechanically enforced partition. Nothing
stops a new consumer from taking the default when it wanted evidence — the
default is chosen to make that mistake the safe one, not to make it impossible.
The behavior of the four consumers above is pinned by
tests/test_ledger_correction_consumers.py; the classification of a fifth is
not. Reclassify on an observed instance of a new consumer taking the wrong
stream.