diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index 39c898de8..b5953eaa4 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -1,3 +1,4 @@ +import { claimAgeLabel, viewClaims } from "@/lib/provider-claims"; import { distinctBenchCount } from "@/lib/providers"; import type { Metadata } from "next"; import { loadSitemapBlob } from "@/lib/sitemap-blob"; @@ -1061,6 +1062,58 @@ export default async function ProviderPage({ )} + {(() => { + // Properties this site has no instrument for: insurance, + // confidentiality, whether users are shielded from questionable + // liquidity. Real, and worth a reader knowing, but not measured. They + // get their own section with the source and the date attached, never + // a column next to the measured ones, and the heading names the + // speaker. See lib/provider-claims.ts. + const claims = viewClaims(reg?.claims); + if (claims.length === 0) return null; + return ( +
+

+ Stated by {p.name}, not measured here +

+
+

+ These are the provider’s own statements about properties no + benchmark on this site measures. We checked that each one appears + at the source on the date given; we did not verify that it holds. +

+
+ {claims.map((c) => ( +
+
+ {c.label} +
+
+ {c.value} +
+
+ + source + + {" · "} + + {c.stale && ( + + not re-checked recently + + )} +
+
+ ))} +
+
+ ); + })()} + {(() => { // Most recent lastRunAt across every appearance is the truest // "last measured" signal a Google freshness ranker, an AI citation diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts index 29b8faeb8..13abef00d 100644 --- a/src/data/provider-registry.ts +++ b/src/data/provider-registry.ts @@ -10,14 +10,43 @@ * factual, no marketing adjectives. Twitter handle without the URL. */ +/** + * A statement the provider makes about itself that no harness can measure: + * swap insurance, confidentiality, whether users are shielded from + * questionable liquidity. Aurora asked for exactly these, and they are real + * properties, but they are not measurements. + * + * Three fields a measurement does not need, all required: where we read it, + * when we last looked, and the provider's own words for it. The product page + * renders all three under a heading that says the provider is speaking, and + * flags a claim nobody has re-checked in six months. See lib/provider-claims.ts. + * + * A claim never enters a benchmark, never ranks anything, and never moves a + * leader. If a property can be probed, probe it and publish the measurement + * instead; this is only for the ones that cannot. + */ +export type ProviderClaim = { + /** What the claim is about. "Swap insurance", "Confidentiality". */ + label: string; + /** The provider's position, in plain words, no superlatives. */ + value: string; + /** https URL where this was read. An unsourced claim is a rumour. */ + source: string; + /** YYYY-MM-DD the source was last checked by a human. */ + verifiedAt: string; +}; + export type ProviderRegistryEntry = { url: string; description: string; twitter?: string; - // Optional rich content surfaced on the product detail page when present. - // Every field is independently optional; the page renders only the - // sections that have data, so partial enrichment is safe. + // Optional enrichment. NOTE: of the seven fields below, none is currently + // read by any page. They were added for a product-page section that was + // never built, and the comment here used to claim they were "surfaced on + // the product detail page", which is not true. Either wire them or drop + // them; do not add an eighth expecting it to render. `claims` below IS + // rendered, and its test fails the build if a claim is malformed. longDescription?: string; chains?: string[]; features?: string[]; @@ -33,6 +62,11 @@ export type ProviderRegistryEntry = { * Sub-products keep their own bench rankings — this is editorial * cross-linking, not data merging. */ parent?: string; + + /** Provider statements we cannot measure, each with a source and a date. + * Rendered in their own section, never mixed with measured columns. + * See lib/provider-claims.ts for the rules and the staleness window. */ + claims?: ProviderClaim[]; }; export const PROVIDER_REGISTRY: Record = { diff --git a/src/lib/provider-claims.test.ts b/src/lib/provider-claims.test.ts new file mode 100644 index 000000000..8ea8f96c4 --- /dev/null +++ b/src/lib/provider-claims.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "bun:test"; +import { PROVIDER_REGISTRY, type ProviderClaim } from "@/data/provider-registry"; +import { claimAgeDays, claimAgeLabel, claimProblems, viewClaims } from "./provider-claims"; + +const ok: ProviderClaim = { + label: "Swap insurance", + value: "Swaps are covered by a third-party policy up to the notional amount.", + source: "https://example.com/terms", + verifiedAt: "2026-10-01", +}; + +describe("a claim must be sourced and dated to be published", () => { + test("a well-formed claim has no problems", () => { + expect(claimProblems("x", ok)).toEqual([]); + }); + + test("an unsourced claim is a rumour", () => { + expect(claimProblems("x", { ...ok, source: "" })[0]).toContain("https URL"); + expect(claimProblems("x", { ...ok, source: "http://example.com" })[0]).toContain("https URL"); + }); + + test("a claim without a readable date cannot be aged", () => { + expect(claimProblems("x", { ...ok, verifiedAt: "last tuesday" })[0]).toContain("YYYY-MM-DD"); + }); + + test("a future verification date is a typo, not a fresh check", () => { + expect(claimProblems("x", { ...ok, verifiedAt: "2099-01-01" })[0]).toContain("future"); + }); + + test("a superlative in a claim would be marketing we are laundering", () => { + // The measured columns are where a provider gets to be fastest. This site + // exists because marketing pages quote "real-time" without a number. + const p = claimProblems("x", { ...ok, value: "The fastest settlement in the market." }); + expect(p[0]).toContain("superlative"); + }); +}); + +describe("age and staleness", () => { + const now = new Date("2026-10-02T12:00:00Z"); + + test("counts whole days from the verification date", () => { + expect(claimAgeDays("2026-10-02", now)).toBe(0); + expect(claimAgeDays("2026-09-02", now)).toBe(30); + }); + + test("a future date yields no age rather than a negative one", () => { + expect(claimAgeDays("2027-01-01", now)).toBeNull(); + }); + + test("past six months a claim says so instead of ageing quietly", () => { + const fresh = viewClaims([{ ...ok, verifiedAt: "2026-09-20" }], now)[0]; + const old = viewClaims([{ ...ok, verifiedAt: "2026-01-01" }], now)[0]; + expect(fresh.stale).toBe(false); + expect(old.stale).toBe(true); + }); + + test("stale claims sort last, so the section opens on what we stand behind", () => { + const views = viewClaims( + [ + { ...ok, label: "Old", verifiedAt: "2025-01-01" }, + { ...ok, label: "New", verifiedAt: "2026-09-30" }, + ], + now, + ); + expect(views.map((v) => v.label)).toEqual(["New", "Old"]); + }); + + test("the age label reads like a person wrote it", () => { + expect(claimAgeLabel(viewClaims([{ ...ok, verifiedAt: "2026-10-02" }], now)[0])).toBe("checked today"); + expect(claimAgeLabel(viewClaims([{ ...ok, verifiedAt: "2026-10-01" }], now)[0])).toBe("checked yesterday"); + expect(claimAgeLabel(viewClaims([{ ...ok, verifiedAt: "2026-09-22" }], now)[0])).toBe("checked 10 days ago"); + expect(claimAgeLabel(viewClaims([{ ...ok, verifiedAt: "2026-04-02" }], now)[0])).toContain("months ago"); + }); + + test("no claims renders nothing rather than an empty section", () => { + expect(viewClaims(undefined)).toEqual([]); + expect(viewClaims([])).toEqual([]); + }); +}); + +describe("the registry itself", () => { + test("every claim in the registry is publishable", () => { + // This is the gate: a malformed claim fails the build rather than + // reaching a page unsourced or undated. + const problems = Object.entries(PROVIDER_REGISTRY).flatMap(([slug, entry]) => + (entry.claims ?? []).flatMap((c) => claimProblems(slug, c)), + ); + expect(problems).toEqual([]); + }); +}); diff --git a/src/lib/provider-claims.ts b/src/lib/provider-claims.ts new file mode 100644 index 000000000..e15d36c2d --- /dev/null +++ b/src/lib/provider-claims.ts @@ -0,0 +1,89 @@ +import type { ProviderClaim } from "@/data/provider-registry"; + +/** + * Statements a provider makes about itself, which no harness can measure. + * + * Aurora asked us to cover things this site has no instrument for: whether a + * swap is insured, whether it is confidential, whether users are shielded + * from questionable liquidity. Those are real properties and they matter to a + * reader, but they are not measurements and must never be printed as if they + * were. This site exists because marketing pages quote "real-time" without a + * number; publishing a vendor's claim unqualified would make us the thing we + * were built against. + * + * So a claim carries three things a measurement does not need: where we read + * it, when we last looked, and the fact that it is the provider speaking. The + * page renders all three, and a claim nobody has re-checked in a long time + * says so rather than ageing silently into something that reads current. + * + * What a claim is NOT: a benchmark value, a ranking input, or anything that + * can move a leader. Nothing here touches the ledger. + */ + +/** A claim goes stale at six months. Long enough that re-checking is not + * busywork, short enough that a pricing page or a terms change is unlikely to + * have passed unnoticed for two of them. */ +export const CLAIM_STALE_AFTER_DAYS = 183; + +export type ClaimView = ProviderClaim & { + /** Whole days since it was last verified; null when the date will not parse. */ + ageDays: number | null; + /** Past CLAIM_STALE_AFTER_DAYS, so the page says so instead of ageing quietly. */ + stale: boolean; +}; + +/** Days between an ISO date and now, or null when the date is unusable. */ +export function claimAgeDays(verifiedAt: string, now = new Date()): number | null { + const d = new Date(`${verifiedAt}T00:00:00Z`); + if (Number.isNaN(d.getTime())) return null; + const ms = now.getTime() - d.getTime(); + if (ms < 0) return null; // a future date is a typo, not a fresh check + return Math.floor(ms / 86_400_000); +} + +/** Claims in the order a reader should meet them: anything stale last, so the + * section opens on what we actually stand behind. */ +export function viewClaims(claims: ProviderClaim[] | undefined, now = new Date()): ClaimView[] { + if (!claims || claims.length === 0) return []; + return claims + .map((c) => { + const ageDays = claimAgeDays(c.verifiedAt, now); + return { ...c, ageDays, stale: ageDays == null || ageDays > CLAIM_STALE_AFTER_DAYS }; + }) + .sort((a, b) => Number(a.stale) - Number(b.stale) || a.label.localeCompare(b.label)); +} + +/** "checked 12 days ago", for the line under each claim. A claim whose date + * will not parse says that plainly rather than printing a wrong age. */ +export function claimAgeLabel(c: ClaimView): string { + if (c.ageDays == null) return "verification date unreadable"; + if (c.ageDays === 0) return "checked today"; + if (c.ageDays === 1) return "checked yesterday"; + if (c.ageDays < 60) return `checked ${c.ageDays} days ago`; + const months = Math.round(c.ageDays / 30.4); + return `checked ${months} months ago`; +} + +/** Problems that should stop a claim reaching a page. Returned rather than + * thrown so a test can assert the whole registry at once. */ +export function claimProblems(slug: string, c: ProviderClaim): string[] { + const out: string[] = []; + const where = `${slug} claim "${c.label}"`; + if (!c.label.trim()) out.push(`${where}: empty label`); + if (!c.value.trim()) out.push(`${where}: empty value`); + if (!/^https:\/\//.test(c.source)) { + // An unsourced claim is a rumour, and http is not a source we can stand on. + out.push(`${where}: source must be an https URL, got ${c.source || "(none)"}`); + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(c.verifiedAt)) { + out.push(`${where}: verifiedAt must be YYYY-MM-DD, got ${c.verifiedAt || "(none)"}`); + } else if (claimAgeDays(c.verifiedAt) == null) { + out.push(`${where}: verifiedAt is in the future or unparseable (${c.verifiedAt})`); + } + if (/\b(fastest|best|cheapest|leading|#1|number one)\b/i.test(c.value)) { + // A superlative in a claim is marketing we would be laundering. The + // measured columns are where a provider gets to be fastest. + out.push(`${where}: value carries a superlative; quote the substance, not the boast`); + } + return out; +}