diff --git a/.env.selfhost.example b/.env.selfhost.example index d93675dfc1..31fd42ded8 100644 --- a/.env.selfhost.example +++ b/.env.selfhost.example @@ -148,3 +148,17 @@ LOOPOVER_REVIEW_ENRICHMENT=false # LOOPOVER_LEDGER_ANCHOR_GIT_BRANCH=main # LOOPOVER_LEDGER_ANCHOR_GIT_PATH=anchors.jsonl # LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID= + +# --- Declaring unrecoverable rows (rare) --------------------------------------------------------- +# If your ledger carries rows whose record preimage is genuinely gone -- e.g. written by a version with +# the record-overwriting bug fixed in #9123 -- /v1/public/decision-ledger/verify will report ok:false +# forever. Rewriting those records so the digests match would be exactly the tampering the ledger exists +# to detect, so the honest alternative is to DECLARE them. +# +# Format "-:". Both bounds and a reason are mandatory; the range and the count of +# rows it excuses are published by /verify even on a clean chain, so the exclusion is disclosed rather +# than hidden. Bounded by SEQ, never by time -- a date boundary would silently swallow new damage as the +# clock moved. CHAIN checks are never waived: a declared row that is mis-chained still fails. +# +# Malformed values waive NOTHING (preflight flags them) rather than widening the exclusion on a typo. +# LOOPOVER_LEDGER_CONTENT_WAIVER=5-257:rows written before the #9123 record-overwrite fix diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index f77ea5b3bd..092284aa64 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -20805,7 +20805,7 @@ "summary": "Verify a window of the hash-chained decision ledger (resumable via afterSeq)", "responses": { "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)." + "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)." }, "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." diff --git a/apps/loopover-ui/src/lib/selfhost-env-reference.ts b/apps/loopover-ui/src/lib/selfhost-env-reference.ts index d9312ad86c..b37770fb62 100644 --- a/apps/loopover-ui/src/lib/selfhost-env-reference.ts +++ b/apps/loopover-ui/src/lib/selfhost-env-reference.ts @@ -301,6 +301,10 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ name: "LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY", firstReference: "src/selfhost/preflight.ts", }, + { + name: "LOOPOVER_LEDGER_CONTENT_WAIVER", + firstReference: "src/selfhost/preflight.ts", + }, { name: "LOOPOVER_MCP_TOKEN", firstReference: "src/selfhost/preflight.ts", @@ -784,6 +788,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `LOOPOVER_LEDGER_ANCHOR_GIT_REPO` | `src/selfhost/preflight.ts` |", "| `LOOPOVER_LEDGER_ANCHOR_KEYS` | `src/selfhost/preflight.ts` |", "| `LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY` | `src/selfhost/preflight.ts` |", + "| `LOOPOVER_LEDGER_CONTENT_WAIVER` | `src/selfhost/preflight.ts` |", "| `LOOPOVER_MCP_TOKEN` | `src/selfhost/preflight.ts` |", "| `LOOPOVER_METRICS_REPO_LABELS` | `src/server.ts` |", "| `LOOPOVER_PUBLIC_SCORE_TERMS_ALLOWED_REPOS` | `src/selfhost/inert-config.ts` |", diff --git a/src/env.d.ts b/src/env.d.ts index 7cfcb2ff59..ac51f23e1c 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -635,6 +635,11 @@ declare global { * than guessing. Public key material only — the private half is LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY and * must never appear here. Default unset → an empty published list, meaning "no anchors are claimed to be * verifiable yet", which a verifier can distinguish from a key that exists. See review/ledger-anchor.ts. */ + /** #9850: a declared, bounded exclusion from the ledger CONTENT re-check, "-:". + * For rows whose record preimage is genuinely unrecoverable (the pre-#9123 record-overwriting UPDATE), + * where the alternative is a permanently-failing public endpoint. Chain checks are never waived, both + * bounds and a reason are mandatory, and the range plus its count are published by /verify. */ + LOOPOVER_LEDGER_CONTENT_WAIVER?: string; LOOPOVER_LEDGER_ANCHOR_KEYS?: string; /** External ledger anchoring (#9270, epic #9267): the PRIVATE half of the anchor-signing keypair — an * ECDSA P-256 key in PKCS8 PEM (the `\n`-escaped single-line form is accepted, since that is how a key diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 2c62a216ed..a6edebc233 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -1882,7 +1882,7 @@ export function buildOpenApiSpec() { tags: ["Public"], summary: "Verify a window of the hash-chained decision ledger (resumable via afterSeq)", responses: { - 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)." }, + 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)." }, 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." }, }, }); diff --git a/src/review/decision-record.ts b/src/review/decision-record.ts index a2e9fac907..21e7997ea8 100644 --- a/src/review/decision-record.ts +++ b/src/review/decision-record.ts @@ -439,11 +439,67 @@ const LEDGER_APPEND_GRACE_MS = 5 * 60 * 1000; * original preimage. Read-only; safe on a public route — it reads `record_json` to recompute a digest but * never RETURNS record contents, only the break kind, sequence, and (public, already-exposed) record id. */ +/** A declared, bounded exclusion from the CONTENT re-check (#9850). Chain checks are never waived. */ +export type LedgerContentWaiver = { fromSeq: number; toSeq: number; reason: string }; + +/** + * PURE. Parse `LOOPOVER_LEDGER_CONTENT_WAIVER`, format `-:`. + * + * WHY THIS EXISTS. A row whose record preimage is genuinely unrecoverable -- e.g. 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. + * + * FOUR PROPERTIES MAKE THIS A DISCLOSURE RATHER THAN A COVER-UP, and each is enforced here: + * + * 1. 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 above + * gets to be time-based because it tracks a published policy that genuinely moves; this does not.) + * 2. BOTH ENDS REQUIRED. An open-ended waiver is a blanket exemption wearing a range's clothes. + * 3. A REASON IS MANDATORY. You cannot waive silently; the text is published with the count, so the claim + * being made is legible to whoever is checking. + * 4. CONTENT ONLY. Enforced at the call site, not here: a waived row still has to pass sequence, + * predecessor and row_hash. Tampering with a waived row's CHAIN position still fails. + * + * Returns null on anything malformed -- fail CLOSED, waiving nothing, so a typo cannot widen an exclusion. + * src/selfhost/preflight.ts surfaces the malformed value rather than leaving it silently inert. + */ +export function parseLedgerContentWaiver(raw: string | undefined): LedgerContentWaiver | null { + if (!raw) return null; + const separator = raw.indexOf(":"); + if (separator < 0) return null; + const reason = raw.slice(separator + 1).trim(); + if (reason === "") return null; + const match = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(raw.slice(0, separator)); + if (!match) return null; + const fromSeq = Number(match[1]); + const toSeq = Number(match[2]); + // A descending or zero-based range is a mistake, not an intent: seq starts at 1 and a waiver must name a + // real, forward interval. + if (fromSeq < 1 || toSeq < fromSeq) return null; + return { fromSeq, toSeq, reason }; +} + export async function verifyDecisionLedger( env: Env, afterSeq = 0, limit = 500, -): Promise<{ ok: boolean; checked: number; nextAfterSeq: number | null; tipSeq: number; tipHash: string; totalCount: number; prunedRecords: number; contentMismatches: number; break?: LedgerBreak }> { +): Promise<{ + ok: boolean; + checked: number; + nextAfterSeq: number | null; + tipSeq: number; + tipHash: string; + totalCount: number; + prunedRecords: number; + contentMismatches: number; + /** #9850: mismatches inside the declared waiver -- reported separately and prominently, never folded into + * contentMismatches, so "83 rows are excused" can never read as "83 rows are fine". */ + waivedContentMismatches: number; + contentWaiver: LedgerContentWaiver | null; + break?: LedgerBreak; +}> { const bounded = Math.max(1, Math.min(1000, limit)); // #9474: rows whose record preimage was legitimately pruned by the published retention policy (see the // 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( // `ok` is still false and the first mismatch is still reported as `break`, so nothing about the verdict is // softened; the scan simply keeps going and reports how many there are. let contentMismatches = 0; + let waivedContentMismatches = 0; let firstContentMismatch: LedgerBreak | null = null; + const contentWaiver = parseLedgerContentWaiver(env.LOOPOVER_LEDGER_CONTENT_WAIVER); const decisionRecordsPruneCutoff = retentionCutoffIsoForTable("decision_records"); const [totalRow, globalTip, prior] = await Promise.all([ env.DB.prepare("SELECT COUNT(*) AS n FROM decision_ledger").first<{ n: number }>(), @@ -477,7 +535,7 @@ export async function verifyDecisionLedger( const tipSeq = globalTip?.seq ?? 0; const tipHash = globalTip?.rowHash ?? LEDGER_GENESIS_HASH; // `== null` deliberately: D1 drivers disagree on .first() returning null vs undefined for no-row. - 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 } }; + 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 } }; let prevHash = prior?.rowHash ?? LEDGER_GENESIS_HASH; let expectedSeq = afterSeq + 1; const { results } = await env.DB.prepare( @@ -503,10 +561,10 @@ export async function verifyDecisionLedger( // from) so a call that finds ZERO new rows still has an anchor to reconcile against. let lastVerifiedCreatedAt = prior?.createdAt ?? null; for (const row of results) { - if (row.seq !== expectedSeq) return { ok: false, checked, nextAfterSeq: null, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, break: { kind: "sequence_gap", atSeq: row.seq, expectedSeq } }; - if (row.prevHash !== prevHash) return { ok: false, checked, nextAfterSeq: null, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, break: { kind: "predecessor_mismatch", atSeq: row.seq } }; + 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 } }; + if (row.prevHash !== prevHash) return { ok: false, checked, nextAfterSeq: null, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, waivedContentMismatches, contentWaiver, break: { kind: "predecessor_mismatch", atSeq: row.seq } }; const recomputed = await ledgerRowHash(prevHash, { seq: row.seq, recordId: row.recordId, recordDigest: row.recordDigest, createdAt: row.createdAt }); - if (recomputed !== row.rowHash) return { ok: false, checked, nextAfterSeq: null, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, break: { kind: "row_hash_mismatch", atSeq: row.seq } }; + 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 } }; // #9078: the promised record/ledger reconciliation — a row_hash chained cleanly can still commit to a // digest whose CONTENT has since been rewritten (or whose preimage is simply gone). Neither is visible to // the chain-only checks above, since those only ever compare ledger rows against each other. @@ -524,7 +582,7 @@ export async function verifyDecisionLedger( if (decisionRecordsPruneCutoff !== null && row.createdAt < decisionRecordsPruneCutoff) { prunedRecords += 1; } else { - return { ok: false, checked, nextAfterSeq: null, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, break: { kind: "missing_record", atSeq: row.seq, recordId: row.recordId } }; + return { ok: false, checked, nextAfterSeq: null, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, waivedContentMismatches, contentWaiver, break: { kind: "missing_record", atSeq: row.seq, recordId: row.recordId } }; } } else { let recomputedContentDigest: string | null = null; @@ -535,8 +593,15 @@ export async function verifyDecisionLedger( recomputedContentDigest = null; } if (recomputedContentDigest !== row.recordDigest) { - contentMismatches += 1; - firstContentMismatch ??= { kind: "content_mismatch", atSeq: row.seq, recordId: row.recordId }; + // Inside a declared waiver this is counted and disclosed, not forgiven silently -- and note this is + // reached only AFTER the chain checks above passed for this row, so a waiver can never excuse a + // structural break. + if (contentWaiver !== null && row.seq >= contentWaiver.fromSeq && row.seq <= contentWaiver.toSeq) { + waivedContentMismatches += 1; + } else { + contentMismatches += 1; + firstContentMismatch ??= { kind: "content_mismatch", atSeq: row.seq, recordId: row.recordId }; + } } } prevHash = row.rowHash; @@ -589,6 +654,8 @@ export async function verifyDecisionLedger( totalCount, prunedRecords, contentMismatches, + waivedContentMismatches, + contentWaiver, break: orphan.createdAt > lastVerifiedCreatedAt ? { kind: "short_tail", atSeq: expectedSeq - 1 } @@ -599,9 +666,9 @@ export async function verifyDecisionLedger( // A content mismatch is still a FAILED verification -- the scan continuing does not soften the verdict, it // only stops one bad row from hiding every row after it. The first is reported as `break` exactly as before. if (firstContentMismatch !== null) { - return { ok: false, checked, nextAfterSeq, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, break: firstContentMismatch }; + return { ok: false, checked, nextAfterSeq, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, waivedContentMismatches, contentWaiver, break: firstContentMismatch }; } - return { ok: true, checked, nextAfterSeq, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches }; + return { ok: true, checked, nextAfterSeq, tipSeq, tipHash, totalCount, prunedRecords, contentMismatches, waivedContentMismatches, contentWaiver }; } /** One ledger row, exactly as chained -- the shape `GET /v1/public/decision-ledger/row/:seq` returns. */ diff --git a/src/selfhost/preflight.ts b/src/selfhost/preflight.ts index 5b69153bdc..9f5812acdf 100644 --- a/src/selfhost/preflight.ts +++ b/src/selfhost/preflight.ts @@ -1,6 +1,7 @@ import { createPrivateKey } from "node:crypto"; import { CRON_INTERVAL_MIN_MS } from "./cron-alignment"; import { currentAnchorKey, parseAnchorPublicKeys } from "../review/ledger-anchor"; +import { parseLedgerContentWaiver } from "../review/decision-record"; export type SelfHostPreflightProblem = { var: string; @@ -82,6 +83,25 @@ function isGitHubAppPrivateKey(value: string): boolean { * The published-key checks call the real `parseAnchorPublicKeys`/`currentAnchorKey` rather than * re-validating the shape here, so preflight can never disagree with what the scheduler will actually do. */ +/** + * #9850: a malformed LOOPOVER_LEDGER_CONTENT_WAIVER fails CLOSED -- it waives nothing, which is the safe + * direction but also a completely silent one. An operator who fat-fingers the range believes rows are + * declared when they are not, and only finds out when the public endpoint keeps reporting a failure they + * thought they had disclosed. Same reasoning as the anchor-config check below: the danger is not the unset + * value, it is the set-but-ineffective one. + */ +function checkLedgerContentWaiverConfig(problems: SelfHostPreflightProblem[], env: SelfHostPreflightEnv): void { + const raw = nonBlank(env["LOOPOVER_LEDGER_CONTENT_WAIVER"]); + if (!raw) return; + if (parseLedgerContentWaiver(raw) === null) { + addProblem( + problems, + "LOOPOVER_LEDGER_CONTENT_WAIVER", + 'Set but unparseable, so NOTHING is waived and /v1/public/decision-ledger/verify will keep reporting the mismatches you meant to declare. Expected "-:" with both bounds, fromSeq >= 1, toSeq >= fromSeq, and a non-empty reason.', + ); + } +} + function checkLedgerAnchorConfig(problems: SelfHostPreflightProblem[], env: SelfHostPreflightEnv): void { const rawKeys = nonBlank(env["LOOPOVER_LEDGER_ANCHOR_KEYS"]); const privateKey = nonBlank(env["LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY"]); @@ -345,6 +365,7 @@ export function preflightEnv(env: SelfHostPreflightEnv): SelfHostPreflightResult positiveInteger(problems, env, "GITHUB_CACHE_TTL_SECONDS", 0, 86_400); checkLedgerAnchorConfig(problems, env); + checkLedgerContentWaiverConfig(problems, env); return problems.length === 0 ? { ok: true, problems: [] } : { ok: false, problems }; } diff --git a/test/unit/decision-record.test.ts b/test/unit/decision-record.test.ts index f5bd9b29e8..b1f0fd9b27 100644 --- a/test/unit/decision-record.test.ts +++ b/test/unit/decision-record.test.ts @@ -4,6 +4,7 @@ import { canonicalJson, contentDigest, DECISION_RECORD_SCHEMA_VERSION, + parseLedgerContentWaiver, persistDecisionRecord, renderDecisionRecordSection, sha256Hex, @@ -384,7 +385,7 @@ describe("decision ledger (#8837)", () => { it("verifying a completely empty ledger returns ok:true with a zero tip, and skips the tail-truncation check (nothing to anchor against yet)", async () => { const env = createTestEnv(); const verified = await verifyDecisionLedger(env); - expect(verified).toEqual({ ok: true, checked: 0, nextAfterSeq: null, tipSeq: 0, tipHash: LEDGER_GENESIS_HASH, totalCount: 0, prunedRecords: 0, contentMismatches: 0 }); + expect(verified).toEqual({ ok: true, checked: 0, nextAfterSeq: null, tipSeq: 0, tipHash: LEDGER_GENESIS_HASH, totalCount: 0, prunedRecords: 0, contentMismatches: 0, waivedContentMismatches: 0, contentWaiver: null }); }); it("TAIL TRUNCATION now breaks verify instead of passing clean (#9122): dropping the newest ledger rows leaves an orphaned decision_records tail", async () => { @@ -713,3 +714,112 @@ describe("content mismatches do not mask later rows (#9850)", () => { expect(verified).toMatchObject({ ok: true, contentMismatches: 0 }); }); }); + +// #9850: rows whose preimage is genuinely unrecoverable -- the pre-#9123 record-overwriting UPDATE -- can +// never be reconciled. Rewriting them to match would be the tampering this ledger exists to detect, so the +// only honest options are to fail forever or to DECLARE the damage. These pin that the declaration is a +// disclosure and not a blanket exemption. +describe("parseLedgerContentWaiver (#9850)", () => { + it("parses a bounded range with a reason", () => { + expect(parseLedgerContentWaiver("5-257:pre-9123 record overwrite")).toEqual({ fromSeq: 5, toSeq: 257, reason: "pre-9123 record overwrite" }); + }); + + it("tolerates surrounding whitespace in the range", () => { + expect(parseLedgerContentWaiver(" 5 - 257 : why ")).toMatchObject({ fromSeq: 5, toSeq: 257, reason: "why" }); + }); + + it("REQUIRES a reason -- you cannot waive silently", () => { + expect(parseLedgerContentWaiver("5-257:")).toBeNull(); + expect(parseLedgerContentWaiver("5-257: ")).toBeNull(); + expect(parseLedgerContentWaiver("5-257")).toBeNull(); + }); + + it("REQUIRES both bounds -- an open-ended waiver is a blanket exemption wearing a range's clothes", () => { + for (const bad of ["5-:r", "-257:r", "5:r", "-:r"]) expect(parseLedgerContentWaiver(bad)).toBeNull(); + }); + + it("rejects a descending or zero-based range, which is a mistake rather than an intent", () => { + expect(parseLedgerContentWaiver("257-5:r")).toBeNull(); + expect(parseLedgerContentWaiver("0-10:r")).toBeNull(); + }); + + it("fails CLOSED on anything malformed, so a typo can never widen an exclusion", () => { + for (const bad of [undefined, "", " ", "all:r", "5..257:r", "1e3-2e3:r", "-5--1:r"]) { + expect(parseLedgerContentWaiver(bad)).toBeNull(); + } + }); +}); + +describe("content waiver applied by verifyDecisionLedger (#9850)", () => { + const seedChained = async (env: Env, count: number) => { + for (let i = 1; i <= count; i += 1) { + const { record, recordDigest } = await buildDecisionRecord(recordInput({ pullNumber: i })); + await persistDecisionRecord(env, record, recordDigest); + } + }; + const corruptRecordAt = async (env: Env, seq: number) => { + const row = await env.DB.prepare("SELECT record_id AS recordId FROM decision_ledger WHERE seq = ?").bind(seq).first<{ recordId: string }>(); + await env.DB.prepare("UPDATE decision_records SET record_json = ? WHERE id = ?").bind('{"tampered":true}', row!.recordId).run(); + }; + + it("reports ok:true with the mismatch counted SEPARATELY when it falls inside the declared range", async () => { + const env = createTestEnv({ LOOPOVER_LEDGER_CONTENT_WAIVER: "2-3:pre-9123 record overwrite" }); + await seedChained(env, 5); + await corruptRecordAt(env, 2); + + const verified = await verifyDecisionLedger(env); + + expect(verified.ok).toBe(true); + expect(verified.waivedContentMismatches).toBe(1); + expect(verified.contentMismatches).toBe(0); // never folded together -- "excused" must not read as "fine" + expect(verified.contentWaiver).toEqual({ fromSeq: 2, toSeq: 3, reason: "pre-9123 record overwrite" }); + expect(verified.break).toBeUndefined(); + }); + + it("INVARIANT: a mismatch OUTSIDE the range still fails, so the waiver is bounded in practice not just on paper", async () => { + const env = createTestEnv({ LOOPOVER_LEDGER_CONTENT_WAIVER: "2-3:declared" }); + await seedChained(env, 6); + await corruptRecordAt(env, 2); // inside + await corruptRecordAt(env, 5); // outside + + const verified = await verifyDecisionLedger(env); + + expect(verified.ok).toBe(false); + expect(verified.break).toMatchObject({ kind: "content_mismatch", atSeq: 5 }); + expect(verified.waivedContentMismatches).toBe(1); + expect(verified.contentMismatches).toBe(1); + }); + + it("INVARIANT: a waiver NEVER excuses a structural break inside its own range", async () => { + // The whole safety argument: the content check runs only after sequence/predecessor/row_hash pass, so a + // waived row still has to be chained correctly. Tampering with a waived row's CHAIN position must fail. + const env = createTestEnv({ LOOPOVER_LEDGER_CONTENT_WAIVER: "1-99:declared" }); + await seedChained(env, 5); + await env.DB.prepare("UPDATE decision_ledger SET row_hash = ? WHERE seq = 3").bind("f".repeat(64)).run(); + + const verified = await verifyDecisionLedger(env); + + expect(verified.ok).toBe(false); + expect(verified.break).toMatchObject({ kind: "row_hash_mismatch", atSeq: 3 }); + }); + + it("waives nothing when the value is malformed -- fail closed, and the failure is still reported", async () => { + const env = createTestEnv({ LOOPOVER_LEDGER_CONTENT_WAIVER: "not-a-range" }); + await seedChained(env, 4); + await corruptRecordAt(env, 2); + + const verified = await verifyDecisionLedger(env); + + expect(verified.ok).toBe(false); + expect(verified.contentWaiver).toBeNull(); + expect(verified.waivedContentMismatches).toBe(0); + expect(verified.contentMismatches).toBe(1); + }); + + it("publishes the waiver on a CLEAN chain too, so the declaration is visible without needing a failure to reveal it", async () => { + const env = createTestEnv({ LOOPOVER_LEDGER_CONTENT_WAIVER: "2-3:declared" }); + await seedChained(env, 4); + const verified = await verifyDecisionLedger(env); + expect(verified).toMatchObject({ ok: true, waivedContentMismatches: 0, contentWaiver: { fromSeq: 2, toSeq: 3 } }); + }); +}); diff --git a/test/unit/selfhost-preflight.test.ts b/test/unit/selfhost-preflight.test.ts index 97ea827563..176589ab63 100644 --- a/test/unit/selfhost-preflight.test.ts +++ b/test/unit/selfhost-preflight.test.ts @@ -450,6 +450,24 @@ describe("ledger-anchor configuration preflight (#9769)", () => { return result.problems.filter((p: SelfHostPreflightProblem) => p.var.startsWith("LOOPOVER_LEDGER_ANCHOR")); }; + // #9850: a malformed waiver fails CLOSED -- safe, but silent. The operator believes rows are declared when + // they are not, and finds out only when the public endpoint keeps failing on mismatches they thought they + // had disclosed. Same class as the half-configured anchoring below: the danger is the set-but-ineffective + // value, not the unset one. + const waiverProblems = (env: Record) => + preflightEnv({ ...base, ...env }).problems.filter((p: SelfHostPreflightProblem) => p.var === "LOOPOVER_LEDGER_CONTENT_WAIVER"); + + it("flags a set-but-unparseable content waiver, which would otherwise waive nothing in silence", () => { + const problems = waiverProblems({ LOOPOVER_LEDGER_CONTENT_WAIVER: "5-257" }); // no reason + expect(problems).toHaveLength(1); + expect(problems[0]!.message).toContain("NOTHING is waived"); + }); + + it("stays silent for an unset waiver (opt-in) and for a well-formed one", () => { + expect(waiverProblems({})).toEqual([]); + expect(waiverProblems({ LOOPOVER_LEDGER_CONTENT_WAIVER: "5-257:pre-9123 record overwrite" })).toEqual([]); + }); + it("INVARIANT: anchoring is opt-in — configuring none of it is never a problem", () => { expect(preflightEnv(base)).toEqual({ ok: true, problems: [] }); });