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 000000000..7f3231cc4 --- /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 000000000..3b0986ffc --- /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 7ac38456e..5649d23ba 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 000000000..265a2b602 --- /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 ; +}