You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(ledger): declare unrecoverable rows instead of failing forever
A row whose record preimage is genuinely gone -- the rows the pre-#9123
record-overwriting UPDATE left behind -- can never be reconciled again.
Rewriting those records so the digests match would be exactly the tampering this
ledger exists to detect, so the only honest options are to fail forever or to
declare the damage. A permanently-red endpoint is one nobody reads, which is a
worse public signal than a green one carrying an explicit, counted exclusion.
LOOPOVER_LEDGER_CONTENT_WAIVER, format "<fromSeq>-<toSeq>:<reason>". Four
properties make it a disclosure rather than a cover-up, each enforced and tested:
* BOUNDED BY SEQ, NOT TIME. A date boundary drifts -- as the clock advances it
silently swallows new damage. A seq range is fixed forever; only an explicit
edit widens it. (The retention cutoff is time-based because it tracks a
published policy that genuinely moves. This does not.)
* BOTH ENDS REQUIRED. An open-ended waiver is a blanket exemption wearing a
range's clothes.
* A REASON IS MANDATORY. You cannot waive silently, and the text is published.
* CONTENT ONLY. The content check runs after sequence/predecessor/row_hash have
already passed, so a waived row that is mis-chained still fails.
Waived mismatches are counted SEPARATELY and never folded into contentMismatches:
"83 rows are excused" must never read as "83 rows are fine". The range and the
count are published even on a clean chain, so the declaration does not require a
failure to become visible.
Per-deployment, because this is one operator's damage -- hardcoding a range into
the product would assert something about every operator's ledger. Malformed
values waive NOTHING rather than widening the exclusion on a typo, and preflight
surfaces them, since a set-but-ineffective value is the dangerous one.
Refs #9850
Copy file name to clipboardExpand all lines: apps/loopover-ui/public/openapi.json
+1-1Lines changed: 1 addition & 1 deletion
Original file line number
Diff line number
Diff line change
@@ -20760,7 +20760,7 @@
20760
20760
"summary": "Verify a window of the hash-chained decision ledger (resumable via afterSeq)",
20761
20761
"responses": {
20762
20762
"200": {
20763
-
"description": "Window verified clean; nextAfterSeq is the resume cursor (null at the tip). Every response also carries tipSeq/tipHash/totalCount for third-party checkpointing, contentMismatches (#9850 -- the COUNT of rows whose preimage no longer matches the digest the chain committed to; a content mismatch no longer aborts the scan, because one unreconcilable row used to hide every row after it, so a structural break further along was unreachable. The verdict is unchanged: any mismatch is still ok:false and the first is still reported as `break`), and prunedRecords — the count of rows whose record preimage was legitimately pruned by the published retention window (chain checks still hold for them; only the content re-check is impossible, and the committed digest stays published)."
20763
+
"description": "Window verified clean; nextAfterSeq is the resume cursor (null at the tip). Every response also carries tipSeq/tipHash/totalCount for third-party checkpointing, contentMismatches (#9850 -- the COUNT of rows whose preimage no longer matches the digest the chain committed to; a content mismatch no longer aborts the scan, because one unreconcilable row used to hide every row after it, so a structural break further along was unreachable. The verdict is unchanged: any mismatch is still ok:false and the first is still reported as `break`), waivedContentMismatches + contentWaiver (#9850 -- a DECLARED, seq-bounded exclusion from the content re-check for rows whose preimage is unrecoverable, set per-deployment via LOOPOVER_LEDGER_CONTENT_WAIVER. Both bounds and a reason are mandatory, the range and count are always published even on a clean chain, waived mismatches are counted separately and never folded into contentMismatches, and CHAIN checks are never waived -- a waived row that is mis-chained still fails), and prunedRecords — the count of rows whose record preimage was legitimately pruned by the published retention window (chain checks still hold for them; only the content re-check is impossible, and the committed digest stays published)."
20764
20764
},
20765
20765
"409": {
20766
20766
"description": "First break found: sequence_gap | predecessor_mismatch | row_hash_mismatch | missing_record | content_mismatch | short_tail (a record newer than the verified tip has no chain entry — the truncated-tail signature) | unchained_record (an INTERIOR record has no chain entry — the failed-append signature). Records younger than the 5-minute append grace window are not reported: the record insert and its chain append are two writes moments apart, and a verify landing between them is not evidence of tampering."
Copy file name to clipboardExpand all lines: src/openapi/spec.ts
+1-1Lines changed: 1 addition & 1 deletion
Original file line number
Diff line number
Diff line change
@@ -1882,7 +1882,7 @@ export function buildOpenApiSpec() {
1882
1882
tags: ["Public"],
1883
1883
summary: "Verify a window of the hash-chained decision ledger (resumable via afterSeq)",
1884
1884
responses: {
1885
-
200: {description: "Window verified clean; nextAfterSeq is the resume cursor (null at the tip). Every response also carries tipSeq/tipHash/totalCount for third-party checkpointing, contentMismatches (#9850 -- the COUNT of rows whose preimage no longer matches the digest the chain committed to; a content mismatch no longer aborts the scan, because one unreconcilable row used to hide every row after it, so a structural break further along was unreachable. The verdict is unchanged: any mismatch is still ok:false and the first is still reported as `break`), and prunedRecords — the count of rows whose record preimage was legitimately pruned by the published retention window (chain checks still hold for them; only the content re-check is impossible, and the committed digest stays published)."},
1885
+
200: {description: "Window verified clean; nextAfterSeq is the resume cursor (null at the tip). Every response also carries tipSeq/tipHash/totalCount for third-party checkpointing, contentMismatches (#9850 -- the COUNT of rows whose preimage no longer matches the digest the chain committed to; a content mismatch no longer aborts the scan, because one unreconcilable row used to hide every row after it, so a structural break further along was unreachable. The verdict is unchanged: any mismatch is still ok:false and the first is still reported as `break`), waivedContentMismatches + contentWaiver (#9850 -- a DECLARED, seq-bounded exclusion from the content re-check for rows whose preimage is unrecoverable, set per-deployment via LOOPOVER_LEDGER_CONTENT_WAIVER. Both bounds and a reason are mandatory, the range and count are always published even on a clean chain, waived mismatches are counted separately and never folded into contentMismatches, and CHAIN checks are never waived -- a waived row that is mis-chained still fails), and prunedRecords — the count of rows whose record preimage was legitimately pruned by the published retention window (chain checks still hold for them; only the content re-check is impossible, and the committed digest stays published)."},
1886
1886
409: {description: "First break found: sequence_gap | predecessor_mismatch | row_hash_mismatch | missing_record | content_mismatch | short_tail (a record newer than the verified tip has no chain entry — the truncated-tail signature) | unchained_record (an INTERIOR record has no chain entry — the failed-append signature). Records younger than the 5-minute append grace window are not reported: the record insert and its chain append are two writes moments apart, and a verify landing between them is not evidence of tampering."},
0 commit comments