Skip to content

Commit e95afc0

Browse files
authored
feat(api): say why the public anchor list is empty instead of implying health (#9721)
`GET /v1/public/decision-ledger/anchors` returned `{"anchors":[]}` for three completely different situations — anchoring was never configured, there is no ledger to anchor, or it is configured and simply has not run yet. From outside they were indistinguishable, and an empty list reads as a healthy one, so a silently misconfigured deployment looked exactly like a working one. That is the gap ledger-anchor-persistence.ts's own header set out to close ("an operator whose anchoring silently fails could quietly regress the ledger back to tamper-evident-only with no visible signal"): the guarantee only held AFTER the scheduler's two guards passed, and both of those return without writing anything a reader can see. The response now carries `status`: anchored | empty_ledger | unconfigured | pending. The predicate is pure and its guard order deliberately mirrors runScheduledLedgerAnchor's own — tip first, then signing key — so the published status always names the same reason the scheduler would act on rather than offering a second opinion. Only computed for an unfiltered first page: with a backend/before filter an empty page means "none matched", which is a different question from "is anchoring running at all", so `status` is omitted there rather than answering the wrong one. Refs #9719
1 parent d79470f commit e95afc0

5 files changed

Lines changed: 95 additions & 5 deletions

File tree

apps/loopover-ui/public/openapi.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20802,7 +20802,7 @@
2080220802
],
2080320803
"responses": {
2080420804
"200": {
20805-
"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"
20805+
"description": "{ anchors: [{ id, seq, rowHash, keyId, backend, backendRef, status, error, createdAt }], nextBefore, status } — a failed attempt is returned identically to a successful one, never filtered out or reshaped. The top-level `status` (anchored | empty_ledger | unconfigured | pending) says why the list looks as it does, so an empty list cannot be mistaken for a healthy one; it is omitted when a backend/before filter is applied, where empty just means none matched"
2080620806
}
2080720807
},
2080820808
"operationId": "listPublicDecisionLedgerAnchors",

src/api/routes.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ import { isFairnessAnalyticsEnabled, resolveFairnessAnalyticsManifestOverride }
305305
import { isRagEnabled } from "../review/rag-wire";
306306
import { loadDecisionLedgerTip, loadPublicDecisionRecord, loadPublicLedgerRow, verifyDecisionLedger } from "../review/decision-record";
307307
import { buildEvalScoreRecordsFromRulePrecision, filterEvalScoreRecords } from "../review/eval-score-records";
308-
import { anchorSigningInput, buildLedgerAnchorPayload, currentAnchorKey, parseAnchorPublicKeys, signLedgerAnchorPayload } from "../review/ledger-anchor";
308+
import { anchorSigningInput, buildLedgerAnchorPayload, currentAnchorKey, parseAnchorPublicKeys, publicAnchorStatus, signLedgerAnchorPayload } from "../review/ledger-anchor";
309309
import { ingestBittensorAnchorReport, parseBittensorAnchorReport } from "../review/ledger-anchor-bittensor";
310310
import { loadPublicLedgerAnchors } from "../review/ledger-anchor-persistence";
311311
import { getPublicStats, isPublicStatsEnabled, resolvePublicStatsManifestOverride } from "../review/public-stats";
@@ -1346,8 +1346,24 @@ export function createApp() {
13461346
...(before !== undefined && { before }),
13471347
...(limit !== undefined && { limit }),
13481348
});
1349+
// An empty list is ambiguous on its own -- say WHY, so "not configured" can never be mistaken for
1350+
// "healthy, nothing to report". Only computed for an unfiltered first page: with a backend/before filter an
1351+
// empty page means "none matched", which is a different question than "is anchoring running at all".
1352+
const unfiltered = backend === undefined && before === undefined;
1353+
const [tip, keys] = unfiltered
1354+
? await Promise.all([loadDecisionLedgerTip(c.env), Promise.resolve(parseAnchorPublicKeys(c.env.LOOPOVER_LEDGER_ANCHOR_KEYS))])
1355+
: [null, []];
13491356
c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=300");
1350-
return c.json(result);
1357+
return c.json({
1358+
...result,
1359+
...(tip !== null && {
1360+
status: publicAnchorStatus({
1361+
anchorCount: result.anchors.length,
1362+
tipSeq: tip.seq,
1363+
hasSigningKey: currentAnchorKey(keys) !== null && Boolean(c.env.LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY),
1364+
}),
1365+
}),
1366+
});
13511367
});
13521368

