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
4,575 changes: 2,312 additions & 2,263 deletions apps/loopover-ui/public/openapi.json

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions migrations/0195_decision_ledger_anchors.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- #9271 (epic #9267): persistence for external decision-ledger anchoring attempts, success AND failure both.
--
-- migrations/0180_decision_ledger.sql named its own honest limit up front: a self-operated chain is
-- tamper-EVIDENT, not tamper-PROOF. Anchoring (#9270 for the signed payload; #9272/#9273 for the Rekor and
-- git-commit backends) closes that by publishing a periodic checkpoint somewhere the operator does not
-- control. But per the mechanism research on #9267: if anchoring can fail SILENTLY (best-effort, so a failure
-- must never block a review), an operator could make every attempt "fail" and quietly regress the ledger back
-- to tamper-evident-only with no visible signal. Recording every attempt -- success or failure -- as a public
-- row is what closes that: "anchoring has been failing for a week" becomes a fact anyone can observe, not
-- something only the operator's own logs show.
CREATE TABLE IF NOT EXISTS decision_ledger_anchors (
id TEXT PRIMARY KEY,
seq INTEGER NOT NULL, -- the ledger seq this anchor commits to (decision_ledger.seq)
row_hash TEXT NOT NULL, -- the anchored decision_ledger.row_hash at seq
payload_json TEXT NOT NULL, -- canonical-JSON'd LedgerAnchorPayload (#9270) -- the exact signed bytes
signature TEXT NOT NULL, -- base64 ECDSA signature over payload_json (#9270)
key_id TEXT NOT NULL, -- which published anchor-signing key signed this attempt
-- 'ots' (OpenTimestamps) is a named, tracked-but-not-yet-built backend per #9267's research -- included in
-- the CHECK now so a future implementation issue does not need its own migration just to widen this list.
backend TEXT NOT NULL CHECK (backend IN ('rekor', 'git', 'ots')),
-- Backend-specific resolvable reference, JSON. For Rekor: {shardBaseUrl, logIndex, logIdKeyId, uuid} --
-- deliberately the FULL reference, not a bare logIndex, since Rekor's annual shard rotation makes a bare
-- index unresolvable later. For git: {repo, sha}. NULL on a failed attempt (there is no reference yet).
backend_ref TEXT,
-- R2 key for the full stored proof (Rekor's TransparencyLogEntry -- inclusion proof + signed checkpoint --
-- needed for fully OFFLINE verification later, without trusting Rekor's continued availability). NULL on
-- a failed attempt, and NULL for backends with no separate proof blob to store.
proof_r2_key TEXT,
status TEXT NOT NULL CHECK (status IN ('ok', 'failed')),
error TEXT, -- populated on status='failed', NULL on 'ok'
created_at TEXT NOT NULL
);
-- The public listing (`GET /v1/public/decision-ledger/anchors`) paginates newest-first per backend and
-- overall; this index serves both without a table scan as the table grows.
CREATE INDEX IF NOT EXISTS decision_ledger_anchors_created_at ON decision_ledger_anchors (created_at DESC);
CREATE INDEX IF NOT EXISTS decision_ledger_anchors_backend_created_at ON decision_ledger_anchors (backend, created_at DESC);
1 change: 1 addition & 0 deletions scripts/check-schema-drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const RAW_SQL_ONLY_TABLES: Set<string> = new Set([
"contributor_gate_history",
"decision_audit_labels",
"decision_ledger",
"decision_ledger_anchors",
"decision_records",
"decision_replay_inputs",
"ai_review_verdict_flips",
Expand Down
24 changes: 24 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ import { isRagEnabled } from "../review/rag-wire";
import { loadPublicDecisionRecord, loadPublicLedgerRow, verifyDecisionLedger } from "../review/decision-record";
import { buildEvalScoreRecordsFromRulePrecision, filterEvalScoreRecords } from "../review/eval-score-records";
import { currentAnchorKey, parseAnchorPublicKeys } from "../review/ledger-anchor";
import { loadPublicLedgerAnchors } from "../review/ledger-anchor-persistence";
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 @@ -1319,6 +1320,27 @@ export function createApp() {
return c.json({ keys, currentKeyId: currentAnchorKey(keys)?.keyId ?? null });
});

// #9271 (epic #9267): every anchoring attempt, success AND failure, paginated newest-first. This is what
// makes anchoring's own health a publicly checkable fact -- a failure is recorded and served exactly like a
// success (same shape, same listing), so "anchoring has been failing for a week" is something anyone can
// observe rather than only visible in the operator's own logs. Unauthenticated by design, same posture as
// every /v1/public/* sibling above.
app.get("/v1/public/decision-ledger/anchors", async (c) => {
// Built with spreads, not literal undefined-valued keys: exactOptionalPropertyTypes means an optional
// filter field must be OMITTED to mean "no filter", not present-with-value-undefined.
const backendParam = c.req.query("backend");
const backend = backendParam === "rekor" || backendParam === "git" || backendParam === "ots" ? backendParam : undefined;
const before = c.req.query("before");
const limit = Number(c.req.query("limit")) || undefined;
const result = await loadPublicLedgerAnchors(c.env, {
...(backend !== undefined && { backend }),
...(before !== undefined && { before }),
...(limit !== undefined && { limit }),
});
c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=300");
return c.json(result);
});

// #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 @@ -6809,6 +6831,8 @@ function requiresApiToken(path: string): boolean {
// #9270: the published anchor-signing public keys, added in the SAME PR as its route so the two cannot
// drift the way #9120's sibling did.
if (path === "/v1/public/decision-ledger/anchor-key") return false;
// #9271: the public anchor-attempt listing, added in the SAME PR as its route.
if (path === "/v1/public/decision-ledger/anchors") 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
9 changes: 9 additions & 0 deletions src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,15 @@ export function buildOpenApiSpec() {
200: { description: "{ keys: [{ keyId, publicKeySpki, notBefore, notAfter }], currentKeyId } — retired keys are retained so anchors signed under them stay verifiable; currentKeyId is null when unconfigured or the rotation state is ambiguous" },
},
});
registry.registerPath({
method: "get",
path: "/v1/public/decision-ledger/anchors",
summary: "Every external anchoring attempt, success and failure, paginated newest-first — anchoring's own health as a public fact",
request: { query: z.object({ backend: z.enum(["rekor", "git", "ots"]).optional(), before: z.string().optional(), limit: z.string().optional() }) },
responses: {
200: { description: "{ anchors: [{ id, seq, rowHash, keyId, backend, backendRef, status, error, createdAt }], nextBefore } — a failed attempt is returned identically to a successful one, never filtered out or reshaped" },
},
});
registry.registerPath({
method: "get",
path: "/v1/public/decision-records/{owner}/{repo}/{pull}",
Expand Down
147 changes: 147 additions & 0 deletions src/review/ledger-anchor-persistence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Persistence for external decision-ledger anchoring attempts (#9271, epic #9267; migrations/0195). Every
// backend (#9272 Rekor, #9273 git-commit, and any future one) calls ONE recording function whether it
// succeeded or failed -- there is no separate "failure log" a backend could simply not call. That is the
// whole point: per the mechanism research on #9267, an operator whose anchoring silently fails could quietly
// regress the ledger back to tamper-evident-only with no visible signal. A failure recorded exactly like a
// success, on the same public listing, is what makes anchoring's own health itself a publicly checkable fact.
import { nowIso, errorMessage } from "../utils/json";
import type { LedgerAnchorPayload } from "./ledger-anchor";

export type LedgerAnchorBackend = "rekor" | "git" | "ots";

/** What a backend passes in to record ONE attempt -- success or failure, same shape either way. */
export type LedgerAnchorAttemptInput = {
payload: LedgerAnchorPayload;
signature: string;
keyId: string;
backend: LedgerAnchorBackend;
} & (
| { status: "ok"; backendRef: unknown; proofR2Key: string | null }
| { status: "failed"; error: unknown }
);

/** One row exactly as it will be served publicly -- deliberately the SAME shape whether the attempt
* succeeded or failed, so a listing consumer cannot special-case failures into a different, hideable form. */
export type PublicLedgerAnchor = {
id: string;
seq: number;
rowHash: string;
keyId: string;
backend: LedgerAnchorBackend;
backendRef: unknown;
status: "ok" | "failed";
error: string | null;
createdAt: string;
};

/**
* Record one anchoring attempt. Never throws for the caller's OWN backend failure (that IS what `status:
* "failed"` records) -- only a genuine local persistence error propagates, matching `appendDecisionLedger`'s
* posture of letting a storage-layer failure be its own distinct problem rather than silently swallowing it
* into "the anchor attempt failed" too.
*
* `createdAt` is injectable (defaults to `nowIso()`), matching `loadPublicRulePrecision`'s own
* injectable-clock pattern -- this is NOT `payload.at` (the anchored tip's own captured timestamp, a
* property of the CHAIN) but "when this attempt was recorded" (a property of THIS row), and the two are
* naturally different values. Making it injectable is what lets a test control ordering deterministically
* instead of racing real wall-clock resolution across several inserts in a tight loop.
*/
export async function recordLedgerAnchorAttempt(env: Env, attempt: LedgerAnchorAttemptInput, createdAt: string = nowIso()): Promise<void> {
const id = crypto.randomUUID();
const payloadJson = JSON.stringify(attempt.payload);
const backendRef = attempt.status === "ok" ? JSON.stringify(attempt.backendRef) : null;
const proofR2Key = attempt.status === "ok" ? attempt.proofR2Key : null;
const error = attempt.status === "failed" ? errorMessage(attempt.error).slice(0, 500) : null;

await env.DB.prepare(
`INSERT INTO decision_ledger_anchors
(id, seq, row_hash, payload_json, signature, key_id, backend, backend_ref, proof_r2_key, status, error, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
id,
attempt.payload.seq,
attempt.payload.rowHash,
payloadJson,
attempt.signature,
attempt.keyId,
attempt.backend,
backendRef,
proofR2Key,
attempt.status,
error,
createdAt,
)
.run();
}

export type LedgerAnchorListFilter = {
backend?: LedgerAnchorBackend;
/** Cursor: return rows strictly older than this ISO timestamp. Omit for the first (newest) page. */
before?: string;
limit?: number;
};

const DEFAULT_ANCHOR_LIST_LIMIT = 50;
const MAX_ANCHOR_LIST_LIMIT = 200;

/**
* Public, paginated listing -- newest first, success and failure rows returned identically (no filtering out
* failures, no separate shape). `backendRef`/`error` come back parsed/typed exactly as `PublicLedgerAnchor`
* declares; a row with unparseable `backend_ref` JSON (never written by `recordLedgerAnchorAttempt` itself,
* but defensive against any future direct write) degrades to `null` rather than throwing a public endpoint.
*/
export async function loadPublicLedgerAnchors(env: Env, filter: LedgerAnchorListFilter = {}): Promise<{ anchors: PublicLedgerAnchor[]; nextBefore: string | null }> {
const limit = Math.max(1, Math.min(MAX_ANCHOR_LIST_LIMIT, filter.limit ?? DEFAULT_ANCHOR_LIST_LIMIT));
const conditions: string[] = [];
const binds: unknown[] = [];
if (filter.backend) {
conditions.push("backend = ?");
binds.push(filter.backend);
}
if (filter.before) {
conditions.push("created_at < ?");
binds.push(filter.before);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
// Fetch one extra row to know whether a next page exists, without a separate COUNT query.
const rows = await env.DB.prepare(
`SELECT id, seq, row_hash AS rowHash, key_id AS keyId, backend, backend_ref AS backendRef, status, error, created_at AS createdAt
FROM decision_ledger_anchors ${where}
ORDER BY created_at DESC, id DESC
LIMIT ?`,
)
.bind(...binds, limit + 1)
.all<{ id: string; seq: number; rowHash: string; keyId: string; backend: LedgerAnchorBackend; backendRef: string | null; status: "ok" | "failed"; error: string | null; createdAt: string }>();

/* v8 ignore next -- defensive: D1's .all() always returns a results array (even [] for zero rows); the ??
* guards a driver-shape change, mirroring loadPublicRulePrecision's identical note on COUNT(*). */
const results = rows.results ?? [];
const page = results.slice(0, limit);
// `> limit` guarantees `page` has exactly `limit` (>=1) elements, so `page[page.length - 1]` is always
/* v8 ignore next -- defined; the ?. only guards TypeScript's array-index type, not a reachable runtime case. */
const nextBefore = results.length > limit ? (page[page.length - 1]?.createdAt ?? null) : null;

const anchors: PublicLedgerAnchor[] = page.map((row) => ({
id: row.id,
seq: row.seq,
rowHash: row.rowHash,
keyId: row.keyId,
backend: row.backend,
backendRef: parseBackendRef(row.backendRef),
status: row.status,
error: row.error,
createdAt: row.createdAt,
}));

return { anchors, nextBefore };
}

function parseBackendRef(raw: string | null): unknown {
if (raw === null) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
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 @@ -77,6 +77,7 @@ describe("public decision-ledger/decision-records routes answer WITHOUT credenti
["decision-ledger/verify", "/v1/public/decision-ledger/verify"],
["decision-ledger/row/:seq", "/v1/public/decision-ledger/row/1"],
["decision-ledger/anchor-key", "/v1/public/decision-ledger/anchor-key"],
["decision-ledger/anchors", "/v1/public/decision-ledger/anchors"],
["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
93 changes: 93 additions & 0 deletions test/integration/public-ledger-anchors-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { createApp } from "../../src/api/routes";
import { createTestEnv } from "../helpers/d1";
import { recordLedgerAnchorAttempt } from "../../src/review/ledger-anchor-persistence";
import { buildLedgerAnchorPayload } from "../../src/review/ledger-anchor";

// #9271 (epic #9267). The load-bearing behaviour: a failed attempt is served on this public listing exactly
// like a success, since that's the entire point of recording failures at all.

describe("GET /v1/public/decision-ledger/anchors (#9271)", () => {
it("answers 200 with no Authorization header", async () => {
const env = createTestEnv();
const response = await createApp().request("/v1/public/decision-ledger/anchors", {}, env);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ anchors: [], nextBefore: null });
});

it("serves a FAILED anchor attempt on the public listing, identically shaped to a success", async () => {
const env = createTestEnv();
await recordLedgerAnchorAttempt(
env,
{
payload: buildLedgerAnchorPayload({ seq: 7, rowHash: "e".repeat(64), totalCount: 7 }, "2026-07-27T12:00:00.000Z"),
signature: "c2ln",
keyId: "key1",
backend: "git",
status: "failed",
error: new Error("rate limited by the git remote"),
},
"2026-07-27T12:00:00.000Z",
);

const response = await createApp().request("/v1/public/decision-ledger/anchors", {}, env);
const body = (await response.json()) as { anchors: Array<Record<string, unknown>>; nextBefore: string | null };
expect(body.anchors).toHaveLength(1);
expect(body.anchors[0]).toMatchObject({ seq: 7, backend: "git", status: "failed", error: "rate limited by the git remote" });
});

it("filters by ?backend=", async () => {
const env = createTestEnv();
await recordLedgerAnchorAttempt(
env,
{ payload: buildLedgerAnchorPayload({ seq: 1, rowHash: "a".repeat(64), totalCount: 1 }, "2026-07-27T12:00:00.000Z"), signature: "s", keyId: "k", backend: "rekor", status: "ok", backendRef: {}, proofR2Key: null },
"2026-07-27T12:00:00.000Z",
);
await recordLedgerAnchorAttempt(
env,
{ payload: buildLedgerAnchorPayload({ seq: 2, rowHash: "b".repeat(64), totalCount: 2 }, "2026-07-27T12:01:00.000Z"), signature: "s", keyId: "k", backend: "git", status: "ok", backendRef: {}, proofR2Key: null },
"2026-07-27T12:01:00.000Z",
);

const response = await createApp().request("/v1/public/decision-ledger/anchors?backend=rekor", {}, env);
const body = (await response.json()) as { anchors: Array<{ backend: string }> };
expect(body.anchors.map((a) => a.backend)).toEqual(["rekor"]);
});

it("ignores an invalid ?backend= value rather than erroring (falls back to unfiltered)", async () => {
const env = createTestEnv();
await recordLedgerAnchorAttempt(
env,
{ payload: buildLedgerAnchorPayload({ seq: 1, rowHash: "a".repeat(64), totalCount: 1 }, "2026-07-27T12:00:00.000Z"), signature: "s", keyId: "k", backend: "rekor", status: "ok", backendRef: {}, proofR2Key: null },
"2026-07-27T12:00:00.000Z",
);
const response = await createApp().request("/v1/public/decision-ledger/anchors?backend=nonsense", {}, env);
expect(response.status).toBe(200);
const body = (await response.json()) as { anchors: unknown[] };
expect(body.anchors).toHaveLength(1);
});

it("supports pagination via ?limit= and ?before=", async () => {
const env = createTestEnv();
for (let i = 1; i <= 3; i += 1) {
await recordLedgerAnchorAttempt(
env,
{ payload: buildLedgerAnchorPayload({ seq: i, rowHash: `${i}`.repeat(64).slice(0, 64), totalCount: i }, `2026-07-27T12:0${i}:00.000Z`), signature: "s", keyId: "k", backend: "rekor", status: "ok", backendRef: {}, proofR2Key: null },
`2026-07-27T12:0${i}:00.000Z`,
);
}
const first = await createApp().request("/v1/public/decision-ledger/anchors?limit=2", {}, env);
const firstBody = (await first.json()) as { anchors: Array<{ seq: number }>; nextBefore: string };
expect(firstBody.anchors.map((a) => a.seq)).toEqual([3, 2]);

const second = await createApp().request(`/v1/public/decision-ledger/anchors?limit=2&before=${encodeURIComponent(firstBody.nextBefore)}`, {}, env);
const secondBody = (await second.json()) as { anchors: Array<{ seq: number }>; nextBefore: string | null };
expect(secondBody.anchors.map((a) => a.seq)).toEqual([1]);
expect(secondBody.nextBefore).toBeNull();
});

it("sets the same Cache-Control posture as its public siblings", async () => {
const response = await createApp().request("/v1/public/decision-ledger/anchors", {}, createTestEnv());
expect(response.headers.get("Cache-Control")).toBe("public, max-age=60, stale-while-revalidate=300");
});
});
Loading
Loading