diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 9750863581..6ee7148fe3 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -20802,7 +20802,7 @@ ], "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" + "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" } }, "operationId": "listPublicDecisionLedgerAnchors", diff --git a/src/api/routes.ts b/src/api/routes.ts index 6e6b6bca9f..5da7d96d88 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -305,7 +305,7 @@ import { isFairnessAnalyticsEnabled, resolveFairnessAnalyticsManifestOverride } import { isRagEnabled } from "../review/rag-wire"; import { loadDecisionLedgerTip, loadPublicDecisionRecord, loadPublicLedgerRow, verifyDecisionLedger } from "../review/decision-record"; import { buildEvalScoreRecordsFromRulePrecision, filterEvalScoreRecords } from "../review/eval-score-records"; -import { anchorSigningInput, buildLedgerAnchorPayload, currentAnchorKey, parseAnchorPublicKeys, signLedgerAnchorPayload } from "../review/ledger-anchor"; +import { anchorSigningInput, buildLedgerAnchorPayload, currentAnchorKey, parseAnchorPublicKeys, publicAnchorStatus, signLedgerAnchorPayload } from "../review/ledger-anchor"; import { ingestBittensorAnchorReport, parseBittensorAnchorReport } from "../review/ledger-anchor-bittensor"; import { loadPublicLedgerAnchors } from "../review/ledger-anchor-persistence"; import { getPublicStats, isPublicStatsEnabled, resolvePublicStatsManifestOverride } from "../review/public-stats"; @@ -1346,8 +1346,24 @@ export function createApp() { ...(before !== undefined && { before }), ...(limit !== undefined && { limit }), }); + // An empty list is ambiguous on its own -- say WHY, so "not configured" can never be mistaken for + // "healthy, nothing to report". Only computed for an unfiltered first page: with a backend/before filter an + // empty page means "none matched", which is a different question than "is anchoring running at all". + const unfiltered = backend === undefined && before === undefined; + const [tip, keys] = unfiltered + ? await Promise.all([loadDecisionLedgerTip(c.env), Promise.resolve(parseAnchorPublicKeys(c.env.LOOPOVER_LEDGER_ANCHOR_KEYS))]) + : [null, []]; c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=300"); - return c.json(result); + return c.json({ + ...result, + ...(tip !== null && { + status: publicAnchorStatus({ + anchorCount: result.anchors.length, + tipSeq: tip.seq, + hasSigningKey: currentAnchorKey(keys) !== null && Boolean(c.env.LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY), + }), + }), + }); }); // #9277 (epic #9267): the current tip's SIGNED checkpoint, for the operator's off-Worker Bittensor diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 00bb22fdfb..6fa2382965 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -1906,7 +1906,7 @@ export function buildOpenApiSpec() { 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", "bittensor"]).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" }, + 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" }, }, }); registry.registerPath({ diff --git a/src/review/ledger-anchor.ts b/src/review/ledger-anchor.ts index ac12df6c13..5e5bf7406f 100644 --- a/src/review/ledger-anchor.ts +++ b/src/review/ledger-anchor.ts @@ -210,6 +210,23 @@ export function anchorKeyById(keys: readonly AnchorPublicKey[], keyId: string): return keys.find((key) => key.keyId === keyId) ?? null; } +/** Why the public anchor list looks the way it does. Without this, "never configured", "no ledger to anchor", + * and "anchoring is healthy but has not run yet" are all indistinguishable from outside — every one of them + * renders as `{"anchors":[]}`, which reads as a healthy empty state. The module header of + * ledger-anchor-persistence.ts states the goal ("an operator whose anchoring silently fails could quietly + * regress the ledger back to tamper-evident-only with no visible signal"); that guarantee only held AFTER + * both of the scheduler's guards passed, and this closes the gap before them. */ +export type PublicAnchorStatus = "anchored" | "empty_ledger" | "unconfigured" | "pending"; + +/** PURE. Guard order deliberately mirrors runScheduledLedgerAnchor's own (tip first, then signing key), so the + * status a reader sees always names the same reason the scheduler would act on, never a second opinion. */ +export function publicAnchorStatus(input: { anchorCount: number; tipSeq: number; hasSigningKey: boolean }): PublicAnchorStatus { + if (input.anchorCount > 0) return "anchored"; + if (input.tipSeq === 0) return "empty_ledger"; + if (!input.hasSigningKey) return "unconfigured"; + return "pending"; +} + /** Digest helpers re-exported so an anchor consumer (e.g. the git-commit backend, #9273, which commits the * same canonicalized payload Rekor anchors) never needs a second import from decision-record.ts just to * canonicalize or hash something alongside a signed anchor. */ diff --git a/test/integration/public-ledger-anchors-route.test.ts b/test/integration/public-ledger-anchors-route.test.ts index 8936e1db6e..84e24222b8 100644 --- a/test/integration/public-ledger-anchors-route.test.ts +++ b/test/integration/public-ledger-anchors-route.test.ts @@ -3,6 +3,7 @@ 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"; +import { buildDecisionRecord, contentDigest, persistDecisionRecord } from "../../src/review/decision-record"; // #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. @@ -12,7 +13,42 @@ describe("GET /v1/public/decision-ledger/anchors (#9271)", () => { 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 }); + // #9719: an empty list now says WHY -- a fresh env has no ledger rows, so there is nothing to anchor yet. + expect(await response.json()).toEqual({ anchors: [], nextBefore: null, status: "empty_ledger" }); + }); + + it("REGRESSION: distinguishes an unconfigured deployment from a healthy empty one", async () => { + // Before #9719 all of "never configured", "nothing to anchor" and "healthy but not run yet" rendered as + // {"anchors":[]} -- indistinguishable from outside, so silent misconfiguration looked like success. + const env = createTestEnv(); + await seedLedgerRow(env); + const response = await createApp().request("/v1/public/decision-ledger/anchors", {}, env); + expect(await response.json()).toMatchObject({ anchors: [], status: "unconfigured" }); + }); + + it("reports pending once a ledger exists AND a signing key is published", async () => { + const env = createTestEnv(); + await seedLedgerRow(env); + env.LOOPOVER_LEDGER_ANCHOR_KEYS = JSON.stringify([{ keyId: "k1", publicKeySpki: "c3BraQ==", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null }]); + env.LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY = "test-private-key"; + const response = await createApp().request("/v1/public/decision-ledger/anchors", {}, env); + expect(await response.json()).toMatchObject({ anchors: [], status: "pending" }); + }); + + it("is still unconfigured when a key is published but the private half is not set", async () => { + // Both sides of the signing-key predicate: a published public key alone cannot sign anything. + const env = createTestEnv(); + await seedLedgerRow(env); + env.LOOPOVER_LEDGER_ANCHOR_KEYS = JSON.stringify([{ keyId: "k1", publicKeySpki: "c3BraQ==", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null }]); + const response = await createApp().request("/v1/public/decision-ledger/anchors", {}, env); + expect(await response.json()).toMatchObject({ anchors: [], status: "unconfigured" }); + }); + + it("omits status on a filtered page, where an empty result only means nothing matched", async () => { + const env = createTestEnv(); + const body = (await (await createApp().request("/v1/public/decision-ledger/anchors?backend=rekor", {}, env)).json()) as Record; + expect(body).toEqual({ anchors: [], nextBefore: null }); + expect("status" in body).toBe(false); }); 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)", () => { expect(response.headers.get("Cache-Control")).toBe("public, max-age=60, stale-while-revalidate=300"); }); }); + +/** One persisted decision record, so the ledger tip is non-zero — mirrors ledger-anchor-scheduler.test.ts's + * seedOneDecision, which every scheduler case already calls for the same reason. */ +async function seedLedgerRow(env: Env): Promise { + const { record, recordDigest } = await buildDecisionRecord({ + repoFullName: "acme/widgets", + pullNumber: 1, + headSha: "abc1", + 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); +}