diff --git a/README.md b/README.md
index e2faf91..a210259 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,10 @@ Runtime errors are reported to Sentry when `NEXT_PUBLIC_SENTRY_DSN` is set. See
[MIT](./LICENSE)
+## Data freshness
+
+Landing protocol statistics, oracle status, and payout lists seed client state from REST before showing headline values, then accept feature-flagged realtime updates with a single REST fallback and explicit refresh where provided. Realtime topic flags default off and remain kill switches during the production observation window. There is no interval polling in these data hooks; the landing page's seconds-ago display timer only updates text. See [docs/realtime-freshness.md](docs/realtime-freshness.md) for the polling inventory, topic rollout gates, ordering rule, and safe debug logging switch.
+
## Realtime layer (dev)
Realtime is off by default (`public/flags.json`; see `docs/realtime-protocol.md` and `docs/realtime-rollout.md`).
diff --git a/docs/realtime-freshness.md b/docs/realtime-freshness.md
new file mode 100644
index 0000000..3c95f43
--- /dev/null
+++ b/docs/realtime-freshness.md
@@ -0,0 +1,24 @@
+# Realtime freshness and polling retirement
+
+Realtime is opt-in per topic through the remotely supplied feature flags. Each enabled topic seeds client state from one REST request, applies ordered realtime events through `useRealtimeResource`, and keeps an explicit refresh action where the UI exposes one. REST is the bounded fallback when the transport is unavailable; there are no interval polls in the data hooks.
+
+## Legacy path inventory and disposition
+
+| Path | Current state | Disposition |
+| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| `useOracleStatus` interval/backoff/manual refresh | The hook was empty on the upstream baseline; no data interval or polling backoff existed. The landing page's one-second interval only updates the “seconds ago” label. | Keep manual refresh; use realtime topic `oracle.status` with one REST seed/fallback. Retain the display clock because it does not fetch data. |
+| `useRecentClaims` polling constants/controls | The hook was empty; no polling constants existed. | Use `claims.recent`, one REST seed/fallback, and the landing payouts refresh action. |
+| `useClaims` holder polling | The hook was empty; no polling constants existed. | Use `claims.holder.
` when its kill switch is enabled, one REST seed/fallback, and an explicit dashboard refresh. |
+| `useNotifications` interval/backoff/manual refresh | No notification-specific polling existed; it derives from shared hooks. | Keep derived notifications and inherit each source's freshness policy. |
+| Shared interval/backoff helpers in `src/lib/` | No data polling helper was present. WebSocket reconnect backoff remains transport recovery, not data polling. | Keep reconnect backoff; do not hand it to adaptive polling. |
+| `/` seconds-ago clock | A one-second UI timer updates elapsed-time text only. | Keep; it does not request data or write cache state. |
+
+Topic flags default off and act as kill switches. The retirement is staged: no legacy poll was deleted because none existed in the upstream baseline. For each topic, keep the flag off until production has met the team's agreed observation window; then enable the topic, observe fallback/error rates, and remove any subsequently introduced legacy interval only after the window. The observation-window duration is an operational decision and should be recorded with the rollout.
+
+## Ordering rule
+
+The transport accepts an optional numeric `sequence` and numeric or ISO `timestamp` on each envelope. Higher sequence wins when both updates carry one; otherwise a strictly newer timestamp wins when both carry one. Equal or missing ordering keys favor the already-applied realtime update. REST responses started before a realtime event are aborted and also rejected by a generation guard if cancellation races completion. Comparable newer REST snapshots may advance state during a degraded connection. Cancellation is an optimization; the freshness checks remain authoritative.
+
+## Logging
+
+Realtime diagnostics pass through `src/lib/realtime/redact.ts`. Sensitive keys (auth tokens, wallet/address/holder identifiers, signatures, session credentials) are redacted recursively, arrays and objects are bounded, error messages are omitted, and unknown strings are truncated. Verbose message summaries are off in production and can be temporarily enabled in a development tab with `sessionStorage.setItem("refract:realtime-debug", "1")`; the switch expires when the tab's session ends. Only topic-safe summaries, byte counts, and error names are logged.
diff --git a/e2e/realtime-stats.spec.ts b/e2e/realtime-stats.spec.ts
new file mode 100644
index 0000000..3204d82
--- /dev/null
+++ b/e2e/realtime-stats.spec.ts
@@ -0,0 +1,49 @@
+import { expect, test } from "@playwright/test";
+
+test("landing protocol statistics update after a protocol.stats push", async ({ page }) => {
+ const sockets: Array<{ send: (message: string) => void }> = [];
+ await page.route("**/flags.json", (route) =>
+ route.fulfill({ json: { flags: { "realtime-protocol-stats": true } } })
+ );
+ await page.route("**/api/v1/pool/stats", (route) =>
+ route.fulfill({
+ json: {
+ totalUsdc: (2_500_000 * 1e7).toString(),
+ totalShares: "0",
+ lockedUsdc: "0",
+ premiumAccrued: "0",
+ availableUsdc: "0",
+ utilizationBps: 0,
+ apyBps: 0,
+ sharePrice: 1,
+ maxUtilizationBps: 8000,
+ },
+ })
+ );
+ await page.route("**/api/v1/claims/stats", (route) =>
+ route.fulfill({
+ json: {
+ activePolicies: 11,
+ processedClaims: 0,
+ totalPayout: "0",
+ settlementConfigured: true,
+ },
+ })
+ );
+ await page.routeWebSocket("**/ws", (webSocket) => {
+ sockets.push(webSocket);
+ });
+
+ await page.goto("/");
+ await expect(page.getByText("Policies Active")).toBeVisible();
+ expect(sockets).toHaveLength(1);
+ sockets[0].send(
+ JSON.stringify({
+ topic: "protocol.stats",
+ sequence: 1,
+ timestamp: Date.now(),
+ payload: { tvl: (3_200_000 * 1e7).toString(), activePolicies: 31, cumulativePayouts: "0" },
+ })
+ );
+ await expect(page.getByText("31", { exact: true })).toBeVisible();
+});
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index a847ab9..b0151bb 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -33,7 +33,8 @@ export default function DashboardPage() {
const wallet = useWallet();
const address = wallet.status === "connected" ? wallet.address : null;
const { data: policies, loading, error, isFixture, isStale } = useHolderPolicies(address);
- const claims = useClaims(address, policies);
+ const claimsResource = useClaims(address, policies);
+ const claims = claimsResource.data;
const policiesById = normalizePolicies(policies ?? []);
const claimsByPolicyId = normalizeClaims(claims);
const [exporting, setExporting] = useState(false);
@@ -96,6 +97,9 @@ export default function DashboardPage() {
{canExport && (
- {statsLoading ? (
-
- ) : (
- // Counter only animates once on mount, so it's keyed on
- // loaded-state to remount (and pick up the real value)
- // the moment the data actually arrives.
-
- )}
+