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
34 changes: 34 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
23 changes: 22 additions & 1 deletion src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
29 changes: 29 additions & 0 deletions src/review/decision-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PublicLedgerRow | null> {
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<PublicLedgerRow>();
// `== null` deliberately, matching verifyDecisionLedger above: D1 drivers disagree on .first() returning
// null vs undefined for no-row.
return row == null ? null : row;
}
1 change: 1 addition & 0 deletions test/integration/public-decision-ledger-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
145 changes: 145 additions & 0 deletions test/integration/public-ledger-row-route.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, unknown>;
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");
});
});
Loading