Summary
Four defects in the decision-ledger anchoring feature that shipped 2026-07-27 (#9413/#9274). Each one degrades the property the feature exists to provide — an external, tamper-evident anchor a skeptic can verify — and two of them produce signals that read as evidence of tampering when nothing was tampered with.
1. A failed anchor at a quiet tip is never retried
src/review/ledger-anchor-scheduler.ts:39-42 decides tipUnchanged by comparing the rowHash against the last attempt regardless of its status (src/review/ledger-anchor-persistence.ts:161-167).
Scenario: Rekor returns 429 at seq N; the ledger then goes quiet (a weekend); every hourly tick returns unchanged; the tip therefore has no valid external anchor indefinitely — exactly the unanchored window the feature exists to bound.
It is also backend-blind: git succeeding at seq N masks Rekor's failure at seq N, because there is no per-backend watermark.
2. A tip read race publishes a signed, unretractable false tamper signal
src/review/decision-record.ts:346-354 issues the COUNT(*) and the tip row as two separate queries under Promise.all — no snapshot isolation. A concurrent appendDecisionLedger landing between them yields totalCount = seq + 1 paired with the old rowHash.
Per the payload documentation (src/review/ledger-anchor.ts:29-31), that seq/totalCount pairing is precisely how a verifier detects truncation-and-rechaining. So the race publishes a signed, Rekor-anchored, unretractable false tamper signal.
3. The git backend truncates anchors.jsonl past 1 MB
src/review/ledger-anchor-git.ts:66-75: for a file between 1 MB and 100 MB, GitHub's Contents API returns content: "" with encoding: "none". The code checks typeof data.content === "string", which "" satisfies, so existingContent becomes "" and the subsequent PUT rewrites the file to a single line — recording status: "ok".
At roughly 300 B per line and hourly cadence, that is about 4–5 months out. History survives in the git commits, but the file a skeptic is told to read shrinks — indistinguishable from the tampering signal the module's own header teaches them to look for. The whole-file read-modify-write is also O(n²) in bytes over time.
4. short_tail is transiently false-positive and permanently blind to interior orphans
src/review/decision-record.ts:487-493: a verification running between the record INSERT and the ledger append — or after a permanently failed append (:225-236) — reports short_tail, i.e. "tampering", on a public endpoint.
Conversely, once any newer row chains, an unchained record's created_at falls behind lastVerifiedCreatedAt and that orphan becomes invisible forever, because the completeness reconciliation only ever examines the tail.
Minor
loadPublicLedgerAnchors's keyset cursor is created_at < ? with ORDER BY created_at DESC, id DESC (src/review/ledger-anchor-persistence.ts:106-127) — ties on created_at at a page boundary are skipped. Use a composite (created_at, id) cursor.
Verified solid (do not re-audit)
The signing and verification crypto in src/review/ledger-anchor.ts is sound: canonical serialization shared by sign and verify, fail-closed key parsing, ambiguity-safe currentAnchorKey, and parseRekorResponse degrading to a recorded failure. One documented nuance: nothing checks payload.at against a key's [notBefore, notAfter] window at verification — weak protection anyway given an attacker-chosen at, reasonable to leave as-is.
Summary
Four defects in the decision-ledger anchoring feature that shipped 2026-07-27 (#9413/#9274). Each one degrades the property the feature exists to provide — an external, tamper-evident anchor a skeptic can verify — and two of them produce signals that read as evidence of tampering when nothing was tampered with.
1. A failed anchor at a quiet tip is never retried
src/review/ledger-anchor-scheduler.ts:39-42decidestipUnchangedby comparing the rowHash against the last attempt regardless of its status (src/review/ledger-anchor-persistence.ts:161-167).Scenario: Rekor returns 429 at seq N; the ledger then goes quiet (a weekend); every hourly tick returns
unchanged; the tip therefore has no valid external anchor indefinitely — exactly the unanchored window the feature exists to bound.It is also backend-blind: git succeeding at seq N masks Rekor's failure at seq N, because there is no per-backend watermark.
failed, with backoff.2. A tip read race publishes a signed, unretractable false tamper signal
src/review/decision-record.ts:346-354issues theCOUNT(*)and the tip row as two separate queries underPromise.all— no snapshot isolation. A concurrentappendDecisionLedgerlanding between them yieldstotalCount = seq + 1paired with the old rowHash.Per the payload documentation (
src/review/ledger-anchor.ts:29-31), that seq/totalCount pairing is precisely how a verifier detects truncation-and-rechaining. So the race publishes a signed, Rekor-anchored, unretractable false tamper signal.DB.batch.3. The git backend truncates
anchors.jsonlpast 1 MBsrc/review/ledger-anchor-git.ts:66-75: for a file between 1 MB and 100 MB, GitHub's Contents API returnscontent: ""withencoding: "none". The code checkstypeof data.content === "string", which""satisfies, soexistingContentbecomes""and the subsequent PUT rewrites the file to a single line — recordingstatus: "ok".At roughly 300 B per line and hourly cadence, that is about 4–5 months out. History survives in the git commits, but the file a skeptic is told to read shrinks — indistinguishable from the tampering signal the module's own header teaches them to look for. The whole-file read-modify-write is also O(n²) in bytes over time.
data.content === "" && data.size > 0; consider the Git Data API instead of Contents.4.
short_tailis transiently false-positive and permanently blind to interior orphanssrc/review/decision-record.ts:487-493: a verification running between the record INSERT and the ledger append — or after a permanently failed append (:225-236) — reportsshort_tail, i.e. "tampering", on a public endpoint.Conversely, once any newer row chains, an unchained record's
created_atfalls behindlastVerifiedCreatedAtand that orphan becomes invisible forever, because the completeness reconciliation only ever examines the tail.Minor
loadPublicLedgerAnchors's keyset cursor iscreated_at < ?withORDER BY created_at DESC, id DESC(src/review/ledger-anchor-persistence.ts:106-127) — ties oncreated_atat a page boundary are skipped. Use a composite(created_at, id)cursor.Verified solid (do not re-audit)
The signing and verification crypto in
src/review/ledger-anchor.tsis sound: canonical serialization shared by sign and verify, fail-closed key parsing, ambiguity-safecurrentAnchorKey, andparseRekorResponsedegrading to a recorded failure. One documented nuance: nothing checkspayload.atagainst a key's[notBefore, notAfter]window at verification — weak protection anyway given an attacker-chosenat, reasonable to leave as-is.