diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index cf404a8475..60334b6c96 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -19149,6 +19149,27 @@ } ] } + }, + "/v1/public/decision-ledger/verify": { + "get": { + "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)" + }, + "409": { + "description": "First break found: sequence_gap | predecessor_mismatch | row_hash_mismatch" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/migrations/0180_decision_ledger.sql b/migrations/0180_decision_ledger.sql new file mode 100644 index 0000000000..a2651cd5e9 --- /dev/null +++ b/migrations/0180_decision_ledger.sql @@ -0,0 +1,20 @@ +-- #8837 (epic #8828, Phase 4): hash-chained append-only decision ledger. +-- +-- decision_records (0179) makes each verdict legible; this chain makes the SEQUENCE tamper-evident: nobody +-- can silently delete, reorder, or rewrite history without breaking the chain at a verifiable point. Each +-- row commits to its predecessor: row_hash = SHA-256(prev_hash || canonical-JSON of the semantic fields). +-- Every persistDecisionRecord write appends (including latest-finalize-wins rewrites of the same record id +-- -- supersessions are deliberately VISIBLE history, not silent replacement). +-- +-- HONEST LIMIT (module header repeats this): a self-operated chain is tamper-EVIDENT, not tamper-PROOF -- +-- the operator can still rewrite wholesale. External anchoring (signed checkpoints / witness cosigning) is +-- the tracked follow-up once tenants exist, per the epic's sequencing. That gap does not reduce the value +-- against every OTHER actor, or against accidental corruption. +CREATE TABLE IF NOT EXISTS decision_ledger ( + seq INTEGER PRIMARY KEY, -- explicit, contiguous (verified); NOT autoincrement -- gaps are breaks + record_id TEXT NOT NULL, -- decision_records.id at append time + record_digest TEXT NOT NULL, -- the record's content digest (commits to the full record) + prev_hash TEXT NOT NULL, -- row_hash of seq-1; 64 zeros at genesis + row_hash TEXT NOT NULL, + created_at TEXT NOT NULL +); diff --git a/scripts/check-schema-drift.ts b/scripts/check-schema-drift.ts index b4f08c5b8e..0d48fa058c 100644 --- a/scripts/check-schema-drift.ts +++ b/scripts/check-schema-drift.ts @@ -41,6 +41,7 @@ export const RAW_SQL_ONLY_TABLES: Set = new Set([ "ams_signals", "contributor_gate_history", "decision_audit_labels", + "decision_ledger", "decision_records", "global_agent_controls", "global_contributor_blacklist", diff --git a/src/api/routes.ts b/src/api/routes.ts index 6582f4525d..827ed51fdc 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -304,6 +304,7 @@ import { getContributorTrustProfile } from "../review/contributor-trust-profile" import { backfillContributorGateHistory } from "../review/contributor-gate-history-backfill"; import { isFairnessAnalyticsEnabled, resolveFairnessAnalyticsManifestOverride } from "../review/contributor-trust-profile-wire"; import { isRagEnabled } from "../review/rag-wire"; +import { verifyDecisionLedger } from "../review/decision-record"; import { getPublicStats, isPublicStatsEnabled, resolvePublicStatsManifestOverride } from "../review/public-stats"; import { loadPublicAccuracyTrend } from "../services/public-accuracy-trend"; import { loadPublicRulePrecision } from "../review/public-rule-precision"; @@ -1260,6 +1261,16 @@ export function createApp() { } }); + // #8837: public chain-verification for the decision ledger. Hashes/ids only — no record contents — so it + // is safe unauthenticated; any observer can confirm no decision was deleted, reordered, or rewritten. + // Resumable: pass afterSeq from the previous response's nextAfterSeq until it returns null. + app.get("/v1/public/decision-ledger/verify", async (c) => { + const afterSeq = Math.max(0, Number(c.req.query("afterSeq")) || 0); + const limit = Number(c.req.query("limit")) || 500; + const result = await verifyDecisionLedger(c.env, afterSeq, limit); + return c.json(result, result.ok ? 200 : 409); + }); + app.get("/v1/public/github/repos/:owner/:repo/stats", async (c) => { try { const stats = await fetchPublicRepoStats(c.env, c.req.param("owner"), c.req.param("repo")); diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 9ae61f07b1..5c53eb4c4a 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -996,6 +996,15 @@ export function buildOpenApiSpec() { 401: { description: "Invalid webhook signature" }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/public/decision-ledger/verify", + 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)" }, + 409: { description: "First break found: sequence_gap | predecessor_mismatch | row_hash_mismatch" }, + }, + }); registry.registerPath({ method: "post", path: "/v1/orb/ingest", diff --git a/src/review/decision-record.ts b/src/review/decision-record.ts index 84d8725b9d..7cc6e43b18 100644 --- a/src/review/decision-record.ts +++ b/src/review/decision-record.ts @@ -121,6 +121,9 @@ export async function persistDecisionRecord(env: Env, record: DecisionRecord, re record.decidedAt, ) .run(); + // #8837: every write appends a chain row — including latest-finalize-wins rewrites of the same id, so + // supersessions are visible history rather than silent replacement. + await appendDecisionLedger(env, `record:${record.repoFullName}#${record.pullNumber}@${record.headSha}`.slice(0, 250), recordDigest); } catch (error) { console.warn(JSON.stringify({ event: "decision_record_persist_error", target: `${record.repoFullName}#${record.pullNumber}`, message: errorMessage(error).slice(0, 160) })); } @@ -162,3 +165,80 @@ export async function loadDecisionRecordCollapsible(env: Env, repoFullName: stri return null; } } + +// ── Hash-chained ledger (#8837) ───────────────────────────────────────────────────────────────────────────── + +/** Genesis predecessor: the chain's first row links to 64 zero nibbles. */ +export const LEDGER_GENESIS_HASH = "0".repeat(64); + +/** The semantic fields a ledger row commits to (canonical-JSON'd inside the row hash). */ +export type LedgerRowFields = { seq: number; recordId: string; recordDigest: string; createdAt: string }; + +/** row_hash = SHA-256(prev_hash || canonicalJson(fields)) — the ONE definition append and verify share. */ +export async function ledgerRowHash(prevHash: string, fields: LedgerRowFields): Promise { + return sha256Hex(prevHash + canonicalJson(fields)); +} + +/** + * Append one chain row for a persisted record. seq is explicit (last+1, genesis 1) so a GAP is itself a + * detectable break — never autoincrement, which would silently paper over deletions. A concurrent append + * races on the PRIMARY KEY and retries with a re-read predecessor (bounded); persistDecisionRecord treats a + * final failure as its own best-effort failure (the record row still lands — an unchained record is caught + * by the verify endpoint's record/ledger reconciliation, a follow-up check, rather than by losing the + * decision itself). + */ +export async function appendDecisionLedger(env: Env, recordId: string, recordDigest: string, attempts = 3): Promise { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const tip = await env.DB.prepare("SELECT seq, row_hash AS rowHash FROM decision_ledger ORDER BY seq DESC LIMIT 1").first<{ seq: number; rowHash: string }>(); + const seq = (tip?.seq ?? 0) + 1; + const prevHash = tip?.rowHash ?? LEDGER_GENESIS_HASH; + const createdAt = nowIso(); + const rowHash = await ledgerRowHash(prevHash, { seq, recordId, recordDigest, createdAt }); + try { + await env.DB.prepare( + "INSERT INTO decision_ledger (seq, record_id, record_digest, prev_hash, row_hash, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(seq, recordId, recordDigest, prevHash, rowHash, createdAt) + .run(); + return; + } catch (error) { + if (attempt === attempts) throw error; + // PK collision from a concurrent append — re-read the tip and retry. + } + } +} + +export type LedgerBreak = + | { kind: "sequence_gap"; atSeq: number; expectedSeq: number } + | { kind: "predecessor_mismatch"; atSeq: number } + | { kind: "row_hash_mismatch"; atSeq: number }; + +/** + * Verify a window of the chain, resumable via `afterSeq` (0 = genesis). Reports the FIRST break with its + * class — a gap, a broken predecessor link, or a rewritten row — and the cursor for the next window. Pure + * read; safe on a public route (hashes and ids only, no record contents). + */ +export async function verifyDecisionLedger(env: Env, afterSeq = 0, limit = 500): Promise<{ ok: boolean; checked: number; nextAfterSeq: number | null; break?: LedgerBreak }> { + const bounded = Math.max(1, Math.min(1000, limit)); + const prior = afterSeq > 0 ? await env.DB.prepare("SELECT seq, row_hash AS rowHash FROM decision_ledger WHERE seq = ?").bind(afterSeq).first<{ seq: number; rowHash: string }>() : null; + // `== 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, 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( + "SELECT seq, record_id AS recordId, record_digest AS recordDigest, prev_hash AS prevHash, row_hash AS rowHash, created_at AS createdAt FROM decision_ledger WHERE seq > ? ORDER BY seq ASC LIMIT ?", + ) + .bind(afterSeq, bounded) + .all<{ seq: number; recordId: string; recordDigest: string; prevHash: string; rowHash: string; createdAt: string }>(); + let checked = 0; + for (const row of results) { + if (row.seq !== expectedSeq) return { ok: false, checked, nextAfterSeq: null, break: { kind: "sequence_gap", atSeq: row.seq, expectedSeq } }; + if (row.prevHash !== prevHash) return { ok: false, checked, nextAfterSeq: null, 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, break: { kind: "row_hash_mismatch", atSeq: row.seq } }; + prevHash = row.rowHash; + expectedSeq = row.seq + 1; + checked += 1; + } + return { ok: true, checked, nextAfterSeq: results.length === bounded ? results[results.length - 1]!.seq : null }; +} diff --git a/test/unit/decision-record.test.ts b/test/unit/decision-record.test.ts index f90f0461a6..a8187f353d 100644 --- a/test/unit/decision-record.test.ts +++ b/test/unit/decision-record.test.ts @@ -9,7 +9,7 @@ import { sha256Hex, type DecisionRecord, } from "../../src/review/decision-record"; -import { loadDecisionRecordCollapsible } from "../../src/review/decision-record"; +import { appendDecisionLedger, LEDGER_GENESIS_HASH, loadDecisionRecordCollapsible, verifyDecisionLedger } from "../../src/review/decision-record"; import { createTestEnv } from "../helpers/d1"; // #8836: the digests are commitments a contributor can challenge — key-order invariance and unicode @@ -153,3 +153,81 @@ describe("loadDecisionRecordCollapsible", () => { vi.restoreAllMocks(); }); }); + +describe("decision ledger (#8837)", () => { + const persist = async (env: Env, pull: number, action = "close") => { + const { record, recordDigest } = await buildDecisionRecord(recordInput({ pullNumber: pull, action })); + await persistDecisionRecord(env, record, recordDigest); + return recordDigest; + }; + + it("every persist appends a chained row: explicit contiguous seq, genesis prev, linked hashes", async () => { + const env = createTestEnv(); + await persist(env, 1); + await persist(env, 2); + // A rewrite of the SAME record id appends a THIRD row — supersessions are visible history. + await persist(env, 1, "merge"); + const rows = (await env.DB.prepare("SELECT seq, prev_hash AS prevHash, row_hash AS rowHash FROM decision_ledger ORDER BY seq").all<{ seq: number; prevHash: string; rowHash: string }>()).results!; + expect(rows.map((r) => r.seq)).toEqual([1, 2, 3]); + expect(rows[0]!.prevHash).toBe(LEDGER_GENESIS_HASH); + expect(rows[1]!.prevHash).toBe(rows[0]!.rowHash); + expect(rows[2]!.prevHash).toBe(rows[1]!.rowHash); + const verified = await verifyDecisionLedger(env); + expect(verified).toMatchObject({ ok: true, checked: 3, nextAfterSeq: null }); + }); + + it("verification is RESUMABLE: a full window returns the cursor, the next call continues from it", async () => { + const env = createTestEnv(); + for (let i = 1; i <= 5; i += 1) await persist(env, i); + const first = await verifyDecisionLedger(env, 0, 2); + expect(first).toMatchObject({ ok: true, checked: 2, nextAfterSeq: 2 }); + const second = await verifyDecisionLedger(env, first.nextAfterSeq!, 2); + expect(second).toMatchObject({ ok: true, checked: 2, nextAfterSeq: 4 }); + const last = await verifyDecisionLedger(env, second.nextAfterSeq!, 2); + expect(last).toMatchObject({ ok: true, checked: 1, nextAfterSeq: null }); + // Resuming from a seq that does not exist is itself a reported gap, not a silent clean pass. + expect((await verifyDecisionLedger(env, 99)).break).toMatchObject({ kind: "sequence_gap" }); + }); + + it("reports the FIRST break per corruption class: gap, predecessor, row rewrite", async () => { + const gapEnv = createTestEnv(); + for (let i = 1; i <= 3; i += 1) await persist(gapEnv, i); + await gapEnv.DB.prepare("DELETE FROM decision_ledger WHERE seq = 2").run(); + expect((await verifyDecisionLedger(gapEnv)).break).toMatchObject({ kind: "sequence_gap", atSeq: 3, expectedSeq: 2 }); + + const predEnv = createTestEnv(); + for (let i = 1; i <= 3; i += 1) await persist(predEnv, i); + await predEnv.DB.prepare("UPDATE decision_ledger SET prev_hash = ? WHERE seq = 3").bind("f".repeat(64)).run(); + expect((await verifyDecisionLedger(predEnv)).break).toMatchObject({ kind: "predecessor_mismatch", atSeq: 3 }); + + const rewriteEnv = createTestEnv(); + for (let i = 1; i <= 3; i += 1) await persist(rewriteEnv, i); + await rewriteEnv.DB.prepare("UPDATE decision_ledger SET record_digest = ? WHERE seq = 2").bind("a".repeat(64)).run(); + const broken = await verifyDecisionLedger(rewriteEnv); + expect(broken.break).toMatchObject({ kind: "row_hash_mismatch", atSeq: 2 }); + expect(broken.checked).toBe(1); // seq 1 verified before the break + }); + + it("a concurrent append races on the PK and retries with a re-read predecessor — both rows land, chain intact", async () => { + const env = createTestEnv(); + await Promise.all([appendDecisionLedger(env, "record:a", "1".repeat(64)), appendDecisionLedger(env, "record:b", "2".repeat(64))]); + const verified = await verifyDecisionLedger(env); + expect(verified).toMatchObject({ ok: true, checked: 2 }); + }); + + it("an exhausted retry budget rethrows (persistDecisionRecord's own warn covers it)", async () => { + const env = createTestEnv(); + await appendDecisionLedger(env, "record:a", "1".repeat(64)); + const { vi } = await import("vitest"); + const realPrepare = env.DB.prepare.bind(env.DB); + // Freeze the tip read at a stale value so every retry collides. + vi.spyOn(env.DB, "prepare").mockImplementation((sql: string) => { + if (sql.includes("ORDER BY seq DESC LIMIT 1")) { + return { first: async () => ({ seq: 0, rowHash: LEDGER_GENESIS_HASH }) } as never; + } + return realPrepare(sql); + }); + await expect(appendDecisionLedger(env, "record:c", "3".repeat(64), 2)).rejects.toThrow(); + vi.restoreAllMocks(); + }); +});