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
2 changes: 1 addition & 1 deletion apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 18 additions & 2 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
17 changes: 17 additions & 0 deletions src/review/ledger-anchor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
59 changes: 58 additions & 1 deletion test/integration/public-ledger-anchors-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand 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<string, unknown>;
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 () => {
Expand Down Expand Up @@ -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<void> {
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);
}
Loading