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,579 changes: 2,323 additions & 2,256 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
43 changes: 43 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ import { isFairnessAnalyticsEnabled, resolveFairnessAnalyticsManifestOverride }
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 @@ -1303,6 +1305,42 @@ export function createApp() {
return c.json(row);
});

// #9270 (epic #9267): the anchor-signing public keys, with their rotation history. A verifier holding an
// anchor published elsewhere (transparency log, public git repo) fetches this to check that anchor's
// signature. The FULL history is served, not just the current key, because an anchor signed in 2026 must
// stay verifiable after a 2027 rotation -- retired keys are published forever rather than replaced.
// Unauthenticated by design, like every /v1/public/* sibling: a public key is public, and a verifier who had
// to authenticate to US in order to check OUR anchors would not be independently verifying anything.
// Unconfigured yields an empty list + null currentKeyId rather than an error -- "nothing is claimed to be
// verifiable yet" is the honest answer before the signing key is provisioned, and a verifier can tell that
// apart from a key that exists.
app.get("/v1/public/decision-ledger/anchor-key", (c) => {
const keys = parseAnchorPublicKeys(c.env.LOOPOVER_LEDGER_ANCHOR_KEYS);
c.header("Cache-Control", "public, max-age=300, stale-while-revalidate=3600");
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 @@ -6790,6 +6828,11 @@ function requiresApiToken(path: string): boolean {
// #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;
// #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
19 changes: 19 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,25 @@ declare global {
* not depend on this var at all. Default "" (unset) → the own-ledger side reports zero (fails safe, same
* privacy stance as before) but the Orb aggregate still reports normally. See review/public-stats.ts. */
LOOPOVER_PUBLIC_STATS_REPOS?: string;
/** External ledger anchoring (#9270, epic #9267): the PUBLIC half of the anchor-signing keypair(s), as a
* JSON array of `{ keyId, publicKeySpki, notBefore, notAfter }` — served verbatim by the unauthenticated
* `GET /v1/public/decision-ledger/anchor-key` so a third party can verify an anchor published to a
* transparency log or public git repo. The full ROTATION HISTORY belongs here, not just the current key:
* an anchor signed under a retired key must stay verifiable forever, so retired entries get a non-null
* `notAfter` and are never removed. Exactly one entry may have `notAfter: null` (the key currently
* signing); zero or several is an ambiguous rotation state and `currentAnchorKey` fails closed rather
* than guessing. Public key material only — the private half is LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY and
* must never appear here. Default unset → an empty published list, meaning "no anchors are claimed to be
* verifiable yet", which a verifier can distinguish from a key that exists. See review/ledger-anchor.ts. */
LOOPOVER_LEDGER_ANCHOR_KEYS?: string;
/** External ledger anchoring (#9270, epic #9267): the PRIVATE half of the anchor-signing keypair — an
* ECDSA P-256 key in PKCS8 PEM (the `\n`-escaped single-line form is accepted, since that is how a key
* survives a secret store). A real SECRET: set via `wrangler secret put`, never committed, never served
* by any route, and never logged. Only the scheduled anchoring job reads it (#9274). Unset simply means
* anchoring does not run — the ledger stays tamper-evident rather than tamper-proof, which is the exact
* pre-#9267 posture, so an unprovisioned key degrades honestly instead of failing. See
* review/ledger-anchor.ts. */
LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY?: string;
/** Convergence (port): public OAuth draft-submission flow ported from reviewbot. When truthy, the
* /v1/drafts endpoints accept a contributor draft -> GitHub OAuth -> fork PR against the content repo.
* Default OFF — unset/false makes every draft endpoint 404 and writes nothing (byte-identical worker). */
Expand Down
17 changes: 17 additions & 0 deletions src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,23 @@ export function buildOpenApiSpec() {
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-ledger/anchor-key",
summary: "Published anchor-signing public keys with their full rotation history, for verifying an externally-published ledger anchor",
responses: {
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;
}
}
Loading
Loading