13531369
// #9277 (epic #9267): the current tip's SIGNED checkpoint, for the operator's off-Worker Bittensor

src/openapi/spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1906,7 +1906,7 @@ export function buildOpenApiSpec() {
19061906
summary: "Every external anchoring attempt, success and failure, paginated newest-first — anchoring's own health as a public fact",
19071907
request: { query: z.object({ backend: z.enum(["rekor", "git", "ots", "bittensor"]).optional(), before: z.string().optional(), limit: z.string().optional() }) },
19081908
responses: {
1909-
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" },
1909+
200: { description: "{ anchors: [{ id, seq, rowHash, keyId, backend, backendRef, status, error, createdAt }], nextBefore, status } — a failed attempt is returned identically to a successful one, never filtered out or reshaped. The top-level `status` (anchored | empty_ledger | unconfigured | pending) says why the list looks as it does, so an empty list cannot be mistaken for a healthy one; it is omitted when a backend/before filter is applied, where empty just means none matched" },
19101910
},
19111911
});
19121912
registry.registerPath({

src/review/ledger-anchor.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,23 @@ export function anchorKeyById(keys: readonly AnchorPublicKey[], keyId: string):
210210
return keys.find((key) => key.keyId === keyId) ?? null;
211211
}
212212

213+
/** Why the public anchor list looks the way it does. Without this, "never configured", "no ledger to anchor",
214+
* and "anchoring is healthy but has not run yet" are all indistinguishable from outside — every one of them
215+
* renders as `{"anchors":[]}`, which reads as a healthy empty state. The module header of
216+
* ledger-anchor-persistence.ts states the goal ("an operator whose anchoring silently fails could quietly
217+
* regress the ledger back to tamper-evident-only with no visible signal"); that guarantee only held AFTER
218+
* both of the scheduler's guards passed, and this closes the gap before them. */
219+
export type PublicAnchorStatus = "anchored" | "empty_ledger" | "unconfigured" | "pending";
220+
221+
/** PURE. Guard order deliberately mirrors runScheduledLedgerAnchor's own (tip first, then signing key), so the
222+
* status a reader sees always names the same reason the scheduler would act on, never a second opinion. */
223+
export function publicAnchorStatus(input: { anchorCount: number; tipSeq: number; hasSigningKey: boolean }): PublicAnchorStatus {
224+
if (input.anchorCount > 0) return "anchored";
225+
if (input.tipSeq === 0) return "empty_ledger";
226+
if (!input.hasSigningKey) return "unconfigured";
227+
return "pending";
228+
}
229+
213230
/** Digest helpers re-exported so an anchor consumer (e.g. the git-commit backend, #9273, which commits the
214231
* same canonicalized payload Rekor anchors) never needs a second import from decision-record.ts just to
215232
* canonicalize or hash something alongside a signed anchor. */

test/integration/public-ledger-anchors-route.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createApp } from "../../src/api/routes";
33
import { createTestEnv } from "../helpers/d1";
44
import { recordLedgerAnchorAttempt } from "../../src/review/ledger-anchor-persistence";
55
import { buildLedgerAnchorPayload } from "../../src/review/ledger-anchor";
6+
import { buildDecisionRecord, contentDigest, persistDecisionRecord } from "../../src/review/decision-record";
67

