verifyDecisionLedger (src/review/decision-record.ts ~196-250, exposed at src/api/routes.ts ~1268) walks decision_ledger only. It re-derives each row_hash from (prev_hash, seq, record_id, record_digest, created_at) — but never reads decision_records, never recomputes contentDigest(JSON.parse(record_json)), and never compares it to the stored record_digest.
Consequences:
- Content tampering is undetectable. An operator or a bug that rewrites
decision_records.record_json while leaving record_digest alone passes verification green. The chain proves a digest existed; nothing proves the stored content still matches it.
- Unchained records leave no gap.
persistDecisionRecord (~110-136) wraps appendDecisionLedger in the same try/catch as the insert and swallows failures with a console.warn. A record whose chain append exhausted its 3 retries lands unchained — and because seq is last+1, there is no gap to detect. The code comment asserts this is "caught by the verify endpoint's record/ledger reconciliation". That reconciliation does not exist anywhere in the codebase.
- Superseded preimages are destroyed.
ON CONFLICT(id) DO UPDATE on decision_records overwrites. The chain commits to a digest whose content is gone, so a superseded merge/close decision cannot be reproduced under challenge.
DecisionRecord (~55-80) carries no author identity, so the ledger cannot answer who a decision was for — relevant when the decision is what causes an upstream payout.
This matters more than usual here: the decision ledger is the non-repudiation story behind a publicly advertised statistical guarantee.
Fix
- Implement the promised reconciliation: join
decision_records, recompute the digest from record_json, and report mismatches and unchained records as distinct break kinds.
- Make a failed ledger append raise a dedicated alarm rather than a swallowed warn.
- Make
decision_records append-only (new row per revision) instead of upserting, so preimages survive.
Refs #9050, #9068.
verifyDecisionLedger(src/review/decision-record.ts~196-250, exposed atsrc/api/routes.ts~1268) walksdecision_ledgeronly. It re-derives eachrow_hashfrom(prev_hash, seq, record_id, record_digest, created_at)— but never readsdecision_records, never recomputescontentDigest(JSON.parse(record_json)), and never compares it to the storedrecord_digest.Consequences:
decision_records.record_jsonwhile leavingrecord_digestalone passes verification green. The chain proves a digest existed; nothing proves the stored content still matches it.persistDecisionRecord(~110-136) wrapsappendDecisionLedgerin the same try/catch as the insert and swallows failures with aconsole.warn. A record whose chain append exhausted its 3 retries lands unchained — and becauseseqislast+1, there is no gap to detect. The code comment asserts this is "caught by the verify endpoint's record/ledger reconciliation". That reconciliation does not exist anywhere in the codebase.ON CONFLICT(id) DO UPDATEondecision_recordsoverwrites. The chain commits to a digest whose content is gone, so a superseded merge/close decision cannot be reproduced under challenge.DecisionRecord(~55-80) carries no author identity, so the ledger cannot answer who a decision was for — relevant when the decision is what causes an upstream payout.This matters more than usual here: the decision ledger is the non-repudiation story behind a publicly advertised statistical guarantee.
Fix
decision_records, recompute the digest fromrecord_json, and report mismatches and unchained records as distinct break kinds.decision_recordsappend-only (new row per revision) instead of upserting, so preimages survive.Refs #9050, #9068.