From aa9c46531b9e1738d02cec3cc1246f9d307f67fa Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:27:40 -0700 Subject: [PATCH 1/2] fix(verify): tell an empty ledger apart from a missing signing key Refs #9940. Both produced the same sentence -- 'anchor signing not configured, or the ledger is empty' -- and the difference is the entire diagnosis. It sent me looking for a missing secret on edge-nl-01, whose anchoring was working perfectly: the surface the verifier defaults to simply holds no ledger, because decisions are recorded on the ORB and only aggregate outcomes travel to the public API. The check now reads the ledger's own size and reports three distinct states: an empty ledger (nothing to anchor -- not a failure, and it names --base-url as the fix), records present with nothing signed (a real gap), and an unreachable ledger endpoint. Verified end to end against production: api.loopover.ai reports the empty-ledger skip, and the ORB's own surface PASSES -- checkpoint at seq 2190 verifies offline against published key 6b6490126ad44b51, which is also the first proof this verifier's crypto path works against a real signature. --- packages/loopover-mcp/bin/loopover-verify.ts | 7 ++++-- .../loopover-mcp/lib/verify-public-claims.ts | 22 ++++++++++++++++++- test/unit/verify-public-claims.test.ts | 19 ++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/loopover-mcp/bin/loopover-verify.ts b/packages/loopover-mcp/bin/loopover-verify.ts index b2549eb243..8f66c83c3e 100644 --- a/packages/loopover-mcp/bin/loopover-verify.ts +++ b/packages/loopover-mcp/bin/loopover-verify.ts @@ -109,11 +109,14 @@ export async function runVerify(args: readonly string[], baseUrlOverride?: strin // Fetched together: they are independent reads, and a verifier that serialises four round trips for no // reason is a slower verifier with no compensating property. - const [scoresOutcome, statsOutcome, checkpointOutcome, keysOutcome] = await Promise.all([ + const [scoresOutcome, statsOutcome, checkpointOutcome, keysOutcome, ledgerOutcome] = await Promise.all([ apiGet<{ records?: VerifiableEvalRecord[] }>(baseUrl, "/v1/public/eval-scores"), apiGet<{ totals?: { handled?: unknown }; reviewParity?: { verdicts?: unknown } }>(baseUrl, "/v1/public/stats"), apiGet<{ signed?: unknown; signingInput?: unknown }>(baseUrl, "/v1/public/decision-ledger/anchor-payload"), apiGet<{ keys?: unknown[] }>(baseUrl, "/v1/public/decision-ledger/anchor-key"), + // #9940: the ledger's own size, so an EMPTY surface is reported as such rather than as a missing + // signing key. Those are different findings and only one of them is a problem. + apiGet<{ totalCount?: unknown }>(baseUrl, "/v1/public/decision-ledger/verify"), ]); const records = scoresOutcome.ok && Array.isArray(scoresOutcome.value.records) ? scoresOutcome.value.records : []; @@ -141,7 +144,7 @@ export async function runVerify(args: readonly string[], baseUrlOverride?: strin // so an unavailable checkpoint is passed through as `undefined` and reported as a skip by the check // itself rather than being special-cased into a second, subtly different skip message here. const keys = keysOutcome.ok && Array.isArray(keysOutcome.value.keys) ? (keysOutcome.value.keys as Parameters[1]) : []; - results.push(await checkAnchorCheckpoint(checkpointOutcome.ok ? checkpointOutcome.value : undefined, keys)); + results.push(await checkAnchorCheckpoint(checkpointOutcome.ok ? checkpointOutcome.value : undefined, keys, ledgerOutcome.ok ? ledgerOutcome.value : undefined)); results.push( statsOutcome.ok diff --git a/packages/loopover-mcp/lib/verify-public-claims.ts b/packages/loopover-mcp/lib/verify-public-claims.ts index 1e12a7d73c..ddda3a5546 100644 --- a/packages/loopover-mcp/lib/verify-public-claims.ts +++ b/packages/loopover-mcp/lib/verify-public-claims.ts @@ -179,11 +179,31 @@ export async function checkCorpusCommitments( export async function checkAnchorCheckpoint( checkpoint: { signed?: unknown; signingInput?: unknown } | undefined, keys: readonly AnchorPublicKey[], + ledger?: { totalCount?: unknown } | undefined, ): Promise { const claim = "The current signed ledger checkpoint verifies offline against a published key"; const id = "anchor-checkpoint"; if (checkpoint === undefined || !isSignedAnchor(checkpoint.signed)) { - return { id, claim, status: "skip", detail: "no signed checkpoint published (anchor signing not configured, or the ledger is empty)" }; + // #9940: an EMPTY ledger and a misconfigured signer used to produce the same sentence, and the + // difference is the whole diagnosis. This surface holding no decisions is not a verifiability failure + // -- there is nothing to anchor -- whereas decisions with no signing key is a real gap. Conflating them + // sent me down the wrong path on a live deployment: I read "not configured" and went looking for a + // missing secret, when the deployment simply had no ledger and the anchoring worked fine elsewhere. + const totalCount = typeof ledger?.totalCount === "number" ? ledger.totalCount : null; + if (totalCount === 0) { + return { + id, + claim, + status: "skip", + detail: "this deployment's decision ledger is EMPTY (0 records), so there is nothing to anchor — check the deployment that actually records decisions, via --base-url", + }; + } + return { + id, + claim, + status: "skip", + detail: totalCount === null ? "no signed checkpoint published, and the ledger size is unknown" : `no signed checkpoint published, though the ledger holds ${totalCount} record(s) — anchor signing looks unconfigured here`, + }; } const signed = checkpoint.signed; if (keys.length === 0) { diff --git a/test/unit/verify-public-claims.test.ts b/test/unit/verify-public-claims.test.ts index 4525e86ebd..2d354bafaa 100644 --- a/test/unit/verify-public-claims.test.ts +++ b/test/unit/verify-public-claims.test.ts @@ -176,6 +176,25 @@ describe("checkAnchorCheckpoint", () => { expect(result.detail).toContain("not among"); }); + it("distinguishes an EMPTY ledger from a missing signing key (#9940)", async () => { + // These read identically before, and the difference is the whole diagnosis. Conflating them sent me + // looking for a missing secret on a deployment whose anchoring was working fine -- the surface simply + // held no ledger, because the records live on a different one. + const { checkAnchorCheckpoint } = await loadClaims(); + const empty = await checkAnchorCheckpoint(undefined, [], { totalCount: 0 }); + expect(empty.status).toBe("skip"); + expect(empty.detail).toContain("EMPTY"); + expect(empty.detail).toContain("--base-url"); + + // Records present but nothing signed: a real gap, and it must NOT read as "nothing to anchor". + const unsigned = await checkAnchorCheckpoint(undefined, [], { totalCount: 2190 }); + expect(unsigned.detail).toContain("2190"); + expect(unsigned.detail).not.toContain("EMPTY"); + + // Ledger size unknown (the endpoint itself was unreachable) is its own third case. + expect((await checkAnchorCheckpoint(undefined, [], undefined)).detail).toContain("ledger size is unknown"); + }); + it("skips when no checkpoint is published, and when no key is published to check one against", async () => { const { checkAnchorCheckpoint } = await loadClaims(); const { signed } = await realCheckpoint(); From 1f2d4835cdddcb3c1fcb1699be2cc5887d676bf6 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:37:52 -0700 Subject: [PATCH 2/2] docs(fairness): point ledger verification at the Orb that holds it The public API's ledger is empty by design -- it aggregates outcomes Orbs report rather than holding their chains -- so /fairness and the walkthrough were sending readers to a surface that structurally could not answer the ledger question. Both pages now name the two surfaces and what each answers, and say plainly that a skipped ledger check against the aggregate is expected rather than a failure. --- .../content/docs/fairness-methodology.mdx | 18 ++++++++++++++++-- .../content/docs/verify-this-review.mdx | 13 +++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/loopover-ui/content/docs/fairness-methodology.mdx b/apps/loopover-ui/content/docs/fairness-methodology.mdx index 3b87a98f3c..ecc6ec2db8 100644 --- a/apps/loopover-ui/content/docs/fairness-methodology.mdx +++ b/apps/loopover-ui/content/docs/fairness-methodology.mdx @@ -16,8 +16,22 @@ Nothing here is a summary of intent. Every rule below is the rule the code imple **Want the machine-checkable version?** `npx -p @loopover/mcp loopover-verify` recomputes the - published commitments from the public endpoints and prints a PASS/FAIL table. It needs no - credentials. See [Verify this review](/docs/verify-this-review). + published commitments and prints a PASS/FAIL table. It needs no credentials. See + [Verify this review](/docs/verify-this-review). + + Two surfaces answer different questions, because they hold different data (#9940). Aggregate + stats and eval-score records come from `api.loopover.ai`, the default. The **anchored decision + ledger** lives on the Orb that actually reviews — its own hash chain, signed and anchored to + Rekor — so ledger and anchor checks need that one: + + ```bash + npx -p @loopover/mcp loopover-verify # stats + eval scores + npx -p @loopover/mcp loopover-verify --base-url https://shots.loopover.ai # ledger + anchors + ``` + + The public API's own ledger is empty by design: it aggregates outcomes reported by Orbs rather + than holding their chains. A verifier run against it reports the ledger checks as *skipped*, + not failed. ## The three data sources are not interchangeable diff --git a/apps/loopover-ui/content/docs/verify-this-review.mdx b/apps/loopover-ui/content/docs/verify-this-review.mdx index 06aa436c5c..86be9335f7 100644 --- a/apps/loopover-ui/content/docs/verify-this-review.mdx +++ b/apps/loopover-ui/content/docs/verify-this-review.mdx @@ -16,6 +16,19 @@ figure stops supporting a conclusion — see the [fairness methodology](/docs/fa For a single command that recomputes the published commitments and exits non-zero if any fail, run `npx -p @loopover/mcp loopover-verify`. +**Two surfaces, because they hold different things** (#9940). The commands on this page use +`api.loopover.ai`, which serves aggregate stats and eval-score records. The **anchored decision +ledger** — the hash chain, its signed checkpoints, and the Rekor anchors — lives on the Orb that +performs the reviews, at `https://shots.loopover.ai`. Point the ledger and anchor checks there: + +```bash +curl -s https://shots.loopover.ai/v1/public/decision-ledger/verify | jq '{ok, tipSeq, totalCount}' +npx -p @loopover/mcp loopover-verify --base-url https://shots.loopover.ai +``` + +The public API's own ledger is empty by design — it aggregates outcomes Orbs report, rather than +holding their chains — so a run against it reports the ledger checks as *skipped*, never failed. + Everything below runs read-only against a corpus export and pure functions from `@loopover/engine`. Nothing posts anywhere, and nothing needs a LoopOver API key.