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
14 changes: 12 additions & 2 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -27587,14 +27587,24 @@
"required": true,
"name": "ruleId",
"in": "query"
},
{
"schema": {
"type": "string",
"description": "Deprecated alias for `ruleId`, accepted so verifiers published before #9962 (which asked for this spelling and got a 400) work unchanged. `ruleId` wins if both are given."
},
"required": false,
"description": "Deprecated alias for `ruleId`, accepted so verifiers published before #9962 (which asked for this spelling and got a 400) work unchanged. `ruleId` wins if both are given.",
"name": "rule_id",
"in": "query"
}
],
"responses": {
"200": {
"description": "{ ruleId, windowDays, caseCount, truncated, checksum, cases: [{ ruleId, outcome, label, firedAt, decidedAt, metadata?: { confidence } }] } — `targetKey` and every other metadata key are DROPPED, not hashed, so no repo or PR identity is published; `metadata.confidence` stays nested where buildConfidenceThresholdClassifier reads it. Timestamps are truncated to the UTC day. `checksum` is sha256 over canonicalJson(cases), i.e. it commits to the artifact you can actually download"
"description": "{ ruleId, windowDays, caseCount, truncated, readFailed, checksum, cases: [{ ruleId, outcome, label, firedAt, decidedAt, metadata?: { confidence } }] } — `targetKey` and every other metadata key are DROPPED, not hashed, so no repo or PR identity is published; `metadata.confidence` stays nested where buildConfidenceThresholdClassifier reads it. Timestamps are truncated to the UTC day. `checksum` is sha256 over canonicalJson(cases), i.e. it commits to the artifact you can actually download. `readFailed` is true when the corpus is empty because the history read failed rather than because the rule has no cases — no commitment is published for such a corpus"
},
"400": {
"description": "`ruleId` is required — a corpus is only meaningful for one rule"
"description": "`ruleId` (or its `rule_id` alias) is required — a corpus is only meaningful for one rule"
},
"404": {
"description": "Public stats are disabled for this deployment"
Expand Down
8 changes: 7 additions & 1 deletion packages/loopover-mcp/bin/loopover-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,13 @@ export async function runVerify(args: readonly string[], baseUrlOverride?: strin
const ruleIds = [...new Set(records.map((record) => record.workUnit?.ruleId).filter((ruleId): ruleId is string => typeof ruleId === "string"))];
const corpusEntries = await Promise.all(
ruleIds.map(async (ruleId) => {
const outcome = await apiGet<{ cases?: unknown; checksum?: unknown }>(baseUrl, `/v1/public/eval-corpus?rule_id=${encodeURIComponent(ruleId)}`);
// `ruleId`, not `rule_id` (#9962). The route has only ever read the camelCase spelling -- the snake_case
// one 400s -- so every corpus fetch this verifier made came back empty and the corpus claim degraded to
// "nothing could be rehashed" against a deployment that was publishing a perfectly good corpus. The
// camelCase spelling is what the OpenAPI spec and the docs have always documented, so it works against
// every deployment, old and new; the alias the route now also accepts is there for verifiers already
// installed in the wild, not for this one.
const outcome = await apiGet<{ cases?: unknown; checksum?: unknown }>(baseUrl, `/v1/public/eval-corpus?ruleId=${encodeURIComponent(ruleId)}`);
return [ruleId, outcome.ok ? outcome.value : undefined] as const;
}),
);
Expand Down
2 changes: 1 addition & 1 deletion packages/loopover-mcp/lib/verify-public-claims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export async function checkRecordDigests(records: readonly VerifiableEvalRecord[
/**
* CLAIM 2: each record's `corpusChecksum` is the checksum of a corpus that can actually be downloaded.
*
* `corpusByRuleId` holds what `/v1/public/eval-corpus?rule_id=…` returned for each rule -- absent when that
* `corpusByRuleId` holds what `/v1/public/eval-corpus?ruleId=…` returned for each rule -- absent when that
* fetch found nothing. The checksum is recomputed from the CASES as served, which is the whole point: a
* commitment is only worth anything if it covers bytes the reader can obtain, so this deliberately does not
* trust the corpus payload's own `checksum` field and recomputes from `cases` instead.
Expand Down
9 changes: 8 additions & 1 deletion src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -903,7 +903,14 @@ export function createApp() {
app.get("/v1/public/eval-corpus", async (c) => {
const publicStatsManifestOverride = await resolvePublicStatsManifestOverride(c.env);
if (!isPublicStatsEnabled(c.env, publicStatsManifestOverride)) return c.json({ error: "not_found" }, 404);
const ruleId = c.req.query("ruleId");
// `ruleId` is canonical (it is what the OpenAPI spec, the verifiability walkthrough and every other query
// parameter on this API use). `rule_id` is accepted as an ALIAS because #9962: every published
// `@loopover/mcp` verifier up to and including 3.x asks for `?rule_id=`, got a 400 back, and reported
// "no corresponding corpus is downloadable" -- a commitment that looked broken while the bytes were sitting
// one spelling away. Fixing only the client would leave every already-installed copy reporting that same
// false negative against production forever, so the SERVER meets them. The alias is read second, so a
// caller passing both gets the canonical spelling rather than a coin flip.
const ruleId = c.req.query("ruleId") ?? c.req.query("rule_id");
// Required, not defaulted: a corpus is only meaningful for one rule, and silently picking one would
// publish a checksum for a rule the caller never asked about.
if (!ruleId) return c.json({ error: "rule_id_required" }, 400);
Expand Down
14 changes: 11 additions & 3 deletions src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1976,13 +1976,21 @@ export function buildOpenApiSpec() {
operationId: "getPublicEvalCorpus",
tags: ["Public"],
summary: "The redacted, checksummed corpus behind one rule's published precision — downloadable without credentials",
request: { query: z.object({ ruleId: z.string() }) },
request: {
query: z.object({
ruleId: z.string(),
rule_id: z
.string()
.optional()
.describe("Deprecated alias for `ruleId`, accepted so verifiers published before #9962 (which asked for this spelling and got a 400) work unchanged. `ruleId` wins if both are given."),
}),
},
responses: {
200: {
description:
"{ ruleId, windowDays, caseCount, truncated, checksum, cases: [{ ruleId, outcome, label, firedAt, decidedAt, metadata?: { confidence } }] } — `targetKey` and every other metadata key are DROPPED, not hashed, so no repo or PR identity is published; `metadata.confidence` stays nested where buildConfidenceThresholdClassifier reads it. Timestamps are truncated to the UTC day. `checksum` is sha256 over canonicalJson(cases), i.e. it commits to the artifact you can actually download",
"{ ruleId, windowDays, caseCount, truncated, readFailed, checksum, cases: [{ ruleId, outcome, label, firedAt, decidedAt, metadata?: { confidence } }] } — `targetKey` and every other metadata key are DROPPED, not hashed, so no repo or PR identity is published; `metadata.confidence` stays nested where buildConfidenceThresholdClassifier reads it. Timestamps are truncated to the UTC day. `checksum` is sha256 over canonicalJson(cases), i.e. it commits to the artifact you can actually download. `readFailed` is true when the corpus is empty because the history read failed rather than because the rule has no cases — no commitment is published for such a corpus",
},
400: { description: "`ruleId` is required — a corpus is only meaningful for one rule" },
400: { description: "`ruleId` (or its `rule_id` alias) is required — a corpus is only meaningful for one rule" },
404: { description: "Public stats are disabled for this deployment" },
},
});
Expand Down
61 changes: 37 additions & 24 deletions src/review/eval-score-records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,24 +82,41 @@ export function evalScoreCoverage(decided: number, abstained: number): number |
}

/**
* Build `EvalScoreRecord`s from the already-computed public rule-precision block. Returns an empty array
* when there is no persisted backtest run yet (`latestBacktestRun === null`) -- per #9215's own requirement,
* a record whose commitments cannot be independently re-derived (no corpus checksum to point at) is not
* publishable, so this deliberately emits nothing rather than a record with a placeholder commitment.
* Build `EvalScoreRecord`s from the already-computed public rule-precision block. A rule is published only
* when `corpusChecksumByRuleId` carries a usable commitment for it -- per #9215's own requirement, a record
* whose commitments cannot be independently re-derived is not publishable, so this deliberately emits nothing
* rather than a record with a placeholder commitment.
*
* #9805: when no backtest run is persisted, the commitment falls back to `corpusChecksumByRuleId` -- the
* checksum of the corpus `/v1/public/eval-corpus` publishes for that same rule, over the same window. That is
* not a placeholder standing in for a real commitment: it is a hash over an artifact the reader can download
* and re-hash themselves, which is exactly what the `reproducible` trust tier asserts. It exists because a
* deployment with review execution retired (the hosted Worker: see src/index.ts) never persists a backtest
* run at all, so the entire surface was empty while a complete, downloadable corpus sat behind the next
* endpoint over.
* #9805: that commitment is the checksum of the corpus `/v1/public/eval-corpus` publishes for that same rule,
* over the same window. It is not a placeholder standing in for a real commitment: it is a hash over an
* artifact the reader can download and re-hash themselves, which is exactly what the `reproducible` trust tier
* asserts.
*
* #9962: it is also the ONLY commitment source. `precision.latestBacktestRun.corpusChecksum` used to take
* precedence wherever a run was persisted, which was wrong three ways at once and wrong precisely on the
* deployments that execute reviews (the hosted Worker has execution retired, so it never hit this path and the
* defect stayed invisible there):
*
* 1. It is NOT DOWNLOADABLE. That checksum is `checksumCases` over the raw internal `BacktestCase[]` --
* `targetKey`, the full metadata bag, full-precision timestamps -- whereas the corpus a reader can fetch
* is the REDACTED one. The two hash different bytes by construction, so the published commitment could
* never match the published corpus, no matter how healthy the deployment. Pairing that with
* `trust.tier: "reproducible"` asserts a reproducibility the artifact cannot support.
* 2. It is GLOBAL, not per rule. `loadPublicRulePrecision` selects it with `ORDER BY created_at DESC LIMIT 1`
* and no rule filter, so one rule's freeze point got stamped onto every record. This module already
* warned that stamping one checksum across every record would have every record but one committing to a
* different rule's cases, and called it latent "only because a single rule clears the publication floor";
* on a self-host ledger where seven rules clear it, it stopped being latent.
* 3. It is UNWINDOWED. That same query has no `created_at >= ?` bound, so the run could predate the
* `windowStart`/`windowEnd` the record goes on to declare.
*
* `latestBacktestRun` remains published in its own right as the rule-precision block's freeze point (see
* public-rule-precision.ts); it is only no longer misused as a public, re-derivable corpus commitment.
*
* The commitment is resolved PER RULE, not once for the whole batch. Each rule's score is computed over its
* own corpus, so stamping one checksum across every record would have every record but one committing to a
* different rule's cases -- latent today only because a single rule clears the publication floor.
* own corpus, so one checksum across every record would be a different rule's cases on all but one of them.
*
* Also returns an empty array when the run's checksum is {@link EMPTY_CORPUS_CHECKSUM}. A hash over zero
* Also omits a rule whose checksum is {@link EMPTY_CORPUS_CHECKSUM}. A hash over zero
* cases is the same 32 bytes for every rule, every window, and every deployment, so it points at nothing a
* consumer could re-derive the scores from -- it is a placeholder commitment wearing a real hash's clothes,
* and pairing it with a `reproducible` trust tier claims a reproducibility the artifact cannot support. The
Expand All @@ -121,23 +138,19 @@ export function evalScoreCoverage(decided: number, abstained: number): number |
export async function buildEvalScoreRecordsFromRulePrecision(
precision: PublicRulePrecision,
issuedAt: string,
// #9805: per-rule fallback commitments, supplied by the caller so this module stays PURE. Only rules whose
// published corpus is a usable commitment belong in here -- the route drops empty and truncated ones before
// building it, because a truncated corpus's checksum covers a subset of the cases the score covers.
// The per-rule commitments, supplied by the caller so this module stays PURE. Only rules whose published
// corpus is a usable commitment belong in here -- buildPublicCorpusCommitments drops degraded reads, empty
// corpora and truncated ones before building it, because a truncated corpus's checksum covers a subset of
// the cases the score covers. Defaulted to an empty map so a caller with nothing to commit publishes nothing
// (#9962: this is now the sole source, so the default means "no records", never "fall back to the run").
corpusChecksumByRuleId: ReadonlyMap<string, string> = new Map(),
): Promise<EvalScoreRecord[]> {
// A persisted backtest run still wins where one exists, so a deployment that executes reviews keeps exactly
// today's behaviour and this change cannot silently move a self-host commitment.
const runChecksum =
precision.latestBacktestRun && precision.latestBacktestRun.corpusChecksum !== EMPTY_CORPUS_CHECKSUM
? precision.latestBacktestRun.corpusChecksum
: null;
const windowStart = new Date(Date.parse(issuedAt) - precision.windowDays * 24 * 60 * 60 * 1000).toISOString();

// A rule with no usable commitment is OMITTED rather than published with a placeholder -- the #9215
// requirement this module has always enforced, now applied per rule instead of to the whole batch.
const publishable = precision.rules.flatMap((row) => {
const corpusChecksum = runChecksum ?? corpusChecksumByRuleId.get(row.ruleId) ?? null;
const corpusChecksum = corpusChecksumByRuleId.get(row.ruleId) ?? null;
return corpusChecksum === null || corpusChecksum === EMPTY_CORPUS_CHECKSUM ? [] : [{ row, corpusChecksum }];
});

Expand Down
Loading
Loading