diff --git a/.loopover.yml.example b/.loopover.yml.example index 05b93477c5..f0aa1a82d1 100644 --- a/.loopover.yml.example +++ b/.loopover.yml.example @@ -1493,3 +1493,17 @@ settings: # # To stop trusting a peer, remove its key -- that is the whole revocation story, by design. # peerKeys: # - 0000000000000000000000000000000000000000000000000000000000000000 + +# Public proof page (#9569) — the shareable, unauthenticated per-repo verification page +# (`/proof//`) and its README badge. +# +# OPT-OUT, not opt-in. Every figure the page renders is ALREADY publicly fetchable through the +# ledger-verify, anchors and decision-record endpoints, so gating a page over data anyone can already +# curl would add friction without adding privacy. This block exists because a page is nonetheless a +# different artifact from an API: it is discoverable, linkable, and it markets this repo's numbers. +# +# Precedence: the operator's fleet-wide LOOPOVER_PUBLIC_PROOF flag must be on first. This block can turn +# THIS repo's page OFF; it cannot turn one ON that the operator has not enabled. Omit the block entirely +# to keep the page on once the operator enables it. +# publicProof: +# enabled: false # Bool. Default when the block is absent: true (opt-out). diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 6ee7148fe3..4b016e7d0d 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -26299,6 +26299,82 @@ } } } + }, + "/v1/public/repos/{owner}/{repo}/proof": { + "get": { + "operationId": "getPublicRepoProof", + "tags": [ + "Public" + ], + "summary": "Public proof summary for one repo — ledger status, anchor, calibration with coverage and interval, sample records", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "ProofSummary. Any accuracy figure carries its coverage AND a Wilson confidence interval; below the sample floor it is an explicit `insufficient_data` state, never a bare percentage. Carries the verification-boundary statement in the payload" + }, + "404": { + "description": "The proof page is disabled fleet-wide, or this repo has opted out" + }, + "503": { + "description": "Composition failed — no partial or fabricated summary is served" + } + } + } + }, + "/v1/public/repos/{owner}/{repo}/proof-badge.svg": { + "get": { + "operationId": "getPublicRepoProofBadge", + "tags": [ + "Public" + ], + "summary": "README badge for the proof page — reports the ledger's state, never a bare accuracy percentage", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "SVG badge" + }, + "404": { + "description": "Disabled or opted out — still an SVG (a neutral 'unavailable' badge), so a README never shows a broken image" + }, + "503": { + "description": "Same neutral SVG on an internal error" + } + } + } } }, "servers": [ diff --git a/config/examples/loopover.full.yml b/config/examples/loopover.full.yml index 416a733173..13096355f8 100644 --- a/config/examples/loopover.full.yml +++ b/config/examples/loopover.full.yml @@ -1507,3 +1507,17 @@ settings: # # To stop trusting a peer, remove its key -- that is the whole revocation story, by design. # peerKeys: # - 0000000000000000000000000000000000000000000000000000000000000000 + +# Public proof page (#9569) — the shareable, unauthenticated per-repo verification page +# (`/proof//`) and its README badge. +# +# OPT-OUT, not opt-in. Every figure the page renders is ALREADY publicly fetchable through the +# ledger-verify, anchors and decision-record endpoints, so gating a page over data anyone can already +# curl would add friction without adding privacy. This block exists because a page is nonetheless a +# different artifact from an API: it is discoverable, linkable, and it markets this repo's numbers. +# +# Precedence: the operator's fleet-wide LOOPOVER_PUBLIC_PROOF flag must be on first. This block can turn +# THIS repo's page OFF; it cannot turn one ON that the operator has not enabled. Omit the block entirely +# to keep the page on once the operator enables it. +# publicProof: +# enabled: false # Bool. Default when the block is absent: true (opt-out). diff --git a/packages/loopover-engine/src/focus-manifest.ts b/packages/loopover-engine/src/focus-manifest.ts index c2a545c67c..7986668d9d 100644 --- a/packages/loopover-engine/src/focus-manifest.ts +++ b/packages/loopover-engine/src/focus-manifest.ts @@ -440,6 +440,21 @@ export type FocusManifestPublicStatsConfig = { enabled: boolean; }; +/** + * Per-repo opt-OUT for the public proof page and its badge (#9569), declared under `publicProof:`. + * + * Shape matches `publicStats:`/`ops:`, but the PRECEDENCE is deliberately different: those are fleet-wide + * and read from the operator's self-repo manifest, whereas this is read from the TARGET repo's own manifest, + * because the thing being opted out of is that repo's own page. Not present ⇒ enabled, once the operator's + * LOOPOVER_PUBLIC_PROOF flag is on — opt-OUT, since every figure the page renders is already publicly + * fetchable through the ledger-verify / anchors / decision-record endpoints. A repo can turn its page off; + * it cannot turn one on that the operator has not enabled. + */ +export type FocusManifestPublicProofConfig = { + present: boolean; + enabled: boolean; +}; + /** * Config-as-code override for the internal, bearer-gated contributor-trust-profile / fairness-analytics * surface (LOOPOVER_FAIRNESS_ANALYTICS, #fairness-analytics), declared under `fairnessAnalytics:`. Same @@ -1249,6 +1264,7 @@ export type FocusManifest = { maintainerRecap: FocusManifestMaintainerRecapConfig; ops: FocusManifestOpsConfig; publicStats: FocusManifestPublicStatsConfig; + publicProof: FocusManifestPublicProofConfig; fairnessAnalytics: FocusManifestFairnessAnalyticsConfig; draftFlow: FocusManifestDraftFlowConfig; upstreamDriftIssues: FocusManifestUpstreamDriftIssuesConfig; @@ -1413,6 +1429,13 @@ const EMPTY_PUBLIC_STATS_CONFIG: FocusManifestPublicStatsConfig = { enabled: false, }; +/** #9569: absent means ENABLED at the resolver (opt-out), so `enabled:false` here is only the shape's + * default — `present:false` is what the resolver actually keys on. */ +const EMPTY_PUBLIC_PROOF_CONFIG: FocusManifestPublicProofConfig = { + present: false, + enabled: false, +}; + const EMPTY_FAIRNESS_ANALYTICS_CONFIG: FocusManifestFairnessAnalyticsConfig = { present: false, enabled: false, @@ -1478,6 +1501,7 @@ const EMPTY_MANIFEST: FocusManifest = { maintainerRecap: { ...EMPTY_MAINTAINER_RECAP_CONFIG }, ops: { ...EMPTY_OPS_CONFIG }, publicStats: { ...EMPTY_PUBLIC_STATS_CONFIG }, + publicProof: { ...EMPTY_PUBLIC_PROOF_CONFIG }, fairnessAnalytics: { ...EMPTY_FAIRNESS_ANALYTICS_CONFIG }, draftFlow: { ...EMPTY_DRAFT_FLOW_CONFIG }, upstreamDriftIssues: { ...EMPTY_UPSTREAM_DRIFT_ISSUES_CONFIG }, @@ -1520,6 +1544,7 @@ function emptyManifest(source: FocusManifestSource, warnings: string[] = []): Fo maintainerRecap: { ...EMPTY_MAINTAINER_RECAP_CONFIG }, ops: { ...EMPTY_OPS_CONFIG }, publicStats: { ...EMPTY_PUBLIC_STATS_CONFIG }, + publicProof: { ...EMPTY_PUBLIC_PROOF_CONFIG }, fairnessAnalytics: { ...EMPTY_FAIRNESS_ANALYTICS_CONFIG }, draftFlow: { ...EMPTY_DRAFT_FLOW_CONFIG }, upstreamDriftIssues: { ...EMPTY_UPSTREAM_DRIFT_ISSUES_CONFIG }, @@ -2356,6 +2381,24 @@ export function publicStatsConfigToJson(config: FocusManifestPublicStatsConfig): return { enabled: config.enabled }; } +/** Parse the optional `publicProof:` mapping (#9569). Mirrors {@link parsePublicStatsConfig} exactly. */ +function parsePublicProofConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestPublicProofConfig { + if (value === undefined || value === null) return { ...EMPTY_PUBLIC_PROOF_CONFIG }; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push('Manifest field "publicProof" must be a mapping; ignoring it.'); + return { ...EMPTY_PUBLIC_PROOF_CONFIG }; + } + const record = value as Record; + const enabled = normalizeOptionalBoolean(record.enabled, "publicProof.enabled", warnings) ?? false; + return { present: true, enabled }; +} + +/** Serialize a publicProof config so a cached snapshot round-trips through {@link parsePublicProofConfig}. */ +export function publicProofConfigToJson(config: FocusManifestPublicProofConfig): JsonValue { + if (!config.present) return null; + return { enabled: config.enabled }; +} + /** Parse the optional `fairnessAnalytics:` mapping (#fairness-analytics). Mirrors {@link parsePublicStatsConfig} * exactly -- the only field is `enabled`, no DB layer to overlay onto. */ function parseFairnessAnalyticsConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestFairnessAnalyticsConfig { @@ -4247,6 +4290,7 @@ export const FOCUS_MANIFEST_TOP_LEVEL_FIELDS = [ "maintainerRecap", "ops", "publicStats", + "publicProof", "draftFlow", "upstreamDriftIssues", "sweepWatchdog", @@ -4326,6 +4370,7 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): maintainerRecap: parseMaintainerRecapConfig(record.maintainerRecap, warnings), ops: parseOpsConfig(record.ops, warnings), publicStats: parsePublicStatsConfig(record.publicStats, warnings), + publicProof: parsePublicProofConfig(record.publicProof, warnings), fairnessAnalytics: parseFairnessAnalyticsConfig(record.fairnessAnalytics, warnings), draftFlow: parseDraftFlowConfig(record.draftFlow, warnings), upstreamDriftIssues: parseUpstreamDriftIssuesConfig(record.upstreamDriftIssues, warnings), diff --git a/src/api/proof-badge.ts b/src/api/proof-badge.ts new file mode 100644 index 0000000000..549712bfac --- /dev/null +++ b/src/api/proof-badge.ts @@ -0,0 +1,39 @@ +// README badge for the public proof page (#9569). Renders through the SAME flat-badge primitive and the +// SAME XML escaping as the repo-quality badge (`./badge.ts`), so an unauthenticated, embeddable surface +// cannot become an injection vector and the two badges cannot drift apart visually. +import { escapeXml } from "./badge"; +import { buildProofBadgeColor, buildProofBadgeMessage, type ProofSummary } from "../review/proof-summary"; + +export const PROOF_BADGE_LABEL = "loopover proof"; +const UNAVAILABLE_COLOR = "#9e9e9e"; + +/** `null` renders the neutral unavailable badge — used for both the flag-off 404 and the error 503, since + * from a README's point of view those are the same thing: no claim is being made right now. */ +export function renderProofBadgeSvg(summary: ProofSummary | null): string { + const message = summary ? buildProofBadgeMessage(summary) : "unavailable"; + const color = summary ? buildProofBadgeColor(summary) : UNAVAILABLE_COLOR; + return renderFlatBadge(PROOF_BADGE_LABEL, message, color); +} + +function renderFlatBadge(label: string, message: string, color: string): string { + const labelText = escapeXml(label); + const messageText = escapeXml(message); + const labelWidth = textWidth(label); + const messageWidth = textWidth(message); + const totalWidth = labelWidth + messageWidth; + return [ + ``, + `${labelText}: ${messageText}`, + ``, + ``, + ``, + ``, + `${labelText}`, + `${messageText}`, + ``, + ].join(""); +} + +function textWidth(text: string): number { + return Math.max(40, Math.round(text.length * 6.5) + 10); +} diff --git a/src/api/routes.ts b/src/api/routes.ts index 5da7d96d88..244ec79597 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -306,6 +306,8 @@ 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, publicAnchorStatus, signLedgerAnchorPayload } from "../review/ledger-anchor"; +import { resolveProofPage } from "../review/proof-summary"; +import { renderProofBadgeSvg } from "./proof-badge"; import { ingestBittensorAnchorReport, parseBittensorAnchorReport } from "../review/ledger-anchor-bittensor"; import { loadPublicLedgerAnchors } from "../review/ledger-anchor-persistence"; import { getPublicStats, isPublicStatsEnabled, resolvePublicStatsManifestOverride } from "../review/public-stats"; @@ -1366,6 +1368,41 @@ export function createApp() { }); }); + // #9569: the public proof page's data, and its README badge. Read-only over the SAME public sources the + // standalone endpoints already serve -- no new verification mechanism and no new SQL surface. + // + // Both handlers are thin renderers over ONE resolver (resolveProofPage): the gate, the read and the + // failure outcome are decided there, so the page and the badge cannot disagree about whether a repo is + // published. That is not a stylistic preference -- the gate previously lived inline in both bodies and + // exactly one of them was wired to the per-repo opt-out. + const proofPageDeps = { + loadManifest: loadRepoFocusManifest, + verifyLedger: (env: Env) => verifyDecisionLedger(env), + loadAnchors: (env: Env) => loadPublicLedgerAnchors(env, { limit: 20 }), + }; + + app.get("/v1/public/repos/:owner/:repo/proof", async (c) => { + const result = await resolveProofPage(c.env, `${c.req.param("owner")}/${c.req.param("repo")}`, proofPageDeps); + if (result.status === "disabled") return c.json({ error: "not_found" }, 404); + c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=300"); + return c.json(result.summary); + }); + + // The badge deliberately reports the LEDGER's state rather than an accuracy percentage: a badge is a + // one-glance claim, and an accuracy number without the interval that makes it honest (which does not fit + // in a badge) is exactly the bare scalar the proof summary refuses to publish. A disabled repo renders + // the neutral badge rather than an error -- a broken image in a README is worse than an honest one. + app.get("/v1/public/repos/:owner/:repo/proof-badge.svg", async (c) => { + c.header("Content-Type", "image/svg+xml; charset=utf-8"); + const result = await resolveProofPage(c.env, `${c.req.param("owner")}/${c.req.param("repo")}`, proofPageDeps); + if (result.status === "disabled") { + c.header("Cache-Control", "public, max-age=300"); + return c.body(renderProofBadgeSvg(null), 404); + } + c.header("Cache-Control", "public, max-age=600, stale-while-revalidate=86400"); + return c.body(renderProofBadgeSvg(result.summary)); + }); + // #9277 (epic #9267): the current tip's SIGNED checkpoint, for the operator's off-Worker Bittensor // commitment submitter to fetch and commit on-chain (sha256 of `signingInput` is the exact 32 bytes // `Data::Sha256` holds). Unauthenticated like every /v1/public/* sibling: it is the same payload the diff --git a/src/auth/route-auth.ts b/src/auth/route-auth.ts index 429036fea6..d19456c762 100644 --- a/src/auth/route-auth.ts +++ b/src/auth/route-auth.ts @@ -48,6 +48,11 @@ export function requiresApiToken(path: string): boolean { if (path === "/v1/public/decision-ledger/anchor-key") return false; // #9271: the public anchor-attempt listing, added in the SAME PR as its route. if (path === "/v1/public/decision-ledger/anchors") return false; + // #9569: the public proof page's data and its README badge. Unauthenticated by design -- every figure + // they render is already served unauthenticated by the ledger-verify / anchors / decision-record routes + // above; this is a composition, not a new disclosure. Added in the SAME PR as the routes, per #9120. + if (/^\/v1\/public\/repos\/[^/]+\/[^/]+\/proof$/.test(path)) return false; + if (/^\/v1\/public\/repos\/[^/]+\/[^/]+\/proof-badge\.svg$/.test(path)) return false; // #9277: the current tip's signed checkpoint, for the operator's off-Worker Bittensor submitter (and // anyone else — it is the same payload the Rekor/git backends already publish externally). Added in the // SAME PR as its route, per the #9120 lesson. diff --git a/src/env.d.ts b/src/env.d.ts index d5d95aa1d8..7cfcb2ff59 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -609,6 +609,10 @@ declare global { * worker is byte-identical to today. Exposes review-disposition counts + a reversal-grounded accuracy * percentage + an estimated-time-saved figure ONLY — never PR content, authors, scores, or reward internals. * See review/public-stats.ts. */ + /** #9569: fleet-wide switch for the public proof page (`/proof/:owner/:repo`) and its badge. Default + * OFF like every sibling public surface. A repo can opt OUT via its own manifest, but cannot opt IN + * when this is off -- see isProofPageEnabledForRepo's recorded decision in review/proof-summary.ts. */ + LOOPOVER_PUBLIC_PROOF?: string; LOOPOVER_PUBLIC_STATS?: string; /** Proof of Power (#1059): comma-separated allowlist of repo full-names ("owner/repo") whose OWN historical * review ledger (audit_events "published a review surface" + pull_requests terminal state) counts toward diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 6fa2382965..27e57678af 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -1909,6 +1909,32 @@ export function buildOpenApiSpec() { 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({ + method: "get", + path: "/v1/public/repos/{owner}/{repo}/proof", + operationId: "getPublicRepoProof", + tags: ["Public"], + summary: "Public proof summary for one repo — ledger status, anchor, calibration with coverage and interval, sample records", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, + responses: { + 200: { description: "ProofSummary. Any accuracy figure carries its coverage AND a Wilson confidence interval; below the sample floor it is an explicit `insufficient_data` state, never a bare percentage. Carries the verification-boundary statement in the payload" }, + 404: { description: "The proof page is disabled fleet-wide, or this repo has opted out" }, + 503: { description: "Composition failed — no partial or fabricated summary is served" }, + }, + }); + registry.registerPath({ + method: "get", + path: "/v1/public/repos/{owner}/{repo}/proof-badge.svg", + operationId: "getPublicRepoProofBadge", + tags: ["Public"], + summary: "README badge for the proof page — reports the ledger's state, never a bare accuracy percentage", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, + responses: { + 200: { description: "SVG badge" }, + 404: { description: "Disabled or opted out — still an SVG (a neutral 'unavailable' badge), so a README never shows a broken image" }, + 503: { description: "Same neutral SVG on an internal error" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/public/decision-ledger/anchor-payload", diff --git a/src/review/proof-summary.ts b/src/review/proof-summary.ts new file mode 100644 index 0000000000..f1a36b13d0 --- /dev/null +++ b/src/review/proof-summary.ts @@ -0,0 +1,390 @@ +// Public proof-page summary (#9569) — the shareable, unauthenticated twin of the in-app trust panel. +// +// ONE IMPLEMENTATION, TWO RENDERINGS. This module is the single composition both surfaces read: the public +// proof page here and the in-app per-repo trust panel (#9193) render the SAME object, so the two can never +// disagree about a figure. A number that appears in one and not the other, or differs between them, would +// undermine the exact property this page exists to demonstrate. +// +// ── THE PRIVACY BOUNDARY IS STRUCTURAL ─────────────────────────────────────────────────────────────── +// Every field below is built by NAMING it, never by filtering a wider object. That distinction is the whole +// control: a blocklist has to anticipate every field a future upstream type might grow, and silently leaks +// the one it did not anticipate. An allowlisted shape cannot leak a field nobody wrote down, so wallet, +// hotkey, reward, trust-score and private-ranking data are unreachable here by construction rather than by +// vigilance. `buildProofSummary` takes only already-public inputs and copies named scalars out of them. +// +// ── NEVER A BARE SCALAR ────────────────────────────────────────────────────────────────────────────── +// Any accuracy figure travels with its COVERAGE (how many decisions it is computed over) and a Wilson +// confidence INTERVAL. A "97% accurate" with no denominator is marketing; the same number over 31 decisions +// with a [0.84, 0.99] interval is a claim someone can argue with. Wilson rather than Wald because a gate +// metric lives near p→1, exactly where Wald claims impossible certainty (see `wilsonInterval`'s own note). +// Below the sample floor the accuracy is `null` — no data must render as no claim, never as a fabricated +// zero or a bare percentage. +// +// ── HONEST BOUNDARY STATES ─────────────────────────────────────────────────────────────────────────── +// Not-yet-anchored and no-published-records are NEUTRAL states carrying the verification contract's own +// language, not errors and not blanks. A repo that has not been anchored yet is not a failing repo; a page +// that renders that as an error would be lying in the more damaging direction. +import { wilsonInterval } from "../orb/analytics"; +import type { PublicLedgerAnchor } from "./ledger-anchor-persistence"; + +/** The floor below which an accuracy figure is not published at all. Mirrors the public precision block's + * own discipline: a percentage over a handful of decisions is noise wearing a number's clothes. */ +export const PROOF_MIN_DECISIONS = 20; + +/** How many published decision records the page samples. Enough to be checkable by hand, small enough that + * the page stays a summary rather than a dump. */ +export const PROOF_SAMPLE_RECORDS = 5; + +export type ProofLedgerStatus = + | { state: "verified"; tipSeq: number; totalCount: number; checkedAt: string } + /** `brokenKind` travels alongside the position because the KIND of break is the actionable half: a + * pruned-preimage short tail and a row-hash mismatch are very different claims about this operator. */ + | { state: "broken"; tipSeq: number; totalCount: number; checkedAt: string; brokenAtSeq: number; brokenKind: string } + | { state: "empty"; checkedAt: string } + | { state: "unavailable"; checkedAt: string }; + +export type ProofAnchorStatus = + | { state: "anchored"; backend: string; seq: number; rowHash: string; at: string } + | { state: "not_yet_anchored" }; + +export type ProofAccuracy = + | { state: "published"; accuracy: number; decided: number; confirmed: number; interval: { lo: number; hi: number } } + /** Below the floor. `decided` is still published — "we have 7 decisions, too few to claim a rate" is a + * more honest statement than hiding the count along with the figure. */ + | { state: "insufficient_data"; decided: number; minimumDecisions: number }; + +export type ProofSampleRecord = { pullNumber: number; action: string; reasonCode: string; decidedAt: string; recordDigest: string }; + +export type ProofSummary = { + schemaVersion: 1; + repoFullName: string; + decisionCount: number; + accuracy: ProofAccuracy; + ledger: ProofLedgerStatus; + anchor: ProofAnchorStatus; + sampleRecords: ProofSampleRecord[]; + /** The plain-language boundary statement — what this page does NOT prove. Carried IN the payload so a + * screenshot or an embed cannot shed it the way a footer caption can. */ + boundary: string; +}; + +/** The verification contract's own language for the limit of what anchoring proves (#9420's framing). */ +export const PROOF_BOUNDARY_STATEMENT = + "Anchoring bounds how far back an undetected rewrite could reach; it does not make every row checkable in real time. A rewrite made since the last checkpoint, followed by ordinary appends, is absorbed into future anchors."; + +/** Round to 3dp — the precision the rest of the public surface publishes at. */ +function round3(value: number): number { + return Math.round(value * 1000) / 1000; +} + +/** + * Fold decision counts into a publishable accuracy claim, or into an explicit insufficient-data state. + * + * PURE. `confirmed`/`decided` come from the already-public precision block; nothing new is computed here + * and no new SQL surface exists for this page. + */ +export function buildProofAccuracy(decided: number, confirmed: number, minimumDecisions: number = PROOF_MIN_DECISIONS): ProofAccuracy { + // ONE guard for both reasons a rate is unpublishable, and both arms are reachable: `wilsonInterval` + // returns null exactly when there are no trials, which IS the "nothing decided" case, so checking it + // here rather than pre-empting it with a separate `decided <= 0` test avoids a branch no input can take. + const interval = wilsonInterval(confirmed, decided); + if (!interval || decided < minimumDecisions) return { state: "insufficient_data", decided: Math.max(0, decided), minimumDecisions }; + return { + state: "published", + accuracy: round3(confirmed / decided), + decided, + confirmed, + interval: { lo: round3(interval.lo), hi: round3(interval.hi) }, + }; +} + +/** Project the newest SUCCESSFUL anchor onto the page's anchor state. A failed attempt is not an anchor: + * the public attempt log (#9271) is where failures are legible, and presenting one here as "anchored" + * would claim corroboration that does not exist. */ +export function buildProofAnchorStatus(anchors: readonly PublicLedgerAnchor[]): ProofAnchorStatus { + const succeeded = anchors.filter((anchor) => anchor.status === "ok"); + // The list arrives newest-first; take the newest successful one without assuming the caller sorted. + let newest: PublicLedgerAnchor | undefined; + for (const anchor of succeeded) { + if (!newest || Date.parse(anchor.createdAt) > Date.parse(newest.createdAt)) newest = anchor; + } + if (!newest) return { state: "not_yet_anchored" }; + return { state: "anchored", backend: newest.backend, seq: newest.seq, rowHash: newest.rowHash, at: newest.createdAt }; +} + +/** Project a ledger-verify result onto the page's status. An empty ledger is `empty`, not `verified`: + * "nothing has been decided yet" and "everything checks out" are different claims. */ +export function buildProofLedgerStatus( + verify: { ok: boolean; tipSeq: number; totalCount: number; break?: { kind: string; atSeq: number } | undefined } | null, + checkedAt: string, +): ProofLedgerStatus { + if (!verify) return { state: "unavailable", checkedAt }; + if (verify.totalCount === 0) return { state: "empty", checkedAt }; + if (verify.ok) return { state: "verified", tipSeq: verify.tipSeq, totalCount: verify.totalCount, checkedAt }; + return { + state: "broken", + tipSeq: verify.tipSeq, + totalCount: verify.totalCount, + checkedAt, + // A break with no position still renders as broken; -1 marks "broken, position unknown" rather than + // silently claiming seq 0, which is a real position. Same for an unnamed kind. + brokenAtSeq: verify.break?.atSeq ?? -1, + brokenKind: verify.break?.kind ?? "unknown", + }; +} + +/** + * Compose the public proof summary from already-public inputs. + * + * Every field is copied out BY NAME (see the header's privacy note), so this function cannot leak a field + * a future upstream type grows. `sampleRecords` is bounded and each entry is likewise rebuilt field by + * field rather than spread from a wider record. + */ +export function buildProofSummary(input: { + repoFullName: string; + decisionCount: number; + decided: number; + confirmed: number; + verify: { ok: boolean; tipSeq: number; totalCount: number; break?: { kind: string; atSeq: number } | undefined } | null; + anchors: readonly PublicLedgerAnchor[]; + records: ReadonlyArray<{ pullNumber: number; action: string; reasonCode: string; decidedAt: string; recordDigest: string }>; + checkedAt: string; +}): ProofSummary { + return { + schemaVersion: 1, + repoFullName: input.repoFullName, + decisionCount: input.decisionCount, + accuracy: buildProofAccuracy(input.decided, input.confirmed), + ledger: buildProofLedgerStatus(input.verify, input.checkedAt), + anchor: buildProofAnchorStatus(input.anchors), + sampleRecords: input.records.slice(0, PROOF_SAMPLE_RECORDS).map((record) => ({ + pullNumber: record.pullNumber, + action: record.action, + reasonCode: record.reasonCode, + decidedAt: record.decidedAt, + recordDigest: record.recordDigest, + })), + boundary: PROOF_BOUNDARY_STATEMENT, + }; +} + +/** The proof badge's message — deliberately the LEDGER's state rather than an accuracy percentage. A badge + * is a one-glance claim, and "verified" is a claim this system can actually stand behind at a glance; + * an accuracy number without its interval (which does not fit in a badge) would be exactly the bare + * scalar this module exists to avoid. */ +export function buildProofBadgeMessage(summary: ProofSummary): string { + switch (summary.ledger.state) { + case "verified": + return summary.anchor.state === "anchored" ? "verified · anchored" : "verified"; + case "broken": + return "chain broken"; + case "empty": + return "no decisions yet"; + default: + return "unavailable"; + } +} + +export function buildProofBadgeColor(summary: ProofSummary): string { + switch (summary.ledger.state) { + case "verified": + return summary.anchor.state === "anchored" ? "#3fb950" : "#2da44e"; + case "broken": + return "#f85149"; + // Neutral, not alarming: a repo with nothing decided yet has not failed anything. + default: + return "#9e9e9e"; + } +} + +// ── DECISION: THE PROOF PAGE IS OPT-**OUT**, PER REPO, DEFAULT ON WHEN THE OPERATOR ENABLES IT ──────── +// (#9569 requirement 6 asks for this to be decided and recorded; here it is, next to the code that +// implements it, rather than only in an issue comment that the code could drift away from.) +// +// Opt-OUT rather than opt-in, because every figure this page renders is ALREADY publicly fetchable today: +// `/v1/public/decision-ledger/verify`, `/v1/public/decision-ledger/anchors` and +// `/v1/public/decision-records/...` are unauthenticated by design and were argued as public-safe when they +// shipped. Gating a PAGE over data anyone can already curl would add friction without adding privacy -- +// security theater, and the kind that makes a verification story look less confident than it is. +// +// A per-repo opt-out still exists, because a page is a genuinely different artifact from an API: it is +// discoverable, linkable, indexable, and it markets a repo's numbers whether or not the maintainer wants +// them marketed. "Anyone could assemble this" and "we assembled it for you and gave it a URL" are not the +// same act, so the maintainer keeps a switch. +// +// Two gates, both of which must allow: the operator's fleet-wide flag (default OFF, like every sibling +// public surface), and the repo's own manifest setting (default ON once the operator has opted in). + +export type ProofPageRepoOverride = { present: boolean; enabled: boolean }; + +/** + * Load ONE repo's `publicProof:` opt-out from its own focus manifest. + * + * Read from the TARGET repo's manifest, not the operator's self-repo, because the thing being opted out of + * is that repo's own page -- the opposite precedence from `publicStats:`/`ops:`, which are fleet-wide. + * + * A manifest load failure degrades to `{ present: false }`, i.e. exactly as if no override existed, so a + * network blip or malformed YAML can never accidentally EXPOSE a page the maintainer turned off... which is + * the wrong direction, and is why the caller must treat a failed load as the operator default rather than + * this function pretending to know. Documented here because the failure direction is the interesting part: + * we accept "a broken manifest leaves the page on" in exchange for "a broken manifest never takes a page + * down", matching how every other resolveX accessor in this codebase degrades. + */ +export async function loadProofPageRepoOverride( + env: Env, + repoFullName: string, + loadManifest: (env: Env, repoFullName: string) => Promise<{ publicProof: { present: boolean; enabled: boolean } } | null>, +): Promise { + // try/catch, NOT `.catch()`: a loader that throws SYNCHRONOUSLY (a driver-level failure before it ever + // returns a promise) skips a promise handler entirely, and the throw would escape to the route and 503 a + // public page over a manifest read that is supposed to be optional. Same defect this file already had in + // loadProofSummary's section reads. + let manifest: { publicProof: { present: boolean; enabled: boolean } } | null = null; + try { + manifest = await loadManifest(env, repoFullName); + } catch { + return { present: false, enabled: false }; + } + if (!manifest?.publicProof?.present) return { present: false, enabled: false }; + return { present: true, enabled: manifest.publicProof.enabled }; +} + +/** Fleet-wide operator flag -- truthy-string, default OFF, matching isPublicStatsEnabled's convention. */ +export function isPublicProofPageEnabled(env: { LOOPOVER_PUBLIC_PROOF?: string | undefined }): boolean { + return /^(1|true|yes|on)$/i.test(env.LOOPOVER_PUBLIC_PROOF ?? ""); +} + +/** + * The effective gate for ONE repo: the operator flag AND the repo's own opt-out. + * + * An absent repo override means ON (opt-out, per the decision above). An operator flag that is off wins + * outright -- a repo cannot opt INTO a surface the operator has not enabled, which keeps the fleet-wide + * switch a real switch rather than a default a repo can override. + */ +export function isProofPageEnabledForRepo( + env: { LOOPOVER_PUBLIC_PROOF?: string | undefined }, + repoOverride?: ProofPageRepoOverride | undefined, +): boolean { + if (!isPublicProofPageEnabled(env)) return false; + return repoOverride?.present ? repoOverride.enabled : true; +} + +/** + * Load one repo's proof summary from the SAME public sources the standalone endpoints serve. + * + * No new SQL surface and no new verification mechanism (#9569's own boundary): the decision counts come + * from `decision_records`, the chain status from the shared `verifyDecisionLedger`, the anchor from the + * public attempt log, and the samples from records already published individually. Every read is wrapped + * so one failing section degrades to its honest neutral state rather than failing the whole page -- + * matching `loadPublicRulePrecision`'s own fail-safe-per-section contract. + */ +export async function loadProofSummary( + env: Env, + repoFullName: string, + deps: { + verifyLedger: (env: Env) => Promise<{ ok: boolean; tipSeq: number; totalCount: number; break?: { kind: string; atSeq: number } | undefined }>; + loadAnchors: (env: Env) => Promise<{ anchors: PublicLedgerAnchor[] }>; + now?: () => string; + }, +): Promise { + const checkedAt = (deps.now ?? (() => new Date().toISOString()))(); + + // `.catch()` alone is NOT enough here: `DB.prepare()` throws SYNCHRONOUSLY on a driver-level failure, so + // the rejection never reaches a promise handler and the whole page 503s instead of degrading. Each + // section is therefore wrapped in a real try/catch. That is the difference between the fail-safe-per- + // section contract being documented and it being true. + const section = async (read: () => Promise, fallback: T): Promise => { + try { + return await read(); + } catch { + return fallback; + } + }; + + const counts = await section( + () => + env.DB.prepare( + `SELECT COUNT(*) AS decisionCount, + SUM(CASE WHEN action IN ('merge', 'close') THEN 1 ELSE 0 END) AS decided, + SUM(CASE WHEN action IN ('merge', 'close') AND reason_code NOT LIKE 'reversal%' THEN 1 ELSE 0 END) AS confirmed + FROM decision_records WHERE repo_full_name = ?`, + ) + .bind(repoFullName) + .first<{ decisionCount: number | null; decided: number | null; confirmed: number | null }>(), + null, + ); + + const records = await section( + async () => + ( + await env.DB.prepare( + `SELECT pull_number AS pullNumber, action, reason_code AS reasonCode, created_at AS decidedAt, record_digest AS recordDigest + FROM decision_records WHERE repo_full_name = ? ORDER BY created_at DESC LIMIT ?`, + ) + .bind(repoFullName, PROOF_SAMPLE_RECORDS) + .all<{ pullNumber: number; action: string; reasonCode: string; decidedAt: string; recordDigest: string }>() + ).results ?? [], + [] as Array<{ pullNumber: number; action: string; reasonCode: string; decidedAt: string; recordDigest: string }>, + ); + + const verify = await section(() => deps.verifyLedger(env), null); + const anchors = await section(async () => (await deps.loadAnchors(env)).anchors, [] as PublicLedgerAnchor[]); + + return buildProofSummary({ + repoFullName, + // SUM/COUNT over an empty table yield NULL/0 respectively -- both nullish arms are real and both + // degrade to 0, which renders as the honest "no decisions yet" state rather than a fabricated rate. + decisionCount: counts?.decisionCount ?? 0, + decided: counts?.decided ?? 0, + confirmed: counts?.confirmed ?? 0, + verify, + anchors, + records, + checkedAt, + }); +} + +/** Everything the proof surfaces read, injected as one bag. The routes bind the real implementations; tests + * bind failing ones, which is what makes the unavailable path a tested outcome rather than a hoped-for one. */ +export type ProofPageDeps = { + loadManifest: (env: Env, repoFullName: string) => Promise<{ publicProof: { present: boolean; enabled: boolean } } | null>; + verifyLedger: (env: Env) => Promise<{ ok: boolean; tipSeq: number; totalCount: number; break?: { kind: string; atSeq: number } | undefined }>; + loadAnchors: (env: Env) => Promise<{ anchors: PublicLedgerAnchor[] }>; + now?: (() => string) | undefined; +}; + +/** The two outcomes both proof surfaces share. The JSON route and the badge route render them differently; + * deciding them is not theirs to duplicate. + * + * There is deliberately NO `unavailable` case. `loadProofSummary` is TOTAL: every read it performs is + * wrapped per section, so a failing ledger, anchor or decision-record read degrades to that section's + * honest neutral state and the page still composes. Carrying a 503 outcome would mean carrying a branch + * no input can reach -- dead code that a test can only reach by faking a dependency contract violation, + * which proves nothing about the system. If a future read is ever added outside that per-section + * discipline, the fix is to wrap it there (where the honest-degradation tests live), not to reintroduce a + * catch-all here that would silently turn a partial page into a blank 503. */ +export type ProofPageResult = + | { status: "ok"; summary: ProofSummary } + | { status: "disabled" }; + +/** + * Resolve what a proof surface should serve for one repo: the gate, the read, and the failure outcome. + * + * This exists as ONE function because the alternative already bit us: the gate lived inline in two route + * bodies, and exactly one of them was wired to the per-repo opt-out while the other silently was not. A + * shared resolver makes "the badge and the page agree about whether this repo is published" true by + * construction rather than by two call sites remembering the same thing. + * + * The override is resolved FIRST, so a repo that turned its page off never has its decision records queried + * to build a summary that would be discarded. + */ +export async function resolveProofPage(env: Env, repoFullName: string, deps: ProofPageDeps): Promise { + const override = await loadProofPageRepoOverride(env, repoFullName, deps.loadManifest); + if (!isProofPageEnabledForRepo(env, override)) return { status: "disabled" }; + const summary = await loadProofSummary(env, repoFullName, { + verifyLedger: deps.verifyLedger, + loadAnchors: deps.loadAnchors, + ...(deps.now ? { now: deps.now } : {}), + }); + return { status: "ok", summary }; +} diff --git a/src/signals/focus-manifest-loader.ts b/src/signals/focus-manifest-loader.ts index 021ac9f517..36b529848c 100644 --- a/src/signals/focus-manifest-loader.ts +++ b/src/signals/focus-manifest-loader.ts @@ -2,7 +2,7 @@ import { listSignalSnapshots, persistSignalSnapshot } from "../db/repositories"; import { mapWithConcurrency } from "../queue/map-with-concurrency"; import type { JsonValue } from "../types"; import { nowIso } from "../utils/json"; -import { contentLaneConfigToJson, experimentalConfigToJson, featuresConfigToJson, gateConfigToJson, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, repoDocGenerationConfigToJson, reviewConfigToJson, reviewRecapConfigToJson, maintainerRecapConfigToJson, opsConfigToJson, publicStatsConfigToJson, fairnessAnalyticsConfigToJson, draftFlowConfigToJson, upstreamDriftIssuesConfigToJson, sweepWatchdogConfigToJson, prReconciliationConfigToJson, activeReviewReconciliationConfigToJson, loopEscalationConfigToJson, federatedIntelligenceConfigToJson, settingsOverrideToJson, type FocusManifest, type FocusManifestSource, type RepoReviewContext } from "./focus-manifest"; +import { contentLaneConfigToJson, experimentalConfigToJson, featuresConfigToJson, gateConfigToJson, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, repoDocGenerationConfigToJson, reviewConfigToJson, reviewRecapConfigToJson, maintainerRecapConfigToJson, opsConfigToJson, publicStatsConfigToJson, publicProofConfigToJson, fairnessAnalyticsConfigToJson, draftFlowConfigToJson, upstreamDriftIssuesConfigToJson, sweepWatchdogConfigToJson, prReconciliationConfigToJson, activeReviewReconciliationConfigToJson, loopEscalationConfigToJson, federatedIntelligenceConfigToJson, settingsOverrideToJson, type FocusManifest, type FocusManifestSource, type RepoReviewContext } from "./focus-manifest"; import { LOOPOVER_REPO_FOCUS_MANIFEST_YAML, resolveLoopOverSelfRepoFullName } from "../config/loopover-repo-focus-manifest"; import type { LocalManifestLoadResult } from "../selfhost/private-config"; @@ -337,6 +337,7 @@ function manifestToJson(manifest: FocusManifest): Record { maintainerRecap: maintainerRecapConfigToJson(manifest.maintainerRecap), ops: opsConfigToJson(manifest.ops), publicStats: publicStatsConfigToJson(manifest.publicStats), + publicProof: publicProofConfigToJson(manifest.publicProof), fairnessAnalytics: fairnessAnalyticsConfigToJson(manifest.fairnessAnalytics), draftFlow: draftFlowConfigToJson(manifest.draftFlow), upstreamDriftIssues: upstreamDriftIssuesConfigToJson(manifest.upstreamDriftIssues), diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 4f5e81c3f1..4026164e76 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -37,6 +37,7 @@ export { maintainerRecapConfigToJson, opsConfigToJson, publicStatsConfigToJson, + publicProofConfigToJson, fairnessAnalyticsConfigToJson, draftFlowConfigToJson, upstreamDriftIssuesConfigToJson, diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 5aab97263c..03bbe5f32a 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -83,6 +83,44 @@ const FULL_MANIFEST = { publicNotes: ["Prefer small, focused PRs."], }; +describe("publicProof manifest block (#9569)", () => { + it("parses an explicit block, defaults `enabled` to false, and warns on a non-mapping instead of throwing", async () => { + const { parseFocusManifest, publicProofConfigToJson } = await import("../../src/signals/focus-manifest"); + + expect(parseFocusManifest({ publicProof: { enabled: false } }).publicProof).toEqual({ present: true, enabled: false }); + expect(parseFocusManifest({ publicProof: { enabled: true } }).publicProof).toEqual({ present: true, enabled: true }); + // A present block with no `enabled` key is present-but-false, not absent -- the resolver keys on + // `present`, so the distinction is load-bearing. + expect(parseFocusManifest({ publicProof: {} }).publicProof).toEqual({ present: true, enabled: false }); + // Absent entirely. + expect(parseFocusManifest({ wantedPaths: ["src/**"] }).publicProof).toEqual({ present: false, enabled: false }); + + // A scalar or a list is a config mistake: warned about and ignored, never a thrown parse. + for (const bad of ["nonsense", ["a"], 42]) { + const parsed = parseFocusManifest({ publicProof: bad }); + expect(parsed.publicProof).toEqual({ present: false, enabled: false }); + expect(parsed.warnings.some((warning) => warning.includes('Manifest field "publicProof" must be a mapping'))).toBe(true); + } + + // Round-trips through the snapshot serializer, so a cached manifest re-parses identically. + expect(publicProofConfigToJson({ present: false, enabled: false })).toBeNull(); + expect(publicProofConfigToJson({ present: true, enabled: false })).toEqual({ enabled: false }); + expect(publicProofConfigToJson({ present: true, enabled: true })).toEqual({ enabled: true }); + const roundTripped = parseFocusManifest({ publicProof: publicProofConfigToJson({ present: true, enabled: false }) }); + expect(roundTripped.publicProof).toEqual({ present: true, enabled: false }); + }); + + it("REGRESSION: publicProof is a KNOWN top-level field — otherwise its warning rewrites the review comment", async () => { + const { parseFocusManifest } = await import("../../src/signals/focus-manifest"); + // The writer/reader split behind #9569's regate churn: the loader serializes this field into the + // persisted snapshot, so a reader that did not recognize it warned on every reload, and that warning + // rewrote the published comment on every regate sweep (the #3379 class). + const parsed = parseFocusManifest({ publicProof: { enabled: false } }); + expect(parsed.warnings.some((warning) => warning.includes("unknown top-level field"))).toBe(false); + }); +}); + + describe("parseFocusManifest", () => { it("normalizes a fully specified manifest", () => { const manifest = parseFocusManifest(FULL_MANIFEST); @@ -971,6 +1009,7 @@ describe("compileFocusManifestPolicy", () => { maintainerRecap: { present: false, enabled: false, cadence: "weekly", channel: "discord" }, ops: { present: false, enabled: false }, publicStats: { present: false, enabled: false }, + publicProof: { present: false, enabled: false }, fairnessAnalytics: { present: false, enabled: false }, draftFlow: { present: false, enabled: false }, upstreamDriftIssues: { present: false, enabled: false }, diff --git a/test/unit/proof-summary.test.ts b/test/unit/proof-summary.test.ts new file mode 100644 index 0000000000..c148734750 --- /dev/null +++ b/test/unit/proof-summary.test.ts @@ -0,0 +1,444 @@ +import { describe, expect, it } from "vitest"; +import { + buildProofAccuracy, + buildProofAnchorStatus, + buildProofBadgeColor, + buildProofBadgeMessage, + buildProofLedgerStatus, + buildProofSummary, + isProofPageEnabledForRepo, + isPublicProofPageEnabled, + loadProofSummary, + PROOF_BOUNDARY_STATEMENT, + PROOF_MIN_DECISIONS, + PROOF_SAMPLE_RECORDS, +} from "../../src/review/proof-summary"; +import { renderProofBadgeSvg } from "../../src/api/proof-badge"; +import { createApp } from "../../src/api/routes"; +import { appendDecisionLedger, persistDecisionRecord } from "../../src/review/decision-record"; +import { loadPublicLedgerAnchors, recordLedgerAnchorAttempt } from "../../src/review/ledger-anchor-persistence"; +import { createTestEnv } from "../helpers/d1"; +import { loadProofPageRepoOverride, resolveProofPage, type ProofPageDeps } from "../../src/review/proof-summary"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; +import type { PublicLedgerAnchor } from "../../src/review/ledger-anchor-persistence"; + +// #9569: the public, shareable twin of the in-app trust panel. The properties that matter here are the ones +// that would quietly turn a verification page into a marketing page: an accuracy figure without its +// denominator and interval, a failed anchor attempt presented as an anchor, an empty ledger presented as +// "verified", or a field nobody wrote down leaking through a spread. + +const CHECKED_AT = "2026-07-28T12:00:00.000Z"; + +function anchor(overrides: Partial = {}): PublicLedgerAnchor { + return { + id: "a1", + seq: 10, + rowHash: "f".repeat(64), + keyId: "k1", + backend: "rekor", + backendRef: { uuid: "u" }, + status: "ok", + error: null, + createdAt: "2026-07-20T00:00:00.000Z", + ...overrides, + }; +} + +describe("buildProofAccuracy — never a bare scalar (#9569)", () => { + it("publishes accuracy ONLY with its coverage and a Wilson interval", () => { + const accuracy = buildProofAccuracy(60, 59); + expect(accuracy.state).toBe("published"); + if (accuracy.state !== "published") return; + expect(accuracy).toMatchObject({ accuracy: 0.983, decided: 60, confirmed: 59 }); + // Wilson, not Wald: at 59/60 Wald would claim an upper bound of ~1.017 (impossible) and a lower bound + // that overstates certainty. Wilson stays inside [0,1] and keeps an honest lower bound. + expect(accuracy.interval.lo).toBeGreaterThan(0.9); + expect(accuracy.interval.lo).toBeLessThan(accuracy.accuracy); + expect(accuracy.interval.hi).toBeLessThanOrEqual(1); + }); + + it("REGRESSION: below the sample floor there is NO rate — but the count is still published", () => { + const thin = buildProofAccuracy(PROOF_MIN_DECISIONS - 1, PROOF_MIN_DECISIONS - 1); + expect(thin).toEqual({ state: "insufficient_data", decided: PROOF_MIN_DECISIONS - 1, minimumDecisions: PROOF_MIN_DECISIONS }); + // A perfect record over 19 decisions must NOT render as 100% — that is the exact bare-scalar claim + // this module exists to refuse. + expect(JSON.stringify(thin)).not.toContain("accuracy"); + // Zero and negative both degrade to a non-negative count rather than a fabricated rate. + expect(buildProofAccuracy(0, 0)).toMatchObject({ state: "insufficient_data", decided: 0 }); + expect(buildProofAccuracy(-5, 0)).toMatchObject({ state: "insufficient_data", decided: 0 }); + // Exactly at the floor publishes. + expect(buildProofAccuracy(PROOF_MIN_DECISIONS, PROOF_MIN_DECISIONS).state).toBe("published"); + }); +}); + +describe("buildProofAnchorStatus (#9569)", () => { + it("REGRESSION: a FAILED attempt is not an anchor — presenting one would claim corroboration that does not exist", () => { + expect(buildProofAnchorStatus([anchor({ status: "failed", error: "rekor 429" })])).toEqual({ state: "not_yet_anchored" }); + expect(buildProofAnchorStatus([])).toEqual({ state: "not_yet_anchored" }); + }); + + it("picks the NEWEST successful anchor regardless of list order", () => { + const older = anchor({ id: "old", seq: 5, createdAt: "2026-07-01T00:00:00.000Z" }); + const newer = anchor({ id: "new", seq: 12, createdAt: "2026-07-25T00:00:00.000Z", backend: "git" }); + const failedNewest = anchor({ id: "bad", seq: 13, createdAt: "2026-07-27T00:00:00.000Z", status: "failed", error: "boom" }); + for (const order of [[older, newer, failedNewest], [failedNewest, newer, older]]) { + expect(buildProofAnchorStatus(order)).toEqual({ state: "anchored", backend: "git", seq: 12, rowHash: "f".repeat(64), at: "2026-07-25T00:00:00.000Z" }); + } + }); +}); + +describe("buildProofLedgerStatus — honest boundary states (#9569)", () => { + it("REGRESSION: an EMPTY ledger is `empty`, not `verified` — different claims", () => { + expect(buildProofLedgerStatus({ ok: true, tipSeq: 0, totalCount: 0 }, CHECKED_AT)).toEqual({ state: "empty", checkedAt: CHECKED_AT }); + }); + + it("verified, broken (with kind and position), and unavailable each render distinctly", () => { + expect(buildProofLedgerStatus({ ok: true, tipSeq: 9, totalCount: 9 }, CHECKED_AT)).toEqual({ + state: "verified", tipSeq: 9, totalCount: 9, checkedAt: CHECKED_AT, + }); + expect(buildProofLedgerStatus({ ok: false, tipSeq: 9, totalCount: 9, break: { kind: "row_hash_mismatch", atSeq: 4 } }, CHECKED_AT)).toEqual({ + state: "broken", tipSeq: 9, totalCount: 9, checkedAt: CHECKED_AT, brokenAtSeq: 4, brokenKind: "row_hash_mismatch", + }); + // A break with no detail is still broken, marked unknown rather than silently claiming seq 0. + expect(buildProofLedgerStatus({ ok: false, tipSeq: 9, totalCount: 9 }, CHECKED_AT)).toMatchObject({ brokenAtSeq: -1, brokenKind: "unknown" }); + // A failed read is `unavailable` — not "broken", which would accuse the operator of tampering. + expect(buildProofLedgerStatus(null, CHECKED_AT)).toEqual({ state: "unavailable", checkedAt: CHECKED_AT }); + }); +}); + +describe("buildProofSummary — the structural privacy boundary (#9569)", () => { + it("REGRESSION: fields nobody named cannot travel, even when the inputs carry them", () => { + const summary = buildProofSummary({ + repoFullName: "o/r", + decisionCount: 30, + decided: 30, + confirmed: 29, + verify: { ok: true, tipSeq: 30, totalCount: 30 }, + anchors: [anchor()], + records: [ + { + pullNumber: 1, action: "merge", reasonCode: "clean", decidedAt: CHECKED_AT, recordDigest: "d".repeat(64), + // Hostile extras — exactly the classes the privacy boundary names. A spread-based composition + // would carry every one of these onto an unauthenticated page. + hotkey: "5FHneW46xGXgs5mUiveU4sbTyGBz", walletAddress: "0xdead", rewardTao: 42, trustScore: 0.9, privateRank: 3, + } as never, + ], + checkedAt: CHECKED_AT, + }); + const serialized = JSON.stringify(summary); + for (const forbidden of ["hotkey", "walletAddress", "rewardTao", "trustScore", "privateRank", "0xdead", "5FHneW46"]) { + expect(serialized).not.toContain(forbidden); + } + // The named fields DID come through, so the test is proving allowlisting rather than an empty object. + expect(summary.sampleRecords[0]).toEqual({ pullNumber: 1, action: "merge", reasonCode: "clean", decidedAt: CHECKED_AT, recordDigest: "d".repeat(64) }); + }); + + it("bounds the sample and carries the boundary statement IN the payload", () => { + const many = Array.from({ length: 25 }, (_, index) => ({ + pullNumber: index, action: "merge", reasonCode: "clean", decidedAt: CHECKED_AT, recordDigest: String(index).repeat(2), + })); + const summary = buildProofSummary({ + repoFullName: "o/r", decisionCount: 25, decided: 25, confirmed: 25, + verify: { ok: true, tipSeq: 25, totalCount: 25 }, anchors: [], records: many, checkedAt: CHECKED_AT, + }); + expect(summary.sampleRecords).toHaveLength(PROOF_SAMPLE_RECORDS); + // The caveat travels IN the payload, so a screenshot or embed cannot shed it the way a footer can. + expect(summary.boundary).toBe(PROOF_BOUNDARY_STATEMENT); + expect(summary.boundary).toContain("does not make every row checkable in real time"); + }); +}); + +describe("proof badge (#9569)", () => { + const summaryWith = (ledger: Parameters[0]["ledger"], anchored: boolean) => + ({ ledger, anchor: anchored ? { state: "anchored" } : { state: "not_yet_anchored" } }) as Parameters[0]; + + it("reports the LEDGER state, never a bare accuracy percentage", () => { + expect(buildProofBadgeMessage(summaryWith({ state: "verified", tipSeq: 1, totalCount: 1, checkedAt: CHECKED_AT }, true))).toBe("verified · anchored"); + expect(buildProofBadgeMessage(summaryWith({ state: "verified", tipSeq: 1, totalCount: 1, checkedAt: CHECKED_AT }, false))).toBe("verified"); + expect(buildProofBadgeMessage(summaryWith({ state: "broken", tipSeq: 1, totalCount: 1, checkedAt: CHECKED_AT, brokenAtSeq: 1, brokenKind: "k" }, false))).toBe("chain broken"); + expect(buildProofBadgeMessage(summaryWith({ state: "empty", checkedAt: CHECKED_AT }, false))).toBe("no decisions yet"); + expect(buildProofBadgeMessage(summaryWith({ state: "unavailable", checkedAt: CHECKED_AT }, false))).toBe("unavailable"); + }); + + it("colors a not-yet-decided repo NEUTRALLY — it has not failed anything", () => { + expect(buildProofBadgeColor(summaryWith({ state: "empty", checkedAt: CHECKED_AT }, false))).toBe("#9e9e9e"); + expect(buildProofBadgeColor(summaryWith({ state: "unavailable", checkedAt: CHECKED_AT }, false))).toBe("#9e9e9e"); + expect(buildProofBadgeColor(summaryWith({ state: "broken", tipSeq: 1, totalCount: 1, checkedAt: CHECKED_AT, brokenAtSeq: 1, brokenKind: "k" }, false))).toBe("#f85149"); + expect(buildProofBadgeColor(summaryWith({ state: "verified", tipSeq: 1, totalCount: 1, checkedAt: CHECKED_AT }, true))).toBe("#3fb950"); + expect(buildProofBadgeColor(summaryWith({ state: "verified", tipSeq: 1, totalCount: 1, checkedAt: CHECKED_AT }, false))).toBe("#2da44e"); + }); + + it("renders valid SVG for both the summary and the null (unavailable) case, escaping its text", () => { + const svg = renderProofBadgeSvg(summaryWith({ state: "verified", tipSeq: 1, totalCount: 1, checkedAt: CHECKED_AT }, true)); + expect(svg.startsWith("]*>[^<]*[<>][^<]*<\/text>/); + }); +}); + +describe("the opt-OUT gate and its recorded default (#9569 requirement 6)", () => { + it("the operator flag is OFF by default and truthy-string parsed", () => { + expect(isPublicProofPageEnabled({})).toBe(false); + expect(isPublicProofPageEnabled({ LOOPOVER_PUBLIC_PROOF: "" })).toBe(false); + expect(isPublicProofPageEnabled({ LOOPOVER_PUBLIC_PROOF: "false" })).toBe(false); + for (const on of ["1", "true", "yes", "on", "TRUE"]) { + expect(isPublicProofPageEnabled({ LOOPOVER_PUBLIC_PROOF: on })).toBe(true); + } + }); + + it("REGRESSION: a repo defaults ON once the operator opts in, and can opt OUT — but never opt IN alone", () => { + const on = { LOOPOVER_PUBLIC_PROOF: "true" }; + // Default ON: the data is already public, so a page over it needs no second opt-in. + expect(isProofPageEnabledForRepo(on)).toBe(true); + expect(isProofPageEnabledForRepo(on, { present: false, enabled: false })).toBe(true); + // The repo's opt-out is honored. + expect(isProofPageEnabledForRepo(on, { present: true, enabled: false })).toBe(false); + expect(isProofPageEnabledForRepo(on, { present: true, enabled: true })).toBe(true); + // The operator flag wins outright — a repo cannot opt INTO a surface the fleet has not enabled, which + // is what keeps the fleet-wide switch a real switch. + expect(isProofPageEnabledForRepo({}, { present: true, enabled: true })).toBe(false); + }); +}); + +describe("loadProofSummary + routes (#9569)", () => { + async function seeded() { + const env = createTestEnv({ LOOPOVER_PUBLIC_PROOF: "true" }); + for (let index = 1; index <= 3; index += 1) { + await persistDecisionRecord( + env, + { + schemaVersion: "5", repoFullName: "o/r", pullNumber: index, headSha: `sha${index}`, baseSha: null, + action: "merge", reasonCode: "clean", configDigest: "c", settingsDigest: "s", gatePack: "oss-anti-slop", + ciState: "success", modelIds: null, promptDigest: null, aiConfidence: null, aiAgreement: null, + salvageability: null, divertedByHoldout: false, decidedAt: CHECKED_AT, + } as never, + `${index}`.repeat(64), + ); + await appendDecisionLedger(env, `record:o/r#${index}@sha${index}`, `${index}`.repeat(64)); + } + return env; + } + + it("composes from the real tables and degrades per section rather than failing the page", async () => { + const env = await seeded(); + const summary = await loadProofSummary(env, "o/r", { + verifyLedger: async () => ({ ok: true, tipSeq: 3, totalCount: 3 }), + // A failing anchor read must degrade to not_yet_anchored, not blow up the page. + loadAnchors: async () => { throw new Error("d1 down"); }, + now: () => CHECKED_AT, + }); + expect(summary.decisionCount).toBe(3); + expect(summary.anchor).toEqual({ state: "not_yet_anchored" }); + expect(summary.ledger).toMatchObject({ state: "verified", totalCount: 3 }); + // Three decisions is under the floor — no rate is claimed. + expect(summary.accuracy.state).toBe("insufficient_data"); + expect(summary.sampleRecords.length).toBeGreaterThan(0); + // A failing LEDGER read likewise degrades to unavailable rather than throwing. + const degraded = await loadProofSummary(env, "o/r", { + verifyLedger: async () => { throw new Error("d1 down"); }, + loadAnchors: async () => ({ anchors: [] }), + now: () => CHECKED_AT, + }); + expect(degraded.ledger).toEqual({ state: "unavailable", checkedAt: CHECKED_AT }); + }); + + it("INVARIANT: every DB section degrades independently — a failing read never fabricates a figure", async () => { + const env = createTestEnv({ LOOPOVER_PUBLIC_PROOF: "true" }); + // Both decision-record reads throw. The page must still compose, reporting zero decisions and no rate, + // rather than surfacing a partial or invented number on an unauthenticated marketing surface. + (env.DB as unknown as { prepare: () => never }).prepare = () => { + throw new Error("d1 down"); + }; + const summary = await loadProofSummary(env, "o/r", { + verifyLedger: async () => ({ ok: true, tipSeq: 0, totalCount: 0 }), + loadAnchors: async () => ({ anchors: [] }), + now: () => CHECKED_AT, + }); + expect(summary).toMatchObject({ decisionCount: 0, sampleRecords: [], ledger: { state: "empty" } }); + expect(summary.accuracy).toMatchObject({ state: "insufficient_data", decided: 0 }); + }); + + it("a driver returning no `results` array degrades to an empty sample rather than throwing", async () => { + const env = createTestEnv({ LOOPOVER_PUBLIC_PROOF: "true" }); + // A D1 driver that resolves without a `results` key — the `?? []` arm, which a real outage or a driver + // version change can produce and which must not take the page down. + (env.DB as unknown as { prepare: (sql: string) => unknown }).prepare = () => ({ + bind: () => ({ + first: async () => null, + all: async () => ({}), + }), + }); + const summary = await loadProofSummary(env, "o/r", { + verifyLedger: async () => ({ ok: true, tipSeq: 0, totalCount: 0 }), + loadAnchors: async () => ({ anchors: [] }), + now: () => CHECKED_AT, + }); + expect(summary.sampleRecords).toEqual([]); + expect(summary.decisionCount).toBe(0); + }); + + it("an unknown repo yields the honest empty page rather than a 404 or a fabricated rate", async () => { + const env = await seeded(); + const summary = await loadProofSummary(env, "nobody/nothing", { + verifyLedger: async () => ({ ok: true, tipSeq: 0, totalCount: 0 }), + loadAnchors: async () => ({ anchors: [] }), + now: () => CHECKED_AT, + }); + expect(summary).toMatchObject({ decisionCount: 0, ledger: { state: "empty" }, anchor: { state: "not_yet_anchored" } }); + expect(summary.accuracy).toMatchObject({ state: "insufficient_data", decided: 0 }); + expect(summary.sampleRecords).toEqual([]); + }); + + it("REGRESSION: both routes 404 while the flag is OFF, and serve once it is on", async () => { + const app = createApp(); + const off = createTestEnv(); + expect((await app.request("/v1/public/repos/o/r/proof", {}, off)).status).toBe(404); + const badgeOff = await app.request("/v1/public/repos/o/r/proof-badge.svg", {}, off); + expect(badgeOff.status).toBe(404); + // Even the 404 renders a real badge — a broken image in a README is worse than an honest neutral one. + expect(await badgeOff.text()).toContain("unavailable"); + expect(badgeOff.headers.get("content-type")).toContain("image/svg+xml"); + + const on = await seeded(); + const proof = await app.request("/v1/public/repos/o/r/proof", {}, on); + expect(proof.status).toBe(200); + const body = (await proof.json()) as { repoFullName: string; boundary: string; decisionCount: number }; + expect(body).toMatchObject({ repoFullName: "o/r", decisionCount: 3 }); + expect(body.boundary).toBe(PROOF_BOUNDARY_STATEMENT); + expect(proof.headers.get("cache-control")).toContain("max-age=60"); + + const badge = await app.request("/v1/public/repos/o/r/proof-badge.svg", {}, on); + expect(badge.status).toBe(200); + expect(await badge.text()).toContain(" { + const env = await seeded(); + await recordLedgerAnchorAttempt(env, { + payload: { v: 1, ledger: "loopover.decision_ledger", seq: 3, rowHash: "a".repeat(64), totalCount: 3, at: CHECKED_AT }, + signature: "sig", keyId: "k1", backend: "rekor", status: "ok", backendRef: { uuid: "u" }, proofR2Key: null, + }); + const summary = await loadProofSummary(env, "o/r", { + verifyLedger: async () => ({ ok: true, tipSeq: 3, totalCount: 3 }), + loadAnchors: (target) => loadPublicLedgerAnchors(target, {}), + now: () => CHECKED_AT, + }); + expect(summary.anchor).toMatchObject({ state: "anchored", backend: "rekor", seq: 3 }); + }); + + it("REGRESSION: a repo that opts OUT in its manifest gets 404 from BOTH routes, even with the fleet flag on", async () => { + // The defect this test exists for: both handlers called isProofPageEnabledForRepo(c.env) with no + // override, so the documented per-repo opt-out was never loaded and every repo was effectively + // opt-out-less once the operator flag was on. + const app = createApp(); + const env = await seeded(); + await upsertRepoFocusManifest(env, "o/r", { publicProof: { enabled: false } } as never); + + expect((await app.request("/v1/public/repos/o/r/proof", {}, env)).status).toBe(404); + const badge = await app.request("/v1/public/repos/o/r/proof-badge.svg", {}, env); + expect(badge.status).toBe(404); + expect(await badge.text()).toContain("unavailable"); + + // A DIFFERENT repo in the same fleet is unaffected — the opt-out is per repo, not a kill switch. + expect((await app.request("/v1/public/repos/other/repo/proof", {}, env)).status).toBe(200); + }); + + it("an explicit manifest opt-IN serves, and so does a repo with no manifest block at all (opt-out default)", async () => { + const app = createApp(); + const env = await seeded(); + await upsertRepoFocusManifest(env, "o/r", { publicProof: { enabled: true } } as never); + expect((await app.request("/v1/public/repos/o/r/proof", {}, env)).status).toBe(200); + // No block at all: the page is on, because the data is already public and this is opt-OUT. + const bare = await seeded(); + expect((await app.request("/v1/public/repos/o/r/proof", {}, bare)).status).toBe(200); + }); + + it("loadProofPageRepoOverride: absent block, explicit values, and a failing load all resolve honestly", async () => { + const env = createTestEnv(); + expect(await loadProofPageRepoOverride(env, "o/r", async () => null)).toEqual({ present: false, enabled: false }); + expect(await loadProofPageRepoOverride(env, "o/r", async () => ({ publicProof: { present: false, enabled: false } }))).toEqual({ present: false, enabled: false }); + expect(await loadProofPageRepoOverride(env, "o/r", async () => ({ publicProof: { present: true, enabled: false } }))).toEqual({ present: true, enabled: false }); + expect(await loadProofPageRepoOverride(env, "o/r", async () => ({ publicProof: { present: true, enabled: true } }))).toEqual({ present: true, enabled: true }); + // A failing manifest load degrades to "no override" -- a broken manifest never takes a page DOWN. + expect(await loadProofPageRepoOverride(env, "o/r", async () => { throw new Error("network down"); })).toEqual({ present: false, enabled: false }); + // REGRESSION: a loader that throws SYNCHRONOUSLY (a driver-level failure before it returns a promise) + // skips a `.catch()` entirely -- the earlier implementation let that escape to the route and 503 a + // public page over an optional manifest read. + const syncThrow = (() => { throw new Error("d1 down"); }) as unknown as Parameters[2]; + expect(await loadProofPageRepoOverride(env, "o/r", syncThrow)).toEqual({ present: false, enabled: false }); + }); + + it("resolveProofPage: the gate outcomes, and a TOTAL composition that degrades instead of failing", async () => { + const env = await seeded(); + const deps: ProofPageDeps = { + loadManifest: async () => null, + verifyLedger: async () => ({ ok: true, tipSeq: 3, totalCount: 3 }), + loadAnchors: async () => ({ anchors: [] }), + now: () => CHECKED_AT, + }; + + const ok = await resolveProofPage(env, "o/r", deps); + expect(ok.status).toBe("ok"); + if (ok.status === "ok") expect(ok.summary.decisionCount).toBe(3); + + // Fleet flag off ⇒ disabled. + expect(await resolveProofPage(createTestEnv(), "o/r", deps)).toEqual({ status: "disabled" }); + + // Per-repo opt-out ⇒ disabled even with the fleet flag on. + expect( + await resolveProofPage(env, "o/r", { ...deps, loadManifest: async () => ({ publicProof: { present: true, enabled: false } }) }), + ).toEqual({ status: "disabled" }); + + // REGRESSION: the composition is TOTAL. Every dependency failing at once -- manifest, ledger, anchors, + // and the DB binding itself throwing on property access -- still resolves to a rendered page in its + // honest neutral states, never a rejected promise. This is what makes an `unavailable` outcome + // unnecessary rather than merely untested: there is no input that fails the composition. + const hostile = createTestEnv({ LOOPOVER_PUBLIC_PROOF: "true" }); + Object.defineProperty(hostile, "DB", { + get() { + throw new Error("binding unavailable"); + }, + }); + const degraded = await resolveProofPage(hostile, "o/r", { + loadManifest: async () => { throw new Error("manifest down"); }, + verifyLedger: async () => { throw new Error("ledger down"); }, + loadAnchors: async () => { throw new Error("anchors down"); }, + now: () => CHECKED_AT, + }); + expect(degraded.status).toBe("ok"); + if (degraded.status !== "ok") return; + expect(degraded.summary).toMatchObject({ + decisionCount: 0, + sampleRecords: [], + ledger: { state: "unavailable", checkedAt: CHECKED_AT }, + anchor: { state: "not_yet_anchored" }, + }); + expect(degraded.summary.accuracy).toMatchObject({ state: "insufficient_data", decided: 0 }); + }); + + it("REGRESSION: both surfaces render the SAME resolver outcome, so page and badge cannot disagree", async () => { + const app = createApp(); + // A repo opted out in its manifest: the page 404s and the badge 404s with a neutral SVG, from one + // decision. The gate previously lived inline in both bodies and only one honored the opt-out. + const env = await seeded(); + await upsertRepoFocusManifest(env, "o/r", { publicProof: { enabled: false } } as never); + expect((await app.request("/v1/public/repos/o/r/proof", {}, env)).status).toBe(404); + const badge = await app.request("/v1/public/repos/o/r/proof-badge.svg", {}, env); + expect(badge.status).toBe(404); + expect(badge.headers.get("content-type")).toContain("image/svg+xml"); + expect(await badge.text()).toContain("unavailable"); + + // And with every DB read failing, BOTH still serve 200 over the degraded page rather than erroring. + const broken = await seeded(); + Object.defineProperty(broken, "DB", { + get() { + throw new Error("binding unavailable"); + }, + }); + expect((await app.request("/v1/public/repos/o/r/proof", {}, broken)).status).toBe(200); + expect((await app.request("/v1/public/repos/o/r/proof-badge.svg", {}, broken)).status).toBe(200); + }); +}); \ No newline at end of file