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.
+
+
+ );
+ })()}
+
{(() => {
// 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;
+}