From fa8be68f56d77903a4cab2f922b180183e1ad8df Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:02:37 -0700 Subject: [PATCH] feat(proof): the public per-repo proof page, the last piece of #9569 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API half already shipped: `/v1/public/repos/:owner/:repo/proof`, the badge SVG, the feature flag and the per-repo opt-out are all on main, all built on `buildProofSummary`. What was missing was the page the issue is named for. ONE IMPLEMENTATION, TWO RENDERINGS. This component renders and computes nothing. Every figure comes from the endpoint, which is the same composition the in-app trust panel (#9193) reads, so the two cannot disagree about a number. The only arithmetic is multiplying an already-computed rate by 100 for display -- a percentage derived here would be a second implementation free to drift from the first, undermining the exact property the page exists to demonstrate. The states are the substance, because each is somewhere a plausible implementation says something untrue: • BELOW THE SAMPLE FLOOR the page shows the decision COUNT and no rate -- "7 decisions, too few to claim a rate". Hiding the count along with the figure, or printing 0%, both misrepresent it. • NOT-YET-ANCHORED and EMPTY LEDGER are neutral. A new repository is not a failing one, and rendering it as an error lies in the more damaging direction. • A BROKEN ledger is the one state stated as a problem, naming the row and the kind of break, because the kind is the actionable half. • UNAVAILABLE says so explicitly: it is not a claim that anything is wrong. • AN OPTED-OUT REPO (404) is an EMPTY state, not an error -- a different ARIA role and a different meaning. Telling an opted-out repo something broke invites someone to hunt a fault that does not exist. • ACCURACY never appears without its denominator and Wilson interval. The boundary statement is rendered FROM THE PAYLOAD rather than written here, so a screenshot or an embed cannot shed the caveat while keeping the numbers. Digests are shown head-and-tail with the full value in `title`: a truncated digest with no way back to the whole cannot be checked against anything. Mutation-tested: publishing a rate below the floor, hardcoding the boundary statement, and turning the 404 into an error each fail. Closes #9569 --- .../site/public-proof-page.test.tsx | 177 ++++++++++ .../src/components/site/public-proof-page.tsx | 312 ++++++++++++++++++ apps/loopover-ui/src/routeTree.gen.ts | 21 ++ .../src/routes/proof.$owner.$repo.tsx | 25 ++ 4 files changed, 535 insertions(+) create mode 100644 apps/loopover-ui/src/components/site/public-proof-page.test.tsx create mode 100644 apps/loopover-ui/src/components/site/public-proof-page.tsx create mode 100644 apps/loopover-ui/src/routes/proof.$owner.$repo.tsx diff --git a/apps/loopover-ui/src/components/site/public-proof-page.test.tsx b/apps/loopover-ui/src/components/site/public-proof-page.test.tsx new file mode 100644 index 0000000000..7f3231cc44 --- /dev/null +++ b/apps/loopover-ui/src/components/site/public-proof-page.test.tsx @@ -0,0 +1,177 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { PublicProofPage, type ProofSummary } from "./public-proof-page"; + +// #9569: the public proof page. +// +// What is worth testing here is not that cards render — it is the JUDGEMENT the page encodes, because every +// one of these states is a place where a plausible implementation says something untrue: +// +// • a repo below the sample floor must show its decision COUNT and no rate. Hiding the count with the +// figure, or printing 0%, both misrepresent "we have 7 decisions, too few to claim a rate". +// • not-yet-anchored and empty-ledger are NEUTRAL. A page that renders a new repo as an error is lying in +// the more damaging direction than one that says nothing. +// • a genuinely BROKEN ledger is the one state that must read as a problem, and must name where it broke. +// • an opted-out repo (404) is an empty state, not an error state — different ARIA role, different meaning. +// • the boundary statement comes from the PAYLOAD. Hardcoding it here would let a screenshot or embed shed +// the caveat while keeping the numbers. + +const summary = (over: Partial = {}): ProofSummary => ({ + schemaVersion: 1, + repoFullName: "acme/widgets", + decisionCount: 128, + accuracy: { + state: "published", + accuracy: 0.964, + decided: 112, + confirmed: 108, + interval: { lo: 0.912, hi: 0.987 }, + }, + ledger: { + state: "verified", + tipSeq: 128, + totalCount: 128, + checkedAt: "2026-07-31T12:00:00.000Z", + }, + anchor: { + state: "anchored", + backend: "rekor", + seq: 128, + rowHash: "a".repeat(64), + at: "2026-07-30T00:00:00.000Z", + }, + sampleRecords: [ + { + pullNumber: 42, + action: "merge", + reasonCode: "gate_pass", + decidedAt: "2026-07-30T10:00:00.000Z", + recordDigest: "b".repeat(64), + }, + ], + boundary: + "This page proves what was decided and that the record is intact. It does not prove the decisions were correct.", + ...over, +}); + +/** Renders with fetch stubbed to one response, then waits for the query to settle. */ +async function renderPage(response: { status: number; body?: unknown }): Promise { + vi.stubGlobal("fetch", async () => + response.status === 200 + ? new Response(JSON.stringify(response.body), { + status: 200, + headers: { "content-type": "application/json" }, + }) + : new Response(JSON.stringify({ error: "not_found" }), { + status: response.status, + headers: { "content-type": "application/json" }, + }), + ); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + await waitFor(() => expect(screen.queryByText(/acme\/widgets/)).toBeTruthy()); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("PublicProofPage (#9569)", () => { + it("shows the accuracy WITH its denominator and interval, never as a bare percentage", async () => { + await renderPage({ status: 200, body: summary() }); + await waitFor(() => expect(screen.getByText("96.4%")).toBeTruthy()); + // The denominator and the interval are what make the number arguable rather than promotional. + expect(screen.getByText(/108 of 112 decisions confirmed/)).toBeTruthy(); + expect(screen.getByText(/91\.2%–98\.7%/)).toBeTruthy(); + }); + + it("REGRESSION: below the sample floor it publishes the COUNT and no rate", async () => { + await renderPage({ + status: 200, + body: summary({ accuracy: { state: "insufficient_data", decided: 7, minimumDecisions: 20 } }), + }); + await waitFor(() => expect(screen.getByText(/7 decisions so far/)).toBeTruthy()); + expect(screen.getByText(/fewer than the 20 needed/)).toBeTruthy(); + // The failure mode this guards: rendering a fabricated 0% for "no data". + expect(screen.queryByText("0%")).toBeNull(); + }); + + it("renders not-yet-anchored as a NEUTRAL state, not an error", async () => { + await renderPage({ status: 200, body: summary({ anchor: { state: "not_yet_anchored" } }) }); + await waitFor(() => expect(screen.getByText("Not yet anchored")).toBeTruthy()); + expect(screen.getByText(/The chain is still self-verifying/)).toBeTruthy(); + }); + + it("renders an empty ledger as a new repository, not a failing one", async () => { + await renderPage({ + status: 200, + body: summary({ ledger: { state: "empty", checkedAt: "2026-07-31T12:00:00.000Z" } }), + }); + await waitFor(() => expect(screen.getByText("No decisions recorded yet")).toBeTruthy()); + expect(screen.getByText(/not a failing one/)).toBeTruthy(); + }); + + it("REGRESSION: a BROKEN ledger is stated as a problem, naming the row and the kind", async () => { + // The one state that must not be softened — and the kind of break is the actionable half. + await renderPage({ + status: 200, + body: summary({ + ledger: { + state: "broken", + tipSeq: 128, + totalCount: 128, + checkedAt: "2026-07-31T12:00:00.000Z", + brokenAtSeq: 57, + brokenKind: "row_hash_mismatch", + }, + }), + }); + await waitFor(() => expect(screen.getByText("Ledger verification failed")).toBeTruthy()); + expect(screen.getByText(/sequence 57 of 128 \(row_hash_mismatch\)/)).toBeTruthy(); + }); + + it("says an unavailable verification is not a claim that anything is wrong", async () => { + await renderPage({ + status: 200, + body: summary({ ledger: { state: "unavailable", checkedAt: "2026-07-31T12:00:00.000Z" } }), + }); + await waitFor(() => expect(screen.getByText("Ledger state unavailable")).toBeTruthy()); + expect(screen.getByText(/not a claim that anything is wrong/)).toBeTruthy(); + }); + + it("INVARIANT: renders the boundary statement from the payload, not from this component", async () => { + // Carried in the response so an embed or screenshot cannot shed the caveat while keeping the figures. + const boundary = "A bespoke boundary sentence that exists only in this fixture."; + await renderPage({ status: 200, body: summary({ boundary }) }); + await waitFor(() => expect(screen.getByText(boundary)).toBeTruthy()); + }); + + it("treats an opted-out repo (404) as EMPTY, not as an error", async () => { + // Different meaning and a different ARIA role: telling an opted-out repo that something broke would be + // wrong, and would invite someone to go looking for a fault that does not exist. + await renderPage({ status: 404 }); + await waitFor(() => expect(screen.getByText(/No public proof page/)).toBeTruthy()); + expect(screen.queryByText(/Proof summary unavailable/)).toBeNull(); + }); + + it("shows sample records with a digest that can be checked", async () => { + await renderPage({ status: 200, body: summary() }); + await waitFor(() => expect(screen.getByText("#42")).toBeTruthy()); + expect(screen.getByText("gate_pass")).toBeTruthy(); + // Truncated for the eye, complete in the title — a shortened digest with no way back to the full value + // cannot be checked against anything. + expect(screen.getByTitle("b".repeat(64))).toBeTruthy(); + }); + + it("omits the sample-records card entirely when there are none", async () => { + await renderPage({ status: 200, body: summary({ sampleRecords: [] }) }); + await waitFor(() => expect(screen.getByText("Decisions")).toBeTruthy()); + expect(screen.queryByText("Sample decision records")).toBeNull(); + }); +}); diff --git a/apps/loopover-ui/src/components/site/public-proof-page.tsx b/apps/loopover-ui/src/components/site/public-proof-page.tsx new file mode 100644 index 0000000000..3b0986ffc6 --- /dev/null +++ b/apps/loopover-ui/src/components/site/public-proof-page.tsx @@ -0,0 +1,312 @@ +import { useQuery } from "@tanstack/react-query"; + +import { getApiOrigin } from "@/lib/api/origin"; +import { apiFetch } from "@/lib/api/request"; +import { Card, Section } from "@/components/site/primitives"; +import { StateBoundary } from "@/components/site/state-views"; +import { Skeleton } from "@/components/ui/skeleton"; + +// #9569: the public, shareable twin of the in-app trust panel. +// +// This component RENDERS; it computes nothing. Every figure comes from `/v1/public/repos/:owner/:repo/proof`, +// which is built by `buildProofSummary` -- the same composition the in-app panel reads. A percentage derived +// here would be a second implementation free to disagree with the first, which would undermine the exact +// property the page exists to demonstrate. The only arithmetic below is multiplying an already-computed rate +// by 100 for display. +// +// NEVER A BARE SCALAR. The accuracy figure is only ever shown with its denominator and its Wilson interval, +// because that is the difference between a claim someone can argue with and marketing. Below the sample floor +// the API sends `insufficient_data` WITH the decision count, and this renders exactly that -- "7 decisions, +// too few to claim a rate" -- rather than hiding the count along with the figure or printing a 0%. +// +// BOUNDARY STATES ARE NEUTRAL, NOT ERRORS. A repo that has not been anchored yet, or whose ledger is empty, +// is not a failing repo. Rendering either as an error would be lying in the more damaging direction, so they +// get their own neutral treatment and only a genuinely BROKEN ledger is styled as a problem. + +export type ProofAccuracy = + | { + state: "published"; + accuracy: number; + decided: number; + confirmed: number; + interval: { lo: number; hi: number }; + } + | { state: "insufficient_data"; decided: number; minimumDecisions: number }; + +export type ProofLedgerStatus = + | { state: "verified"; tipSeq: number; totalCount: number; checkedAt: string } + | { + 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 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[]; + boundary: string; +}; + +const pctFmt = new Intl.NumberFormat("en", { maximumFractionDigits: 1 }); +const countFmt = new Intl.NumberFormat("en"); +const asPct = (rate: number): string => `${pctFmt.format(rate * 100)}%`; + +/** A digest is 64 hex characters. Shown head-and-tail so it stays recognisable at a glance while remaining + * copyable in full from the title attribute — a truncated digest with no way back to the whole value cannot + * be checked against anything. */ +function shortDigest(digest: string): string { + return digest.length <= 20 ? digest : `${digest.slice(0, 10)}…${digest.slice(-6)}`; +} + +async function fetchProofSummary(owner: string, repo: string): Promise { + const result = await apiFetch( + `${getApiOrigin()}/v1/public/repos/${owner}/${repo}/proof`, + { + label: "Public proof summary", + timeoutMs: 8000, + silentStatus: true, + }, + ); + // Same distinction the sibling quality page draws (#6821): a transport/HTTP failure must reach ErrorState + // with a retry, while a successful 404 -- the repo has not opted in, or the surface is off -- is an + // EmptyState. Collapsing the two would tell an opted-out repo that something is broken. + if (!result.ok) { + if (result.status === 404) return null; + throw new Error(result.message || "Proof summary unavailable"); + } + return result.data ?? null; +} + +function LedgerCard({ ledger }: { ledger: ProofLedgerStatus }): React.JSX.Element { + if (ledger.state === "verified") { + return ( + +

