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
18 changes: 16 additions & 2 deletions apps/loopover-ui/content/docs/fairness-methodology.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,22 @@ Nothing here is a summary of intent. Every rule below is the rule the code imple

<Callout variant="note">
**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.
</Callout>

## The three data sources are not interchangeable
Expand Down
13 changes: 13 additions & 0 deletions apps/loopover-ui/content/docs/verify-this-review.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 5 additions & 2 deletions packages/loopover-mcp/bin/loopover-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 : [];
Expand Down Expand Up @@ -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<typeof checkAnchorCheckpoint>[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
Expand Down
22 changes: 21 additions & 1 deletion packages/loopover-mcp/lib/verify-public-claims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClaimResult> {
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) {
Expand Down
19 changes: 19 additions & 0 deletions test/unit/verify-public-claims.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading