From d9839be7f560dd2c3f0c7a8dc35f5ec5324b3bab Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:34:59 -0700 Subject: [PATCH] =?UTF-8?q?feat(ledger):=20GET=20/v1/public/decision-ledge?= =?UTF-8?q?r/row/:seq=20=E2=80=94=20bind=20an=20anchor=20to=20the=20live?= =?UTF-8?q?=20chain=20(#9269)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First and foundational sub-issue of #9267 (external ledger anchoring). Everything else in that epic depends on this existing. The gap: an anchor published to a transparency log or public git repo commits to a (seq, rowHash) pair. On its own that proves only that SOME hash existed somewhere at some time — not that it is still THIS chain's hash at that seq. Nothing today can fetch a single row and recompute its chained hash: verify walks internal self-consistency and returns only the tip plus the first break. With this route a third party fetches the live row and recomputes sha256(prevHash || canonicalJson({seq, recordId, recordDigest, createdAt})) against what was anchored. An operator who deleted the ledger and re-chained from genesis produces a different rowHash at every anchored seq, so every published anchor then fails that comparison independently and publicly — which is exactly the wholesale-rewrite gap migrations/0180_decision_ledger.sql names as its own honest limit. Public-safe by the same argument the verify route already makes: hashes, a seq, a timestamp, and the already-public record id. Never record contents (pinned by test). 404 rather than 200-with-nulls for an unappended seq, so "never appended" stays distinguishable from "appended with empty fields". Auth exemption added in this same commit as the route, so it cannot repeat #9120's drift where a doc comment claimed unauthenticated but the exemption list disagreed. --- apps/loopover-ui/public/openapi.json | 34 ++++ src/api/routes.ts | 23 ++- src/openapi/spec.ts | 11 ++ src/review/decision-record.ts | 29 ++++ .../public-decision-ledger-routes.test.ts | 1 + .../public-ledger-row-route.test.ts | 145 ++++++++++++++++++ 6 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 test/integration/public-ledger-row-route.test.ts diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index bf235246c0..eb25b146c2 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -19336,6 +19336,40 @@ } ] } + }, + "/v1/public/decision-ledger/row/{seq}": { + "get": { + "summary": "Fetch one decision-ledger row by seq, so an external anchor can be bound back to the live chain", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "seq", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The chain row: seq, recordId, recordDigest, prevHash, rowHash, createdAt. Recompute sha256(prevHash || canonicalJson({seq, recordId, recordDigest, createdAt})) and compare against an anchored rowHash." + }, + "400": { + "description": "seq is not a positive integer" + }, + "404": { + "description": "No ledger row at that seq (never appended -- distinct from a row with empty fields)" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/api/routes.ts b/src/api/routes.ts index 177d1d0f2a..08b8c2b9ef 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -310,7 +310,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 { loadPublicDecisionRecord, verifyDecisionLedger } from "../review/decision-record"; +import { loadPublicDecisionRecord, loadPublicLedgerRow, verifyDecisionLedger } from "../review/decision-record"; import { buildEvalScoreRecordsFromRulePrecision, filterEvalScoreRecords } from "../review/eval-score-records"; import { getPublicStats, isPublicStatsEnabled, resolvePublicStatsManifestOverride } from "../review/public-stats"; import { loadPublicAccuracyTrend } from "../services/public-accuracy-trend"; @@ -1285,6 +1285,24 @@ export function createApp() { return c.json(result, result.ok ? 200 : 409); }); + // #9269 (epic #9267): fetch ONE chain row by seq. The verify route above walks the chain's internal + // self-consistency; this one is what lets an EXTERNAL anchor be checked against the live chain. An anchor + // published to a transparency log or a public git repo commits to a (seq, rowHash) pair -- without this + // route that only proves some hash existed somewhere, not that it is still this chain's hash at that seq. + // A verifier fetches the row, recomputes sha256(prevHash || canonicalJson({seq, recordId, recordDigest, + // createdAt})), and compares against what was anchored. Unauthenticated, same posture and same public-safety + // argument as its two siblings above (hashes, a seq, a timestamp, and the already-public record id -- + // never record contents); excluded from requiresApiToken below in this same PR, so it cannot repeat #9120's + // "doc comment claimed unauthenticated but the exemption list disagreed" drift. + app.get("/v1/public/decision-ledger/row/:seq", async (c) => { + const seq = Number(c.req.param("seq")); + if (!Number.isInteger(seq) || seq <= 0) return c.json({ error: "invalid_seq" }, 400); + const row = await loadPublicLedgerRow(c.env, seq); + if (!row) return c.json({ error: "not_found" }, 404); + c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=300"); + return c.json(row); + }); + // #9123: the decision record itself was persisted (decision_records) but never published anywhere a // contributor or a third party could fetch the full body — the only prior public surface was // renderDecisionRecordSection's bounded review-comment summary (12-char digest prefixes, and it omits @@ -6769,6 +6787,9 @@ function requiresApiToken(path: string): boolean { // #9266: the eval-scores transport is unauthenticated by the same design as every /v1/public/* sibling // above -- committed to a corpus checksum, independently re-derivable, nothing gated behind a token. if (path === "/v1/public/eval-scores") return false; + // #9269: the single-row read, added in the SAME PR as its route so the two can never drift the way #9120's + // sibling did. Regex (not a literal) because of the :seq path parameter. + if (/^\/v1\/public\/decision-ledger\/row\/[^/]+$/.test(path)) return false; // #9123: the new public decision-record read route — same unauthenticated posture as its ledger-verify // sibling immediately above, added in the SAME PR so the two can never drift apart the way #9120 did. The // pull segment matches any non-slash text (not just digits): an invalid pull number is the ROUTE's 400 to diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index b5acd25bcb..b8bfea05be 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -1005,6 +1005,17 @@ export function buildOpenApiSpec() { 409: { description: "First break found: sequence_gap | predecessor_mismatch | row_hash_mismatch | short_tail (a record exists past the verified tip with no chain entry)" }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/public/decision-ledger/row/{seq}", + summary: "Fetch one decision-ledger row by seq, so an external anchor can be bound back to the live chain", + request: { params: z.object({ seq: z.string() }) }, + responses: { + 200: { description: "The chain row: seq, recordId, recordDigest, prevHash, rowHash, createdAt. Recompute sha256(prevHash || canonicalJson({seq, recordId, recordDigest, createdAt})) and compare against an anchored rowHash." }, + 400: { description: "seq is not a positive integer" }, + 404: { description: "No ledger row at that seq (never appended -- distinct from a row with empty fields)" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/public/decision-records/{owner}/{repo}/{pull}", diff --git a/src/review/decision-record.ts b/src/review/decision-record.ts index da4ccbbd27..42af0310d7 100644 --- a/src/review/decision-record.ts +++ b/src/review/decision-record.ts @@ -485,3 +485,32 @@ export async function verifyDecisionLedger( } return { ok: true, checked, nextAfterSeq, tipSeq, tipHash, totalCount }; } + +/** One ledger row, exactly as chained -- the shape `GET /v1/public/decision-ledger/row/:seq` returns. */ +export type PublicLedgerRow = { seq: number; recordId: string; recordDigest: string; prevHash: string; rowHash: string; createdAt: string }; + +/** + * Load a single ledger row by seq (#9269). This is what BINDS an external anchor back to the live chain: an + * anchor published elsewhere commits to a `(seq, rowHash)` pair, but on its own that only proves some hash + * existed somewhere at some time -- not that it is still THIS chain's hash at that seq. With this route, a + * third party fetches the live row, recomputes `sha256(prevHash || canonicalJson({seq, recordId, + * recordDigest, createdAt}))` via {@link ledgerRowHash}, and compares. An operator who deleted the chain and + * re-chained from genesis produces a DIFFERENT rowHash at every anchored seq, so every published anchor then + * fails that comparison independently and publicly -- which is precisely the "wholesale re-chaining" gap + * `migrations/0180_decision_ledger.sql` names as its own honest limit. + * + * Public-safe by the same argument the verify route already makes: every field here is a hash, a sequence + * number, a timestamp, or the record id -- all of which that route (and the published decision record) expose + * already. Never returns record CONTENTS. `null` for an unknown seq so the caller can 404 rather than answer + * 200 with nulls, keeping "never appended" distinguishable from "appended with empty fields". + */ +export async function loadPublicLedgerRow(env: Env, seq: number): Promise { + const row = 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 = ?", + ) + .bind(seq) + .first(); + // `== null` deliberately, matching verifyDecisionLedger above: D1 drivers disagree on .first() returning + // null vs undefined for no-row. + return row == null ? null : row; +} diff --git a/test/integration/public-decision-ledger-routes.test.ts b/test/integration/public-decision-ledger-routes.test.ts index cfb59068cf..c70cc17b27 100644 --- a/test/integration/public-decision-ledger-routes.test.ts +++ b/test/integration/public-decision-ledger-routes.test.ts @@ -75,6 +75,7 @@ describe("public decision-ledger/decision-records routes answer WITHOUT credenti ["subnet-interface", "/v1/public/subnet-interface"], ["stats", "/v1/public/stats"], ["decision-ledger/verify", "/v1/public/decision-ledger/verify"], + ["decision-ledger/row/:seq", "/v1/public/decision-ledger/row/1"], ["decision-records/:owner/:repo/:pull", "/v1/public/decision-records/acme/widgets/1"], ["github/repos/:owner/:repo/stats", "/v1/public/github/repos/acme/widgets/stats"], ["repos/:owner/:repo/badge.svg", "/v1/public/repos/acme/widgets/badge.svg"], diff --git a/test/integration/public-ledger-row-route.test.ts b/test/integration/public-ledger-row-route.test.ts new file mode 100644 index 0000000000..5ba2cdcc2a --- /dev/null +++ b/test/integration/public-ledger-row-route.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { createTestEnv } from "../helpers/d1"; +import { + buildDecisionRecord, + contentDigest, + LEDGER_GENESIS_HASH, + ledgerRowHash, + persistDecisionRecord, + type PublicLedgerRow, +} from "../../src/review/decision-record"; + +// #9269 (epic #9267): the row-lookup route is what BINDS an external anchor back to the live chain. These +// tests pin the property that actually matters -- a fetched row must be enough, on its own, to recompute the +// chained hash an anchor committed to -- not merely that the endpoint returns 200. + +async function seedRecords(env: Env, count: number): Promise { + for (let i = 1; i <= count; i += 1) { + const { record, recordDigest } = await buildDecisionRecord({ + repoFullName: "acme/widgets", + pullNumber: i, + headSha: `abc${i}`, + baseSha: null, + action: "merge", + reasonCode: "gate_clean", + configDigest: await contentDigest({ gatePack: "oss-anti-slop" }), + gatePack: "oss-anti-slop", + ciState: null, + modelIds: null, + promptDigest: null, + aiConfidence: null, + salvageability: null, + }); + await persistDecisionRecord(env, record, recordDigest); + } +} + +describe("GET /v1/public/decision-ledger/row/:seq (#9269)", () => { + it("answers 200 with no Authorization header and returns the row's chain fields verbatim", async () => { + const env = createTestEnv(); + await seedRecords(env, 1); + + const response = await createApp().request("/v1/public/decision-ledger/row/1", {}, env); + expect(response.status).toBe(200); + const row = (await response.json()) as PublicLedgerRow; + expect(row.seq).toBe(1); + expect(row.prevHash).toBe(LEDGER_GENESIS_HASH); // first row links to genesis + expect(row.recordId).toContain("acme/widgets#1"); + expect(row.recordDigest).toMatch(/^[0-9a-f]{64}$/); + expect(row.rowHash).toMatch(/^[0-9a-f]{64}$/); + expect(typeof row.createdAt).toBe("string"); + }); + + it("THE POINT (#9267): a fetched row alone lets a third party recompute the chained hash an anchor committed to", async () => { + const env = createTestEnv(); + await seedRecords(env, 3); + + const response = await createApp().request("/v1/public/decision-ledger/row/2", {}, env); + const row = (await response.json()) as PublicLedgerRow; + + // Exactly what an external verifier runs against an anchored (seq, rowHash) pair -- using only fields + // this route returned, with no privileged access to anything. + const recomputed = await ledgerRowHash(row.prevHash, { + seq: row.seq, + recordId: row.recordId, + recordDigest: row.recordDigest, + createdAt: row.createdAt, + }); + expect(recomputed).toBe(row.rowHash); + }); + + it("row N's prevHash equals row N-1's rowHash, so anchors at different seqs chain together", async () => { + const env = createTestEnv(); + await seedRecords(env, 3); + + const app = createApp(); + const [first, second] = (await Promise.all( + [1, 2].map(async (seq) => (await app.request(`/v1/public/decision-ledger/row/${seq}`, {}, env)).json()), + )) as PublicLedgerRow[]; + expect(second?.prevHash).toBe(first?.rowHash); + }); + + it("a re-chained ledger produces a DIFFERENT rowHash at an anchored seq -- the wholesale-rewrite detection this route exists for", async () => { + const original = createTestEnv(); + await seedRecords(original, 2); + const anchored = (await (await createApp().request("/v1/public/decision-ledger/row/2", {}, original)).json()) as PublicLedgerRow; + + // A fresh chain over DIFFERENT history — an operator deleting the ledger and starting again from genesis. + const rechained = createTestEnv(); + await seedRecords(rechained, 1); + const { record, recordDigest } = await buildDecisionRecord({ + repoFullName: "acme/widgets", + pullNumber: 999, // the record at seq 2 is not the one that was anchored + headSha: "rewritten", + baseSha: null, + action: "close", + reasonCode: "policy_close:contributor_cap", + configDigest: await contentDigest({ gatePack: "oss-anti-slop" }), + gatePack: "oss-anti-slop", + ciState: null, + modelIds: null, + promptDigest: null, + aiConfidence: null, + salvageability: null, + }); + await persistDecisionRecord(rechained, record, recordDigest); + + const live = (await (await createApp().request("/v1/public/decision-ledger/row/2", {}, rechained)).json()) as PublicLedgerRow; + expect(live.seq).toBe(anchored.seq); // same seq... + expect(live.rowHash).not.toBe(anchored.rowHash); // ...different hash — the anchor comparison fails, publicly + }); + + it("answers 404 (not 401, not 200-with-nulls) for a seq that was never appended", async () => { + const env = createTestEnv(); + await seedRecords(env, 1); + const response = await createApp().request("/v1/public/decision-ledger/row/99", {}, env); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "not_found" }); + }); + + it("answers 400 for a non-integer, zero, or negative seq", async () => { + const env = createTestEnv(); + const app = createApp(); + for (const seq of ["not-a-number", "0", "-1", "1.5"]) { + const response = await app.request(`/v1/public/decision-ledger/row/${seq}`, {}, env); + expect(response.status, `seq=${seq}`).toBe(400); + } + }); + + it("never returns decision-record CONTENTS, only chain fields", async () => { + const env = createTestEnv(); + await seedRecords(env, 1); + const response = await createApp().request("/v1/public/decision-ledger/row/1", {}, env); + const body = (await response.json()) as Record; + expect(Object.keys(body).sort()).toEqual(["createdAt", "prevHash", "recordDigest", "recordId", "rowHash", "seq"]); + expect(JSON.stringify(body)).not.toMatch(/record_json|reasonCode|configDigest|promptDigest/); + }); + + it("sets the same Cache-Control posture as its public siblings", async () => { + const env = createTestEnv(); + await seedRecords(env, 1); + const response = await createApp().request("/v1/public/decision-ledger/row/1", {}, env); + expect(response.headers.get("Cache-Control")).toBe("public, max-age=60, stale-while-revalidate=300"); + }); +});