Skip to content

Commit bde672f

Browse files
committed
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
1 parent 9382d79 commit bde672f

8 files changed

Lines changed: 248 additions & 13 deletions

File tree

.env.selfhost.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,17 @@ LOOPOVER_REVIEW_ENRICHMENT=false
148148
# LOOPOVER_LEDGER_ANCHOR_GIT_BRANCH=main
149149
# LOOPOVER_LEDGER_ANCHOR_GIT_PATH=anchors.jsonl
150150
# LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID=
151+
152+
# --- Declaring unrecoverable rows (rare) ---------------------------------------------------------
153+
# If your ledger carries rows whose record preimage is genuinely gone -- e.g. written by a version with
154+
# the record-overwriting bug fixed in #9123 -- /v1/public/decision-ledger/verify will report ok:false
155+
# forever. Rewriting those records so the digests match would be exactly the tampering the ledger exists
156+
# to detect, so the honest alternative is to DECLARE them.
157+
#
158+
# Format "<fromSeq>-<toSeq>:<reason>". Both bounds and a reason are mandatory; the range and the count of
159+
# rows it excuses are published by /verify even on a clean chain, so the exclusion is disclosed rather
160+
# than hidden. Bounded by SEQ, never by time -- a date boundary would silently swallow new damage as the
161+
# clock moved. CHAIN checks are never waived: a declared row that is mis-chained still fails.
162+
#
163+
# Malformed values waive NOTHING (preflight flags them) rather than widening the exclusion on a typo.
164+
# LOOPOVER_LEDGER_CONTENT_WAIVER=5-257:rows written before the #9123 record-overwrite fix

