Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
20 changes: 20 additions & 0 deletions migrations/0180_decision_ledger.sql
Original file line number Diff line number Diff line change
@@ -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
);
1 change: 1 addition & 0 deletions scripts/check-schema-drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const RAW_SQL_ONLY_TABLES: Set<string> = new Set([
"ams_signals",
"contributor_gate_history",
"decision_audit_labels",
"decision_ledger",
"decision_records",
"global_agent_controls",
"global_contributor_blacklist",
Expand Down
11 changes: 11 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"));
Expand Down
9 changes: 9 additions & 0 deletions src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
80 changes: 80 additions & 0 deletions src/review/decision-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }));
}
Expand Down Expand Up @@ -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<string> {
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<void> {
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 };
}
80 changes: 79 additions & 1 deletion test/unit/decision-record.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
});
});
Loading