Ledger verified

+

+ The hash chain recomputed cleanly over all {countFmt.format(ledger.totalCount)} rows, to + sequence {countFmt.format(ledger.tipSeq)}. +

+

+ Checked {new Date(ledger.checkedAt).toLocaleString()} +

+
+ ); + } + if (ledger.state === "broken") { + // The one state that IS a problem, and it is stated rather than softened -- including which row and what + // kind of break, because the kind is the actionable half. + return ( + +

Ledger verification failed

+

+ The chain broke at sequence {countFmt.format(ledger.brokenAtSeq)} of{" "} + {countFmt.format(ledger.totalCount)} ({ledger.brokenKind}). +

+

+ Checked {new Date(ledger.checkedAt).toLocaleString()} +

+
+ ); + } + if (ledger.state === "empty") { + return ( + +

No decisions recorded yet

+

+ The ledger for this repository is empty. That is a new repository, not a failing one. +

+
+ ); + } + return ( + +

Ledger state unavailable

+

+ The verification could not be run just now. This says nothing about the ledger itself — it + is not a claim that anything is wrong. +

+
+ ); +} + +function AccuracyCard({ accuracy }: { accuracy: ProofAccuracy }): React.JSX.Element { + if (accuracy.state === "insufficient_data") { + return ( + +

Accuracy

+

+ {countFmt.format(accuracy.decided)} decision{accuracy.decided === 1 ? "" : "s"} so far — + fewer than the {countFmt.format(accuracy.minimumDecisions)} needed to publish a rate. A + percentage over this few would be noise wearing a number's clothes. +

+
+ ); + } + return ( + +

Accuracy

+

{asPct(accuracy.accuracy)}

+

+ {countFmt.format(accuracy.confirmed)} of {countFmt.format(accuracy.decided)} decisions + confirmed by what actually happened. 95% interval {asPct(accuracy.interval.lo)}– + {asPct(accuracy.interval.hi)}. +

+
+ ); +} + +function AnchorCard({ anchor }: { anchor: ProofAnchorStatus }): React.JSX.Element { + if (anchor.state === "not_yet_anchored") { + return ( + +

Not yet anchored

+

+ No external anchor has been published for this ledger yet. The chain is still + self-verifying; an anchor adds third-party evidence of when it existed. +

+
+ ); + } + return ( + +

Externally anchored

+

+ Sequence {countFmt.format(anchor.seq)} anchored to {anchor.backend} on{" "} + {new Date(anchor.at).toLocaleDateString()}. +

+

+ {shortDigest(anchor.rowHash)} +

+
+ ); +} + +/** Content-shaped placeholder matching the card grid, so the page does not jump once data arrives. */ +function ProofSkeleton(): React.JSX.Element { + return ( +
+ {[0, 1, 2, 3].map((index) => ( + + ))} +
+ ); +} + +export function PublicProofPage({ + owner, + repo, +}: { + owner: string; + repo: string; +}): React.JSX.Element { + const query = useQuery({ + queryKey: ["public-proof", owner, repo], + queryFn: () => fetchProofSummary(owner, repo), + }); + + return ( +
+
+

Review proof

+

+ {owner}/{repo} +

+

+ Every figure here is read from a public endpoint and can be re-derived independently. + Nothing on this page is asserted without a source. +

+
+ void query.refetch()} + loadingSkeleton={} + emptyTitle="No public proof page" + emptyDescription="This repository has not opted in to a public proof page, or the surface is not enabled for this deployment." + errorTitle="Proof summary unavailable" + errorDescription="The proof summary could not be loaded just now. This says nothing about the repository's ledger." + > + {query.data ? ( +
+
+ +

Decisions

+

+ {countFmt.format(query.data.decisionCount)} +

+

+ Published, digest-committed verdicts. +

+
+ + + +
+ + {query.data.sampleRecords.length > 0 ? ( + +

Sample decision records

+

+ A few of the published records, with the digest each one commits to. Recompute any + digest from the record's own contents — it is a hash of the record minus the + digest field. +

+
    + {query.data.sampleRecords.map((record) => ( +
  • + #{record.pullNumber} + {record.action} + {record.reasonCode} + + {shortDigest(record.recordDigest)} + +
  • + ))} +
+
+ ) : null} + + {/* Rendered from the payload, never hardcoded here: the API carries the boundary statement so a + screenshot or an embed cannot shed it the way a page footer can. */} + +

What this does not prove

+

{query.data.boundary}

+
+
+ ) : null} +
+
+ ); +} diff --git a/apps/loopover-ui/src/routeTree.gen.ts b/apps/loopover-ui/src/routeTree.gen.ts index 7ac38456ef..5649d23bab 100644 --- a/apps/loopover-ui/src/routeTree.gen.ts +++ b/apps/loopover-ui/src/routeTree.gen.ts @@ -41,6 +41,7 @@ import { Route as DocsSlugRouteImport } from './routes/docs.$slug' import { Route as DocsFumadocsSpikeApiReferenceRouteImport } from './routes/docs.fumadocs-spike-api-reference' import { Route as InstallIndexRouteImport } from './routes/install.index' import { Route as InstallPermissionsRouteImport } from './routes/install.permissions' +import { Route as ProofOwnerRepoRouteImport } from './routes/proof.$owner.$repo' import { Route as ReposOwnerRepoQualityRouteImport } from './routes/repos.$owner.$repo.quality' const IndexRoute = IndexRouteImport.update({ @@ -204,6 +205,11 @@ const InstallPermissionsRoute = InstallPermissionsRouteImport.update({ path: '/permissions', getParentRoute: () => InstallRoute, } as any) +const ProofOwnerRepoRoute = ProofOwnerRepoRouteImport.update({ + id: '/proof/$owner/$repo', + path: '/proof/$owner/$repo', + getParentRoute: () => rootRouteImport, +} as any) const ReposOwnerRepoQualityRoute = ReposOwnerRepoQualityRouteImport.update({ id: '/repos/$owner/$repo/quality', path: '/repos/$owner/$repo/quality', @@ -243,6 +249,7 @@ export interface FileRoutesByFullPath { '/app/': typeof AppIndexRoute '/docs/': typeof DocsIndexRoute '/install/': typeof InstallIndexRoute + '/proof/$owner/$repo': typeof ProofOwnerRepoRoute '/repos/$owner/$repo/quality': typeof ReposOwnerRepoQualityRoute } export interface FileRoutesByTo { @@ -274,6 +281,7 @@ export interface FileRoutesByTo { '/app': typeof AppIndexRoute '/docs': typeof DocsIndexRoute '/install': typeof InstallIndexRoute + '/proof/$owner/$repo': typeof ProofOwnerRepoRoute '/repos/$owner/$repo/quality': typeof ReposOwnerRepoQualityRoute } export interface FileRoutesById { @@ -310,6 +318,7 @@ export interface FileRoutesById { '/app/': typeof AppIndexRoute '/docs/': typeof DocsIndexRoute '/install/': typeof InstallIndexRoute + '/proof/$owner/$repo': typeof ProofOwnerRepoRoute '/repos/$owner/$repo/quality': typeof ReposOwnerRepoQualityRoute } export interface FileRouteTypes { @@ -347,6 +356,7 @@ export interface FileRouteTypes { | '/app/' | '/docs/' | '/install/' + | '/proof/$owner/$repo' | '/repos/$owner/$repo/quality' fileRoutesByTo: FileRoutesByTo to: @@ -378,6 +388,7 @@ export interface FileRouteTypes { | '/app' | '/docs' | '/install' + | '/proof/$owner/$repo' | '/repos/$owner/$repo/quality' id: | '__root__' @@ -413,6 +424,7 @@ export interface FileRouteTypes { | '/app/' | '/docs/' | '/install/' + | '/proof/$owner/$repo' | '/repos/$owner/$repo/quality' fileRoutesById: FileRoutesById } @@ -428,6 +440,7 @@ export interface RootRouteChildren { MaintainersRoute: typeof MaintainersRoute MinersRoute: typeof MinersRoute RoadmapRoute: typeof RoadmapRoute + ProofOwnerRepoRoute: typeof ProofOwnerRepoRoute ReposOwnerRepoQualityRoute: typeof ReposOwnerRepoQualityRoute } @@ -657,6 +670,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof InstallPermissionsRouteImport parentRoute: typeof InstallRoute } + '/proof/$owner/$repo': { + id: '/proof/$owner/$repo' + path: '/proof/$owner/$repo' + fullPath: '/proof/$owner/$repo' + preLoaderRoute: typeof ProofOwnerRepoRouteImport + parentRoute: typeof rootRouteImport + } '/repos/$owner/$repo/quality': { id: '/repos/$owner/$repo/quality' path: '/repos/$owner/$repo/quality' @@ -754,6 +774,7 @@ const rootRouteChildren: RootRouteChildren = { MaintainersRoute: MaintainersRoute, MinersRoute: MinersRoute, RoadmapRoute: RoadmapRoute, + ProofOwnerRepoRoute: ProofOwnerRepoRoute, ReposOwnerRepoQualityRoute: ReposOwnerRepoQualityRoute, } export const routeTree = rootRouteImport diff --git a/apps/loopover-ui/src/routes/proof.$owner.$repo.tsx b/apps/loopover-ui/src/routes/proof.$owner.$repo.tsx new file mode 100644 index 0000000000..265a2b6023 --- /dev/null +++ b/apps/loopover-ui/src/routes/proof.$owner.$repo.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { PublicProofPage } from "@/components/site/public-proof-page"; + +export const Route = createFileRoute("/proof/$owner/$repo")({ + head: ({ params }) => ({ + meta: [ + { title: `${params.owner}/${params.repo} review proof — LoopOver` }, + { + name: "description", + content: + "Public, independently checkable evidence for a repository's automated review record: decision count, accuracy with its interval, live ledger verification, and the external anchor.", + }, + { property: "og:title", content: `${params.owner}/${params.repo} review proof` }, + { property: "og:url", content: `/proof/${params.owner}/${params.repo}` }, + ], + links: [{ rel: "canonical", href: `/proof/${params.owner}/${params.repo}` }], + }), + component: RouteComponent, +}); + +function RouteComponent() { + const { owner, repo } = Route.useParams(); + return ; +}