apps/loopover-ui/public/openapi.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20760,7 +20760,7 @@
2076020760
"summary": "Verify a window of the hash-chained decision ledger (resumable via afterSeq)",
2076120761
"responses": {
2076220762
"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)."
2076420764
},
2076520765
"409": {
2076620766
"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."

src/env.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,11 @@ declare global {
635635
* than guessing. Public key material only — the private half is LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY and
636636
* must never appear here. Default unset → an empty published list, meaning "no anchors are claimed to be
637637
* verifiable yet", which a verifier can distinguish from a key that exists. See review/ledger-anchor.ts. */
638+
/** #9850: a declared, bounded exclusion from the ledger CONTENT re-check, "<fromSeq>-<toSeq>:<reason>".
639+
* For rows whose record preimage is genuinely unrecoverable (the pre-#9123 record-overwriting UPDATE),
640+
* where the alternative is a permanently-failing public endpoint. Chain checks are never waived, both
641+
* bounds and a reason are mandatory, and the range plus its count are published by /verify. */
642+
LOOPOVER_LEDGER_CONTENT_WAIVER?: string;
638643
LOOPOVER_LEDGER_ANCHOR_KEYS?: string;
639644
/** External ledger anchoring (#9270, epic #9267): the PRIVATE half of the anchor-signing keypair — an
640645
* ECDSA P-256 key in PKCS8 PEM (the `\n`-escaped single-line form is accepted, since that is how a key

src/openapi/spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1882,7 +1882,7 @@ export function buildOpenApiSpec() {
18821882
tags: ["Public"],
18831883
summary: "Verify a window of the hash-chained decision ledger (resumable via afterSeq)",
18841884
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)." },
18861886
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." },
18871887
},
18881888
});

src/review/decision-record.ts

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -439,11 +439,67 @@ export const LEDGER_APPEND_GRACE_MS = 5 * 60 * 1000;
439439
* original preimage. Read-only; safe on a public route — it reads `record_json` to recompute a digest but
440440
* never RETURNS record contents, only the break kind, sequence, and (public, already-exposed) record id.
441441
*/
442+
/** A declared, bounded exclusion from the CONTENT re-check (#9850). Chain checks are never waived. */
443+
export type LedgerContentWaiver = { fromSeq: number; toSeq: number; reason: string };
444+
445+
/**
446+
* PURE. Parse `LOOPOVER_LEDGER_CONTENT_WAIVER`, format `<fromSeq>-<toSeq>:<reason>`.
447+
*
448+
* WHY THIS EXISTS. A row whose record preimage is genuinely unrecoverable -- e.g. the rows the pre-#9123
449+
* record-overwriting UPDATE left behind -- can never be reconciled again. Rewriting those records so the
450+
* digests match would be exactly the tampering this ledger exists to detect, so the only honest options are
451+
* to fail forever or to declare the damage. A permanently-red endpoint is one nobody reads, which is a worse
452+
* public signal than a green one carrying an explicit, counted exclusion.
453+
*
454+
* FOUR PROPERTIES MAKE THIS A DISCLOSURE RATHER THAN A COVER-UP, and each is enforced here:
455+
*
456+
* 1. BOUNDED BY SEQ, NOT TIME. A date boundary drifts: as the clock advances it silently swallows new
457+
* damage. A seq range is fixed forever -- only an explicit edit widens it. (The retention cutoff above
458+
* gets to be time-based because it tracks a published policy that genuinely moves; this does not.)
459+
* 2. BOTH ENDS REQUIRED. An open-ended waiver is a blanket exemption wearing a range's clothes.
460+
* 3. A REASON IS MANDATORY. You cannot waive silently; the text is published with the count, so the claim
461+
* being made is legible to whoever is checking.
462+
* 4. CONTENT ONLY. Enforced at the call site, not here: a waived row still has to pass sequence,
463+
* predecessor and row_hash. Tampering with a waived row's CHAIN position still fails.
464+
*
465+
* Returns null on anything malformed -- fail CLOSED, waiving nothing, so a typo cannot widen an exclusion.
466+
* src/selfhost/preflight.ts surfaces the malformed value rather than leaving it silently inert.
467+
*/
468+
export function parseLedgerContentWaiver(raw: string | undefined): LedgerContentWaiver | null {
469+
if (!raw) return null;
470+
const separator = raw.indexOf(":");
471+
if (separator < 0) return null;
472+
const reason = raw.slice(separator + 1).trim();
473+
if (reason === "") return null;
474+
const match = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(raw.slice(0, separator));
475+
if (!match) return null;
476+
const fromSeq = Number(match[1]);
477+
const toSeq = Number(match[2]);
478+
// A descending or zero-based range is a mistake, not an intent: seq starts at 1 and a waiver must name a
479+
// real, forward interval.
480+
if (fromSeq < 1 || toSeq < fromSeq) return null;
481+
return { fromSeq, toSeq, reason };
482+
}
483+
442484
export async function verifyDecisionLedger(
443485
env: Env,
444486
afterSeq = 0,
445487
limit = 500,
446-
): Promise<{ ok: boolean; checked: number; nextAfterSeq: number | null; tipSeq: number; tipHash: string; totalCount: number; prunedRecords: number; contentMismatches: number; break?: LedgerBreak }> {
488+
): Promise<{
489+
ok: boolean;
490+
checked: number;
491+
nextAfterSeq: number | null;
492+
tipSeq: number;
493+
tipHash: string;
494+
totalCount: number;
495+
prunedRecords: number;
496+
contentMismatches: number;
497+
/** #9850: mismatches inside the declared waiver -- reported separately and prominently, never folded into
498+
* contentMismatches, so "83 rows are excused" can never read as "83 rows are fine". */
499+
waivedContentMismatches: number;
500+
contentWaiver: LedgerContentWaiver | null;
501+
break?: LedgerBreak;
502+
}> {
447503
const bounded = Math.max(1, Math.min(1000, limit));
448504
// #9474: rows whose record preimage was legitimately pruned by the published retention policy (see the
449505
// missing-record branch below). Surfaced in the result so "the chain is clean but N old preimages are no
@@ -464,7 +520,9 @@ export async function verifyDecisionLedger(
464520
// `ok` is still false and the first mismatch is still reported as `break`, so nothing about the verdict is
465521
// softened; the scan simply keeps going and reports how many there are.
466522
let contentMismatches = 0;
523+
let waivedContentMismatches = 0;
467524
let firstContentMismatch: LedgerBreak | null = null;
525+
const contentWaiver = parseLedgerContentWaiver(env.LOOPOVER_LEDGER_CONTENT_WAIVER);
468526
const decisionRecordsPruneCutoff = retentionCutoffIsoForTable("decision_records");
469527
const [totalRow, globalTip, prior] = await Promise.all([
470528
env.DB.prepare("SELECT COUNT(*) AS n FROM decision_ledger").first<{ n: number }>(),
@@ -477,7 +535,7 @@ export async function verifyDecisionLedger(
477535
const tipSeq = globalTip?.seq ?? 0;
478536
const tipHash = globalTip?.rowHash ?? LEDGER_GENESIS_HASH;
479537
// `== null` deliberately: D1 drivers disagree on .first() returning null vs undefined for no-row.
480-
if (afterSeq > 0 && prior == null) return { ok: false, checked: 0, nextAfterSeq: null, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, break: { kind: "sequence_gap", atSeq: afterSeq, expectedSeq: afterSeq } };
538+
if (afterSeq > 0 && prior == null) return { ok: false, checked: 0, nextAfterSeq: null, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, waivedContentMismatches, contentWaiver, break: { kind: "sequence_gap", atSeq: afterSeq, expectedSeq: afterSeq } };
481539
let prevHash = prior?.rowHash ?? LEDGER_GENESIS_HASH;
482540
let expectedSeq = afterSeq + 1;
483541
const { results } = await env.DB.prepare(
@@ -503,10 +561,10 @@ export async function verifyDecisionLedger(
503561
// from) so a call that finds ZERO new rows still has an anchor to reconcile against.
504562
let lastVerifiedCreatedAt = prior?.createdAt ?? null;
505563
for (const row of results) {
506-
if (row.seq !== expectedSeq) return { ok: false, checked, nextAfterSeq: null, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, break: { kind: "sequence_gap", atSeq: row.seq, expectedSeq } };
507-
if (row.prevHash !== prevHash) return { ok: false, checked, nextAfterSeq: null, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, break: { kind: "predecessor_mismatch", atSeq: row.seq } };
564+
if (row.seq !== expectedSeq) return { ok: false, checked, nextAfterSeq: null, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, waivedContentMismatches, contentWaiver, break: { kind: "sequence_gap", atSeq: row.seq, expectedSeq } };
565+
if (row.prevHash !== prevHash) return { ok: false, checked, nextAfterSeq: null, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, waivedContentMismatches, contentWaiver, break: { kind: "predecessor_mismatch", atSeq: row.seq } };
508566
const recomputed = await ledgerRowHash(prevHash, { seq: row.seq, recordId: row.recordId, recordDigest: row.recordDigest, createdAt: row.createdAt });
509-
if (recomputed !== row.rowHash) return { ok: false, checked, nextAfterSeq: null, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, break: { kind: "row_hash_mismatch", atSeq: row.seq } };
567+
if (recomputed !== row.rowHash) return { ok: false, checked, nextAfterSeq: null, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, waivedContentMismatches, contentWaiver, break: { kind: "row_hash_mismatch", atSeq: row.seq } };
510568
// #9078: the promised record/ledger reconciliation — a row_hash chained cleanly can still commit to a
511569
// digest whose CONTENT has since been rewritten (or whose preimage is simply gone). Neither is visible to
512570
// the chain-only checks above, since those only ever compare ledger rows against each other.
@@ -524,7 +582,7 @@ export async function verifyDecisionLedger(
524582
if (decisionRecordsPruneCutoff !== null && row.createdAt < decisionRecordsPruneCutoff) {
525583
prunedRecords += 1;
526584
} else {
527-
return { ok: false, checked, nextAfterSeq: null, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, break: { kind: "missing_record", atSeq: row.seq, recordId: row.recordId } };
585+
return { ok: false, checked, nextAfterSeq: null, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, waivedContentMismatches, contentWaiver, break: { kind: "missing_record", atSeq: row.seq, recordId: row.recordId } };
528586
}
529587
} else {
530588
let recomputedContentDigest: string | null = null;
@@ -535,8 +593,15 @@ export async function verifyDecisionLedger(
535593
recomputedContentDigest = null;
536594
}
537595
if (recomputedContentDigest !== row.recordDigest) {
538-
contentMismatches += 1;
539-
firstContentMismatch ??= { kind: "content_mismatch", atSeq: row.seq, recordId: row.recordId };
596+
// Inside a declared waiver this is counted and disclosed, not forgiven silently -- and note this is
597+
// reached only AFTER the chain checks above passed for this row, so a waiver can never excuse a
598+
// structural break.
599+
if (contentWaiver !== null && row.seq >= contentWaiver.fromSeq && row.seq <= contentWaiver.toSeq) {
600+
waivedContentMismatches += 1;
601+
} else {
602+
contentMismatches += 1;
603+
firstContentMismatch ??= { kind: "content_mismatch", atSeq: row.seq, recordId: row.recordId };
604+
}
540605
}
541606
}
542607
prevHash = row.rowHash;
@@ -589,6 +654,8 @@ export async function verifyDecisionLedger(
589654
totalCount,
590655
prunedRecords,
591656
contentMismatches,
657+
waivedContentMismatches,
658+
contentWaiver,
592659
break:
593660
orphan.createdAt > lastVerifiedCreatedAt
594661
? { kind: "short_tail", atSeq: expectedSeq - 1 }
@@ -599,9 +666,9 @@ export async function verifyDecisionLedger(
599666
// A content mismatch is still a FAILED verification -- the scan continuing does not soften the verdict, it
600667
// only stops one bad row from hiding every row after it. The first is reported as `break` exactly as before.
601668
if (firstContentMismatch !== null) {
602-
return { ok: false, checked, nextAfterSeq, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, break: firstContentMismatch };
669+
return { ok: false, checked, nextAfterSeq, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, waivedContentMismatches, contentWaiver, break: firstContentMismatch };
603670
}
604-
return { ok: true, checked, nextAfterSeq, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches };
671+
return { ok: true, checked, nextAfterSeq, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, waivedContentMismatches, contentWaiver };
605672
}
606673

607674
/** One ledger row, exactly as chained -- the shape `GET /v1/public/decision-ledger/row/:seq` returns. */

0 commit comments

Comments
 (0)