78
// #9271 (epic #9267). The load-bearing behaviour: a failed attempt is served on this public listing exactly
89
// like a success, since that's the entire point of recording failures at all.
@@ -12,7 +13,42 @@ describe("GET /v1/public/decision-ledger/anchors (#9271)", () => {
1213
const env = createTestEnv();
1314
const response = await createApp().request("/v1/public/decision-ledger/anchors", {}, env);
1415
expect(response.status).toBe(200);
15-
expect(await response.json()).toEqual({ anchors: [], nextBefore: null });
16+
// #9719: an empty list now says WHY -- a fresh env has no ledger rows, so there is nothing to anchor yet.
17+
expect(await response.json()).toEqual({ anchors: [], nextBefore: null, status: "empty_ledger" });
18+
});
19+
20+
it("REGRESSION: distinguishes an unconfigured deployment from a healthy empty one", async () => {
21+
// Before #9719 all of "never configured", "nothing to anchor" and "healthy but not run yet" rendered as
22+
// {"anchors":[]} -- indistinguishable from outside, so silent misconfiguration looked like success.
23+
const env = createTestEnv();
24+
await seedLedgerRow(env);
25+
const response = await createApp().request("/v1/public/decision-ledger/anchors", {}, env);
26+
expect(await response.json()).toMatchObject({ anchors: [], status: "unconfigured" });
27+
});
28+
29+
it("reports pending once a ledger exists AND a signing key is published", async () => {
30+
const env = createTestEnv();
31+
await seedLedgerRow(env);
32+
env.LOOPOVER_LEDGER_ANCHOR_KEYS = JSON.stringify([{ keyId: "k1", publicKeySpki: "c3BraQ==", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null }]);
33+
env.LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY = "test-private-key";
34+
const response = await createApp().request("/v1/public/decision-ledger/anchors", {}, env);
35+
expect(await response.json()).toMatchObject({ anchors: [], status: "pending" });
36+
});
37+
38+
it("is still unconfigured when a key is published but the private half is not set", async () => {
39+
// Both sides of the signing-key predicate: a published public key alone cannot sign anything.
40+
const env = createTestEnv();
41+
await seedLedgerRow(env);
42+
env.LOOPOVER_LEDGER_ANCHOR_KEYS = JSON.stringify([{ keyId: "k1", publicKeySpki: "c3BraQ==", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null }]);
43+
const response = await createApp().request("/v1/public/decision-ledger/anchors", {}, env);
44+
expect(await response.json()).toMatchObject({ anchors: [], status: "unconfigured" });
45+
});
46+
47+
it("omits status on a filtered page, where an empty result only means nothing matched", async () => {
48+
const env = createTestEnv();
49+
const body = (await (await createApp().request("/v1/public/decision-ledger/anchors?backend=rekor", {}, env)).json()) as Record<string, unknown>;
50+
expect(body).toEqual({ anchors: [], nextBefore: null });
51+
expect("status" in body).toBe(false);
1652
});
1753

1854
it("serves a FAILED anchor attempt on the public listing, identically shaped to a success", async () => {
@@ -91,3 +127,24 @@ describe("GET /v1/public/decision-ledger/anchors (#9271)", () => {
91127
expect(response.headers.get("Cache-Control")).toBe("public, max-age=60, stale-while-revalidate=300");
92128
});
93129
});
130+
131+
/** One persisted decision record, so the ledger tip is non-zero — mirrors ledger-anchor-scheduler.test.ts's
132+
* seedOneDecision, which every scheduler case already calls for the same reason. */
133+
async function seedLedgerRow(env: Env): Promise<void> {
134+
const { record, recordDigest } = await buildDecisionRecord({
135+
repoFullName: "acme/widgets",
136+
pullNumber: 1,
137+
headSha: "abc1",
138+
baseSha: null,
139+
action: "merge",
140+
reasonCode: "gate_clean",
141+
configDigest: await contentDigest({ gatePack: "oss-anti-slop" }),
142+
gatePack: "oss-anti-slop",
143+
ciState: null,
144+
modelIds: null,
145+
promptDigest: null,
146+
aiConfidence: null,
147+
salvageability: null,
148+
});
149+
await persistDecisionRecord(env, record, recordDigest);
150+
}

0 commit comments

Comments
 (0)