<!-- source: openspec/current/frontend-runtime.md -->

---
title: Frontend Runtime
status: stable
owner: pavel
audience: [pavel, ai, engineer]
tldr: The contract every studio UI satisfies so users perceive instant navigation, live data, and zero loading flashes. SPA-feel as a goal.
---
# Frontend runtime

> The contract every studio UI satisfies so users perceive instant navigation, live data, and zero loading flashes. Caching is the means; SPA-feel is the goal.

---

## Where this lives

- **No package.** Pure contract; implementations live in `packages/state`, the apps' Next configs, and each engagement's data-fetching hooks.
- **Stack:** Next.js 16.2.1 (App Router, Cache Components), React 19, TypeScript 5, Tailwind CSS 4.
- **Runtime:** Cloudflare Workers via `@opennextjs/cloudflare`.
- **Pinned versions (hard floor):** `next ≥ 16.2.3` (CVE-2026-23869 floor) AND `@opennextjs/cloudflare ≥ 1.19.0` (the version that wired `'use cache'` semantics + SWR `revalidateTag` through the adapter). Bump these **together or not at all** — Next 16.2.0 crashed Workers with `Error 1101 loadManifest(prefetch-hints.json)` until OpenNext 1.17.3+, the Next↔OpenNext handshake is brittle and a partial bump regresses production.
- **Realtime substrate:** Supabase Realtime, accessed through two adapters:
  - XState — `packages/state/src/actors/supabase-realtime-bridge.ts`
  - TanStack Query — Vivod's `useRealtimeTable` (per `vivod#267`)

---

## Goal

Users perceive the studio's apps as Single-Page Applications: **navigation feels instant, data stays fresh without explicit refetches, write actions show their result before the network round-trip completes, and loading spinners only appear when there is no cached state to show.**

This is not a performance budget (that lives in `web.md § Performance`). It is a **behaviour contract** — the same UX expectation, satisfied identically across XState-driven studio surfaces and TanStack Query-driven engagement projects.

The contract has five composing layers plus a vocabulary (staleness tiers) for which surfaces use which knobs. Each app declares its tiers; the rest follows.

---

## Architecture — five layers of SPA-feel

```
   first request                              subsequent navigation
        ▼                                              ▼
 ┌──────────────────────────────┐    ┌──────────────────────────────┐
 │ 1. Server-side data cache    │    │ 3. Client router cache       │
 │    R2 + DO via OpenNext      │───▶│    Next 16 cacheLife `stale` │
 │    `'use cache'` + cacheLife │    │    30 s minimum; default 5 m │
 └──────────────────────────────┘    └──────────────────────────────┘
        │                                              │
        ▼                                              ▼
 ┌──────────────────────────────┐    ┌──────────────────────────────┐
 │ 2. Realtime auto-sync        │    │ 4. Link prefetch             │
 │    Supabase broadcast layer  │───▶│    viewport / hover / focus  │
 │    in-session freshness      │    │    RSC payload pre-warmed    │
 └──────────────────────────────┘    └──────────────────────────────┘
                                                       │
                                                       ▼
                                     ┌──────────────────────────────┐
                                     │ 5. Optimistic UI             │
                                     │    `useOptimistic` + Server  │
                                     │    Actions + `updateTag`     │
                                     └──────────────────────────────┘
```

Layer 1 makes first-paint fast. Layer 2 keeps that paint fresh across sessions and users. Layer 3 makes back-forward instant. Layer 4 makes click-through feel instant. Layer 5 makes writes feel instant. None of them works as designed without the others — they are mutually reinforcing, not alternatives.

---

## Staleness tiers (the vocabulary)

Every cached read declares its tier explicitly. The tier is part of the domain's identity, not a tuning parameter — moving a domain between tiers is a product decision reviewable in code.

Three tiers, expressed in product language rather than just numbers:

- **REFERENCE — `cacheLife('hours')`.** Data the operator perceives as edited "occasionally" (clients, contracts, project metadata, taxonomies, organisation roles). A 30-minute-old client name is acceptable. **Server-cached.**
- **OPERATIONAL — `cacheLife('minutes')`.** Data the operator perceives as edited "during a working session" (leads, projects, payments, facade headers, buildings). A 1-minute-old lead status while typing is acceptable. **Server-cached, with `updateTag` on every write.**
- **HOT — no server cache.** Data the operator perceives as live (block grid status, messages, instructions, assignments). The realtime layer is the freshness contract; any server cache between DB and client introduces a third level of staleness for negligible benefit and complicates invalidation.

### Initial studio assignments

| Domain | Tier | Rationale |
|---|---|---|
| Clients, contracts, contract clauses | REFERENCE | Edited weekly. |
| Workers, organisation roles | REFERENCE | Edited monthly. |
| Leads, payments, projects | OPERATIONAL | Edited multiple times per session. |
| Buildings, facade headers | OPERATIONAL | Edited per project setup, then dormant. |
| Blocks (facade grid status) | HOT | The grid IS the work surface. |
| Messages, instructions, participants | HOT | Conversational; staleness is wrong by definition. |
| Assignments | HOT | Interactive UI; perceived latency matters. |

### Where the constants live

Studio (XState) side — `packages/state/src/tiers.ts`:

```ts
export const STALE_REFERENCE = 5 * 60_000;
export const STALE_OPERATIONAL = 60_000;
export const STALE_HOT = 0;
```

Engagement (TanStack Query) side — same triplet, re-exported from the engagement's `@<scope>/db` package (Vivod ships them from `@vivod/db/keys`). Cross-stack name parity is intentional.

### Two TTLs per tier — server-side and client-side

A tier name maps to **two coordinated TTLs**, applied at different layers:

| Tier | Server-side (Next `cacheLife` profile) | Client-side (`STALE_*` constant) |
|---|---|---|
| REFERENCE | `cacheLife('hours')` — revalidate 1h, expire 1d | `STALE_REFERENCE = 5 min` |
| OPERATIONAL | `cacheLife('minutes')` — revalidate 1min, expire 1h | `STALE_OPERATIONAL = 1 min` |
| HOT | (no server cache) | `STALE_HOT = 0` (always refetch on mount; realtime drives freshness) |

The server-side profile governs the OpenNext R2-backed cache (when the read is wrapped in `'use cache'`). The client-side constant governs the XState / TanStack Query in-memory cache. They are independent; both must be set consistently for a tier to mean what it says.

---

## Layer 1 — Server-side caching on Cloudflare

### Primitive: `'use cache'` + `cacheLife` + `cacheTag`

Next 16 ships Cache Components as the programming model: all dynamic code runs at request time **by default**; caching is opt-in via the `'use cache'` directive. The compiler auto-derives the cache key from the function's arguments and captured closure variables — no manual key namespace, no `unstable_cache(fn, [keys])` boilerplate.

### Hard rule — tenant isolation under closure capture

Next 16's `'use cache'` compiler captures outer-scope variables into the cache key — **except non-serializable values, which become "opaque references" and are NOT part of the key** (per [`/docs/app/api-reference/directives/use-cache`](https://nextjs.org/docs/app/api-reference/directives/use-cache)).

This is a real session-leak risk for any code wrapping a Supabase **server client** (which carries auth context and is non-serializable). Two distinct calls from two distinct tenants will be treated as cache-identical.

**Rules — non-negotiable:**

1. **`'use cache'` functions SHALL NOT capture the Supabase server client via closure.** Either use an admin client (no per-request auth) and pass `tenantId` / `orgId` / `userId` as an **explicit primitive argument**, or do not wrap the read.
2. **Every `cacheTag` SHALL include the tenant segment:** `cacheTag('tenant:${orgId}:clients')`. A tag without the tenant segment will cross-invalidate tenants on `updateTag` and is grounds for review rejection.
3. **A Forge spec-fidelity reviewer rule + an ESLint custom rule SHALL detect:** `'use cache'` in any file importing the server Supabase client unless the function signature includes an explicit `orgId` / `tenantId` / `userId` primitive arg AND a `cacheTag('tenant:${id}:…')` call is present.

REFERENCE-tier read — the correct shape:

```ts
'use cache'
import { cacheLife, cacheTag } from 'next/cache'
import { adminClient } from '@/lib/supabase/admin'   // no auth context

export async function getOrgClients(orgId: OrganizationId) {
  cacheLife('hours')                       // REFERENCE tier
  cacheTag(`tenant:${orgId}:clients`)      // tenant segment mandatory
  return adminClient.from('clients').select('*').eq('org_id', orgId)
}
```

OPERATIONAL-tier read: `cacheLife('minutes')`. HOT tier: do not declare `'use cache'`.

`cacheLife` profiles are built into Next 16 (no `unstable_` prefix as of 16.2):

| Profile | stale (client) | revalidate (server) | expire |
|---|---|---|---|
| `default` | 5 min | 15 min | never |
| `seconds` | 30 s | 1 s | 1 min |
| `minutes` | 5 min | 1 min | 1 hour |
| `hours` | 5 min | 1 hour | 1 day |
| `days` | 5 min | 1 day | 1 week |
| `weeks` | 5 min | 1 week | 30 days |
| `max` | 5 min | 30 days | 1 year |

Custom profiles go in `next.config.ts § cacheLife: { ... }`. The studio sticks to built-ins unless a domain genuinely needs a custom envelope.

`unstable_cache` continues to work on the back-compat path; new code uses `'use cache'`. Migration is non-urgent (no code touches it today — see § Current state).

### Invalidation: `updateTag` for forms, `revalidateTag` for everything else

Two distinct semantics, two distinct APIs:

| API | Semantics | Use when |
|---|---|---|
| `updateTag(tag)` | **Read-your-writes.** Expires the tag; the next read waits for fresh data. Server-Actions-only. | Form submissions, settings, anything where the operator must immediately see their own persisted state. |
| `revalidateTag(tag, 'max')` | **SWR.** Returns cached immediately, refreshes in background. **Second arg required as of Next 16.** | Background mutations, batch operations, denormalised aggregations. |
| `revalidatePath(path)` | Path-rather-than-tag, otherwise same as `revalidateTag`. | When tag enumeration is awkward (large dynamic segment trees). |
| `refresh()` | Refreshes uncached data only; does not touch cache. | Surrounding widgets that read live state alongside a mutation. |

Single-arg `revalidateTag(tag)` is deprecated and **silently no-ops on `@opennextjs/cloudflare < 1.19.0`** — always pass a profile, always pin the floor.

**Hard rule — `updateTag` is forbidden for derived/aggregated reads** (counters, sums, materialised projections, ranking aggregates). These need DB-level optimistic concurrency (`SELECT … FOR UPDATE`, advisory locks, or atomic counters), not cache busting. `updateTag` writes don't serialize with each other or with concurrent reads — two concurrent follows on the same count produce wrong totals. See § Known upstream bugs.

Typical Server Action shape:

```ts
'use server'
export async function createClient(input: CreateClientInput) {
  const result = await adminClient.from('clients').insert({ ...input, org_id: orgId })
  updateTag(`tenant:${orgId}:clients`)   // read-your-writes
  return result
}
```

### Tag namespacing

Every tag is `tenant:<scope>:<domain>`. Three segments, three meanings:
- `tenant:` literal prefix.
- `<scope>` — the org / project / idea / user identifier (the tenant unit for the domain).
- `<domain>` — the conceptual data set (`clients`, `contracts`, `instructions`).

A tag missing the `tenant:<scope>` prefix is a cache-leak waiting to happen and fails review. The ESLint / Forge rule (above) enforces this lexically.

### `force-dynamic` is forbidden — with one escape hatch

Next 16 with Cache Components rejects `export const dynamic = 'force-dynamic'`. Pages are dynamic by default; only `'use cache'` makes them static.

Replace:
- `force-dynamic` on an auth-walled page → delete the directive (default dynamic).
- `force-dynamic` on a public page → if it should be static, declare `'use cache'` at the top of the page module with the right `cacheLife`.
- `force-static` → `'use cache'` (no `cacheLife` defaults to `default` profile).
- `export const revalidate = N` → `cacheLife({ revalidate: N })` inside a `'use cache'` function.

**Escape hatch:** `force-dynamic` MAY be used on routes that hit one of these documented Next.js bugs, until the upstream fix lands:

- [vercel/next.js#86577](https://github.com/vercel/next.js/issues/86577) — Activity-preserved components don't unmount on navigation (mount-time `useEffect` doesn't re-run, dropdowns stay open, hidden DOM lingers).
- [vercel/next.js#88535](https://github.com/vercel/next.js/discussions/88535) — Stale `searchParams` after `router.push` (Activity preserves the old query).

Each `force-dynamic` use SHALL link the issue number it works around in a code comment. When the upstream issue closes, the workaround is removed in the same PR.

### OpenNext backing: R2 + Durable Objects (NOT KV)

`apps/studio/open-next.config.ts` declares the cache backend. Target shape:

```ts
import {
  defineCloudflareConfig,
  r2IncrementalCache,
  doShardedTagCache,
  doQueue,
  purgeCache,
  withRegionalCache,
} from '@opennextjs/cloudflare'

export default defineCloudflareConfig({
  incrementalCache: withRegionalCache(r2IncrementalCache, { mode: 'long-lived' }),
  queue: doQueue,
  tagCache: doShardedTagCache,
  enableCacheInterception: true,
  cachePurge: purgeCache({ type: 'direct' }),
})
```

**Use R2, not KV, for the incremental cache.** OpenNext explicitly discourages KV: it is eventually consistent globally (≤ 60 s propagation window) and can persist stale data indefinitely under `updateTag` / `revalidateTag` semantics. R2 + Durable Object sharded tag cache gives single-writer consistency for tag invalidation and durable cross-region object storage.

**Wrap R2 in `withRegionalCache`.** Required, not optional — R2 PUT latency is non-trivial and the regional cache layer is what makes R2 tolerable as a hot-path read backing. OpenNext docs treat this as canonical.

D1 tag cache is acceptable for very-low-traffic engagements; studio apps cross that threshold immediately, so use Durable Objects.

The Durable Object queue handles ISR revalidation — up to 10 instances × 5 parallel = 50 concurrent revalidations.

### Marketing-site exception (`apps/web`)

`apps/web` ships ~6 static marketing routes plus a small intake API. It does NOT need R2-backed incremental cache and should NOT incur the DO cost. Configure with static-asset caching only:

```ts
export default defineCloudflareConfig({})   // no incrementalCache / queue / tagCache
```

When `apps/web` later ships dynamic routes that warrant revalidation, upgrade to the full R2 + DO setup. Today the empty config is correct for marketing.

This split also avoids the documented R2 hang on builds with > 300 prerendered routes ([opennextjs#637](https://github.com/opennextjs/opennextjs-cloudflare/issues/637)) — `apps/studio` has ~26 pages, well under the threshold; `apps/web` has fewer but uses static-asset caching anyway.

### Local dev limitations

`doShardedTagCache` does **not** function under `wrangler dev` ([OpenNext known issues](https://opennext.js.org/cloudflare/known-issues)). Invalidation testing requires a deployed staging Worker — `wrangler dev` is enough for read-path debugging but cannot reproduce `updateTag` / `revalidateTag` behaviour. Plan a staging deploy step in any PR touching cache invalidation.

### CI smoke test — mandatory

`'use cache'` with no incremental cache backend configured **silently no-ops** on serverless runtimes (each request gets a fresh isolate; in-memory LRU cache dies invisibly). This is the most expensive failure mode in this architecture: dev works, prod under-performs, no error.

**CI requirement:** any PR adding a new `'use cache'` boundary SHALL be gated on a smoke test that:
1. Deploys the PR's worktree to a staging Worker.
2. Hits a known cached endpoint twice in quick succession.
3. Asserts the second request shows a cache hit (via response header instrumentation or worker logs).
4. Fails the PR if hit rate is 0.

This catches the silent-no-op directly, before merge to main.

### Eventual consistency window

R2 reads at the edge are ~10–30 ms; `revalidateTag` / `updateTag` propagation via sharded DO is within seconds globally (single-writer DO semantics). For REFERENCE-tier domains (5-minute staleness budget) this is below noise. For OPERATIONAL: use `updateTag` so the writing operator does not wait for SWR. For HOT: not server-cached — moot.

---

## Layer 2 — Realtime auto-sync contract

The behaviour every UI surface must satisfy when its tier is HOT, and many OPERATIONAL surfaces where multi-session visibility matters.

### Two transport channels — pick by throughput

Supabase Realtime ships two transport mechanisms for row changes; the studio uses both, in different contexts:

| Channel | Throughput envelope | Latency (Supabase published) | Use when |
|---|---|---|---|
| **`realtime.broadcast_changes`** (Postgres trigger → Broadcast) | Benchmarked at 80 k concurrent connections | **p95 132 ms, p99 159 ms** | HOT-tier domains; any surface multiple users actively watch; any volume where Postgres Changes would saturate WAL processing |
| **Postgres Changes** (raw WAL → RLS check per subscriber) | No published benchmark under RLS at scale | Unknown / variable | OPERATIONAL-tier surfaces with low subscriber count; admin / cost dashboards where a 1-second jitter is invisible |

Supabase's own architecture docs warn: "if you need changes to arrive fast and can tolerate occasional gaps, use Realtime" and "consider a separate 'public' table without RLS" for scale ([Supabase Realtime architecture](https://supabase.com/docs/guides/realtime/architecture), [discussion #5641](https://github.com/orgs/supabase/discussions/5641)). The `broadcast_changes` pattern fixes both concerns: the trigger fires once per row change (RLS evaluated at trigger time, not per subscriber), the payload goes through the broadcast layer which has the published latency bounds.

### Producer-side wiring (database)

For HOT-tier domains, a Postgres trigger publishes the row change via `realtime.broadcast_changes`:

```sql
create or replace function broadcast_idea_message_changes()
returns trigger as $$
begin
  perform realtime.broadcast_changes(
    'canvas:' || coalesce(new.canvas_id, old.canvas_id),  -- channel name
    tg_op,                                                 -- INSERT | UPDATE | DELETE
    tg_op,
    tg_table_name,
    tg_table_schema,
    new,
    old
  );
  return coalesce(new, old);
end;
$$ language plpgsql security definer;

create trigger messages_broadcast
  after insert or update or delete on messages
  for each row execute function broadcast_idea_message_changes();
```

This is migration territory (`supabase/migrations/`) and lives alongside the table definition.

For OPERATIONAL-tier domains, no trigger — the consumer subscribes to `postgres_changes` and accepts the variable latency.

### Consumer-side observable obligations

Regardless of transport, every adapter SHALL satisfy these obligations:

- **Bound on remote-change visibility:**
  - For `broadcast_changes` channels: **p95 ≤ 2 seconds, p99 ≤ 5 seconds** (achievable, backed by Supabase's published benchmarks).
  - For raw `postgres_changes` channels: **p95 ≤ 10 seconds** (loose, since Supabase publishes no benchmarks under RLS).
- **Authority delegation to RLS.** Subscriptions SHALL NOT add channel-side filtering or per-user logic; row-level visibility is governed by the same `SELECT` policy that gates queries (and, for `broadcast_changes`, by the trigger's own RLS evaluation).
- **Reconnect-through-refetch.** On `CHANNEL_ERROR` / `CLOSED`, the runtime SHALL refetch authoritative state from the server before resuming live updates. Events arriving before the refetch completes are discarded; post-refetch state is canonical. **Load-bearing:** Supabase explicitly does not guarantee delivery, and missed events on reconnect are documented ([discussion #5641](https://github.com/orgs/supabase/discussions/5641)).
- **Visibility-change refetch.** Browsers throttle WebSocket heartbeats on background tabs, causing the server to consider the client disconnected ([Supabase troubleshooting](https://supabase.com/docs/guides/troubleshooting/realtime-handling-silent-disconnections-in-backgrounded-applications-592794)). On `document.visibilitychange → 'visible'`, the adapter SHALL refetch authoritative state (same path as reconnect).
- **Burst coalescing.** Bursts for the same key SHALL produce at most one render at the consumer per ~250 ms window.
- **Silent failure.** Subscription errors SHALL log to Sentry as breadcrumbs; the runtime SHALL NOT surface "offline" toasts, "live" indicators, or reconnection banners by default.

### Subscriber must live in a client boundary

`supabase-js` has a documented WebSocket initialisation race in Node-like runtimes (where `global.WebSocket` is undefined initially — [supabase-js#1559](https://github.com/supabase/supabase-js/issues/1559)). This is exactly OpenNext-on-Workers SSR.

**Rule:** Realtime subscriptions SHALL be opened only from `'use client'` boundaries. Server Components MAY initialise an initial fetch but MAY NOT call `.subscribe()`. The adapter package surfaces this via export shape — XState's `supabaseRealtimeBridge` is a client-only actor source; TanStack's `useRealtimeTable` is a hook, by definition client-only.

### Adapter-binding matrix

Every adapter SHALL expose the six obligations above, satisfied differently per stack but observably identical:

| Obligation | Surface | Failure if missing |
|---|---|---|
| Subscribe to a `(channel, scope)` pair | hook / actor signature | UI silently goes stale; operator trusts wrong data |
| Invalidate or patch the local cache on each event | adapter-internal | UI shows pre-event state until manual refetch |
| Emit `CONNECTED` / `DISCONNECTED` to the consumer | event / state-machine event / observable | Consumer cannot enter a `reconnecting` mode |
| Refetch authoritative state on reconnect, **before** resuming live patches | adapter-internal | Lost-event window; skipped row state |
| Refetch on `visibilitychange → 'visible'` | adapter-internal | Background-tab return shows stale state |
| Coalesce burst events within ~250 ms | adapter-internal | UI thrashes during bulk operations |
| RLS-only authorisation | omission of channel-side filter | Over-permissive or over-restrictive subscriptions |

### XState adapter — `supabaseRealtimeBridge` (live)

Already documented in `state.md § Actor sources`. Mapping:

| Obligation | XState binding |
|---|---|
| Subscribe (channel, scope) | `subscriptions[]` config with `channel`, `event`, `filter` |
| Cache patch on event | parent machine handles typed events and assigns to context |
| `CONNECTED` / `DISCONNECTED` | synthetic `REALTIME.CONNECTED` / `REALTIME.DISCONNECTED` events |
| Reconnect-refetch | parent machine transitions to `reconnecting`, invokes loader actor, returns to `connected` |
| Visibility-change refetch | parent machine `VISIBILITY.VISIBLE` event triggers same reconnect path (follow-up issue) |
| Burst coalescing | 250 ms window in the bridge (RAP-270) |
| RLS-only | filter is a Postgres `WHERE`; no channel-side ACL |

### TanStack Query adapter — `useRealtimeTable` (Vivod#267)

| Obligation | TanStack binding |
|---|---|
| Subscribe (channel, scope) | `useRealtimeTable(channel, queryKey)` |
| Cache patch on event | `INSERT/DELETE → invalidateQueries`, `UPDATE → setQueryData` for flat arrays, else invalidate |
| `CONNECTED` / `DISCONNECTED` | `options.onConnected` / `onDisconnected` callbacks (Vivod follow-up) |
| Reconnect-refetch | `queryClient.invalidateQueries({ queryKey, refetchType: 'active' })` on transition (Vivod follow-up) |
| Visibility-change refetch | TanStack Query's built-in `refetchOnWindowFocus: true` covers it; verify enabled per-query |
| Burst coalescing | 250 ms `requestAnimationFrame` window (Vivod#267) |
| RLS-only | hook does not accept channel filters; relies on publication + RLS |

### Why two adapters, not one wrapper

A single wrapper hiding XState vs TanStack would couple state-machine semantics to query-cache semantics; consumers would lose the ergonomic wins of either. Two adapters satisfying the same observable contract is the right factoring — the contract is the seam, not the implementation. The cost is a small parity-tracking matrix. The benefit is each adapter stays idiomatic.

This dual-adapter-one-contract pattern has no obvious prior art in published work (we are the canary). Mitigation: ship each adapter alongside its first concrete product, never speculatively; track divergences in the conformance suite, fix in the contract first, then in both adapters.

---

## Layer 3 — Client router cache

The cache that makes back-forward navigation feel like a SPA. Driven by the `stale` value of each `cacheLife` profile, communicated via the `x-nextjs-stale-time` response header on RSC payloads.

- **30 seconds is the minimum enforced regardless of configuration.** This keeps prefetched links from going stale before the user clicks them.
- **Default profile stale = 5 minutes for static segments.** Dynamic segments inherit the per-route `cacheLife` profile's `stale`.
- **Server Actions calling `revalidateTag` / `updateTag` / `refresh` immediately clear the entire client cache.** This is how the cache stays honest across mutations without a per-component subscription.

### HOT-tier rendering rule

**The 30 s minimum stale window applies to the RSC payload itself.** A HOT-tier domain whose RSC payload was generated 25 seconds ago will be returned to a back-navigating user as cached — even if the underlying row has changed since. Realtime fixes row data, not the RSC blob.

**Rule:** HOT-tier surfaces SHALL render their primary data **client-side from the realtime cache** (XState context / TanStack Query cache), not from a Server Component's serialised payload. The Server Component renders the shell + initial fetch; the client component subscribes and owns subsequent renders. Mixing the two produces a 30-second window where the operator sees stale state on back-navigation, which is the bug Layer 2 was supposed to fix.

OPERATIONAL and REFERENCE surfaces may render from the RSC payload — their tier already permits a 1-minute / 5-minute lag.

### Current state — `experimental.staleTimes` fallback

`apps/{web,studio}/next.config.ts` currently sets:

```ts
experimental: {
  staleTimes: { dynamic: 30, static: 180 },
}
```

This is the Next 15 syntax; Next 16 still honours it as the global default profile's `stale` (dynamic) and `default` profile's static stale. **Migration target:** delete the `staleTimes` block once every domain has explicit `cacheLife` declarations colocated with its `'use cache'` reads. Until then the global default holds.

---

## Layer 4 — Link prefetch

`<Link>` defaults in production (Next 16):

- **Static target** → full route prefetched on viewport entry; client cache retained until app reload.
- **Dynamic target** → prefetched only if a `loading.tsx` exists; payload is up to the first Suspense boundary; client TTL = `cacheLife.stale` (≥ 30 s).

Prop values: `prefetch={true | false | null | 'auto'}`. `prefetch={null}` (or omitting) is `'auto'`. `prefetch={false}` disables **both** viewport and hover prefetch.

### Hover prefetch for high-cardinality lists

There is **no native `prefetch="hover"`** value in Next 16. For tables with 200+ rows where viewport-prefetching every visible row is wasteful, the studio convention is a wrapper component that flips `prefetch` from `false` (default) to `null` on hover / focus.

**Trigger events — `onPointerEnter` + `onFocus`, NOT `onMouseEnter`** (touch and a11y parity per Next docs). Reference implementations:

- **XState side:** `packages/ui/src/composed/machine-prefetch-link.tsx` — `<MachinePrefetchLink href machine event>` (RAP-271).
- **TanStack Query side (Vivod):** `<PrefetchLink href prefetchKey prefetchFn>` per Vivod#267.

Both call a stack-side data prefetch alongside the framework prefetch — TanStack calls `queryClient.prefetchQuery`, XState sends a `PREFETCH` event to the machine that warms its loader actor.

### Next 16 routing improvements (automatic)

Three optimisations apply without per-component effort:
- **Layout deduplication** — 50 links sharing a layout → 1 layout payload prefetched, not 50.
- **Incremental prefetching** — only segments not already cached are fetched.
- **Viewport-exit cancellation** — pending fetches abort when their link scrolls off-screen.

The net effect is more individual requests but much lower total bytes.

---

## Layer 5 — Optimistic UI

React 19's `useOptimistic` is the canonical pattern for any mutation where the user expects immediate feedback before the server round-trip completes.

```tsx
'use client'
import { useOptimistic } from 'react'
import { toggleAssignment } from './actions'

function AssignmentToggle({ assignment }: { assignment: Assignment }) {
  const [optimistic, addOptimistic] = useOptimistic(
    assignment,
    (state, next: boolean) => ({ ...state, assigned: next }),
  )
  return (
    <form action={async (formData) => {
      addOptimistic(formData.get('assigned') === 'true')
      // MUST throw on failure — return-on-error disables rollback.
      const result = await toggleAssignment(assignment.id, formData)
      if (!result.ok) throw new Error(result.error)
    }}>
      <input type="hidden" name="assigned" value={!optimistic.assigned ? 'true' : 'false'} />
      <button type="submit">{optimistic.assigned ? '✓ Assigned' : 'Assign'}</button>
    </form>
  )
}
```

### Hard rule — throw on failure

**Server Actions consumed by `useOptimistic` SHALL throw on failure, not return a `{ ok: false }` error envelope.** React rolls back optimistic state only when the action throws — silent-error returns leave the optimistic state on screen indefinitely while the user assumes the write succeeded. This is a documented React 19 footgun ([facebook/react#31967](https://github.com/facebook/react/issues/31967)).

A documented timing bug ([vercel/next.js#49619](https://github.com/vercel/next.js/issues/49619)) can also cause a brief flash between optimistic rollback and the next revalidated render. Mitigation: pair `useOptimistic` with `updateTag` (read-your-writes) inside the action so the post-network state matches the optimistic prediction without an SWR window.

### When to use

- Toggles, likes, reorders, soft-deletes
- Chat messages, instructions, idea assignments
- Form submissions whose result is reflective ("you changed your name") rather than transactional ("you sent money")

### When NOT to use

- Financial actions, money transfers — the operator should see the network confirmation explicitly.
- Destructive irreversible writes — the optimistic-rollback UX is misleading.
- Multi-step transactions where partial success is possible — show the network state.
- **Derived / aggregated counters** — see `updateTag` forbidden cases above.

---

## Adapter conformance

Two adapters today. A third may be added only if the new stack provides ergonomic wins the existing two cannot match; the adapter MUST satisfy all five layers and MUST ship alongside its first product, never as a speculative library.

Conformance is enforced three ways, in increasing rigour:

1. **Documentation parity.** Every obligation row (Layer 2 matrices) names the adapter binding. An adapter that fails to document a row fails review.
2. **Test parity.** Both adapters ship a conformance test suite hitting the same Layer 2 obligations against a shared Supabase fixture.
   - Fixture: `openspec/_methodology/research/realtime-conformance-fixture.md`
   - XState suite: `packages/state/src/__tests__/realtime-conformance.test.ts`
   - TanStack suite: ships in the engagement's repo (Vivod-side issue tracked).
3. **Engagement adoption checklist.** Every kickoff runbook includes a `## Frontend runtime adoption` row with checkboxes: tier assignments documented; `force-dynamic` audit done; broadcast_changes triggers in place for HOT domains; adapter conformance test green; `updateTag` / `revalidateTag` audit done. The studio does not approve a tarball export without it.

If an adapter discovers a contract gap during real use, the gap is fixed in this spec first, then in both adapters. Asymmetric features (one adapter has a behaviour the other doesn't) are forbidden.

---

## Known upstream bugs

Compact reference list — kept here so future readers see the runtime landscape this spec was authored against (2026-05-11).

### Next.js / React

- [vercel/next.js#86577](https://github.com/vercel/next.js/issues/86577) — Activity-preserved components don't unmount on navigation. **Workaround:** `force-dynamic` on the affected route (see § `force-dynamic` escape hatch).
- [vercel/next.js#88535](https://github.com/vercel/next.js/discussions/88535) — Stale `searchParams` after `router.push` with `cacheComponents: true`. **Same escape hatch.**
- [vercel/next.js#49619](https://github.com/vercel/next.js/issues/49619) — `useOptimistic` reverts after action finishes but before revalidated render → flash of stale value. **Mitigation:** pair with `updateTag`.
- [vercel/next.js#88038](https://github.com/vercel/next.js/discussions/88038) — `'use cache'` silently no-ops without backing store. **Mitigation:** CI smoke test for hit-rate.
- [facebook/react#31967](https://github.com/facebook/react/issues/31967) — `useOptimistic` rolls back silently on errors not displayed in console. **Mitigation:** throw-on-failure rule.

### OpenNext / Cloudflare

- [opennextjs/opennextjs-cloudflare#637](https://github.com/opennextjs/opennextjs-cloudflare/issues/637) — R2 incremental cache hangs on builds with >300 prerendered routes. **Mitigation:** `apps/web` uses static-asset caching only.
- [opennextjs/opennextjs-cloudflare#1157](https://github.com/opennextjs/opennextjs-cloudflare/issues/1157) — Next 16.2.0 crashed Workers until OpenNext 1.17.3+. **Mitigation:** version pin (see § Where this lives).
- [opennextjs/opennextjs-cloudflare#572](https://github.com/opennextjs/opennextjs-cloudflare/issues/572) — cost concerns at scale for marketplaces. **Mitigation:** keep marketing on static assets; budget for studio R2/DO.
- [OpenNext known issues](https://opennext.js.org/cloudflare/known-issues) — `doShardedTagCache` doesn't work under `wrangler dev`. **Mitigation:** staging deploy for invalidation testing.

### Supabase

- [supabase/supabase-js#1559](https://github.com/supabase/supabase-js/issues/1559) — WebSocket initialisation race in Node-like runtimes (Workers SSR). **Mitigation:** subscribe only from `'use client'` boundaries.
- [supabase/discussions#5641](https://github.com/orgs/supabase/discussions/5641) — Missed events on reconnect; no delivery guarantee. **Mitigation:** reconnect-refetch obligation (load-bearing).
- [Supabase troubleshooting — background-tab heartbeat throttling](https://supabase.com/docs/guides/troubleshooting/realtime-handling-silent-disconnections-in-backgrounded-applications-592794). **Mitigation:** `visibilitychange` refetch obligation.

This list is not exhaustive; new bugs SHALL be appended as they are encountered in studio code, with the mitigation in the same patch.

---

## Current state vs target state (2026-05-11)

Faithful map of what's in code today versus what this contract specifies. The gap is the implementation backlog (tracked under Epic RAP-266).

| Layer | Current | Target | Gap |
|---|---|---|---|
| 0. Version pins | `next@16.2.1`, `@opennextjs/cloudflare@^1.18.1` | `next≥16.2.3`, `@opennextjs/cloudflare≥1.19.0` | minor bump in both apps, atomic commit |
| 1. Server-side cache | `defineCloudflareConfig({})` empty in both apps | studio: R2 + DO sharded tag + DO queue + regional cache wrap. web: empty (static assets) | configure `apps/studio/open-next.config.ts`; deploy + verify R2 hit rate via CI smoke test |
| 1. `'use cache'` directives | none in `apps/` or `packages/` | every REFERENCE / OPERATIONAL read with tenant-isolation rule satisfied | per-domain audit + wrap (RAP-277) + ESLint rule |
| 1. `updateTag` calls | none | every mutating Server Action in tagged domains | per-action audit (RAP-278) |
| 1. `force-dynamic` | 2 occurrences in `apps/studio` | removed where realtime backs the surface; retained only on escape-hatch routes | per-page audit (RAP-276) |
| 2. Realtime transport | raw Postgres Changes via `supabaseRealtimeBridge` | `broadcast_changes` triggers for HOT domains; Postgres Changes for OPERATIONAL low-traffic | per-HOT-domain trigger migration (new tickets, not yet filed) |
| 2. Realtime obligations | `supabaseRealtimeBridge` conforms (5/6); Vivod `useRealtimeTable` conforms with follow-ups | both adapters conform incl. visibility-change refetch | add visibility-change refetch path to both adapters |
| 3. Router cache | `experimental.staleTimes: { dynamic: 30, static: 180 }` (RAP-275, shipped) | `cacheLife` per-segment | migrate when each domain gets `'use cache'` |
| 4. Hover prefetch | XState: `MachinePrefetchLink` shipped (RAP-271); TanStack: `<PrefetchLink>` in Vivod#267 | adopted in every high-cardinality list | per-list adoption, no single ticket |
| 5. Optimistic UI | scattered patterns | `useOptimistic` + `updateTag` everywhere mutations meet UI; throw-on-failure mandate | per-form audit (not yet ticketed) |

The order of operations matters: bump versions → configure OpenNext backing → deploy + verify smoke test → wrap reads in `'use cache'` → add `updateTag` → migrate HOT domains to `broadcast_changes` → introduce optimistic UI. Skipping ahead causes silent no-ops or invisible regressions.

---

## Boundaries

- **Not a state-management standard.** Whether a product uses XState, TanStack Query, Zustand, or plain `useState` is governed by `state.md` and per-product taste. This capability specifies what those choices must collectively guarantee, not how to pick.
- **Not a data-fetching standard.** Server Actions vs route handlers vs RSC fetch is governed by `web.md` / `studio-workspace/README.md`. This capability assumes Server Actions are the primary mutation path (already canon) but does not legislate read paths.
- **Not a performance SLO.** Layer 2's latency bounds are behaviour contracts; HTTP-level latency SLOs live in `web.md § Performance budget`.
- **No new package.** The contract is documentation; implementations live in `packages/state`, app-level configs, project hooks.
- **No automatic cache wrapper.** No `wrapCached(domain, tier, fn)` helper. Per-domain `'use cache'` + `cacheLife(<tier>)` + `cacheTag(<tag>)` is more readable and locally reviewable than a three-line wrapper indirection.

---

## Out of scope

- **Retroactive Vivod migration.** Vivod#267 already conforms to Layers 1–4 on the TanStack Query side. Vivod-specific follow-ups (callback events, reconnect-refetch, visibility-change) are tracked in Vivod's own repo.
- **`expireTag` / `expirePath` API.** Mentioned in Next.js PR #72485 but not the documented current path; `revalidateTag(tag, 'max')` + `updateTag(tag)` are the canonical Next 16 trio today. Revisit if Next 16.3+ promotes `expireTag` to the recommended API.
- **Backwards-compatibility shims.** No flag, no dual-mode for the Layer 1 migration. When R2 backing lands, it lands; when a page drops `force-dynamic`, it drops.
- **Server-side prefetch coordination.** Some Vivod patterns prefetch in the Server Component AND subscribe in the client (cold cache + warm client). This doubles the first-paint DB hit but is correct per-stack. Whether to legislate one-or-the-other is left to per-product judgment until a measured cost emerges.
- **DB-level optimistic concurrency primitives** (advisory locks, atomic counter functions). These are `db/README.md` territory and complement — not replace — the cache-busting rules here.

---

## Open questions

1. **`@opennextjs/cloudflare` 1.19.x — does `cacheLife` profile-name pass-through to R2 + DO tag cache?** OpenNext caching docs do not yet document Next 16 `cacheLife` profile semantics explicitly. **Action:** test directly with a single REFERENCE-tier read once R2 backing is enabled; encode the verdict here.
2. **Edge CDN cache propagation from Next.js `Cache-Control` headers.** Workers in front of Cloudflare's CDN bypass the standard edge cache unless `caches.default` is explicitly used. **Action:** verify with a known cacheable response; document in `web.md § Route caching policy`.
3. **`'use cache: remote'` on Workers runtime.** Next 16's `cacheLife` docs example uses this variant when "runtime caching in serverless deployments doesn't persist across requests with the default in-memory cache." Whether OpenNext + R2 makes this automatic or requires opt-in per-function is unclear. **Action:** confirm against `@opennextjs/cloudflare 1.19.x` release notes when R2 backing is wired.
4. **`doShardedTagCache` throughput ceiling.** OpenNext docs show `baseShardSize: 12` as an example but never explain how to size it for write contention. **Action:** measure under expected `updateTag` rates from studio + first engagement; encode in spec.
5. **`broadcast_changes` vs `postgres_changes` migration cost.** Each HOT domain needs a Postgres trigger; the migration touches `supabase/migrations/` and the `supabaseRealtimeBridge` consumer config. **Action:** prototype migration for one domain (idea messages) and measure latency end-to-end before mandating the pattern repo-wide.
6. **SLO testing for Layer 2 latency bounds.** Currently stated as goals; not asserted by any synthetic monitor. Vivod's `E2E Synthetic Monitor` workflow could be extended. **Default:** ship contract as goal; add SLO test in a follow-up.
7. **Cross-tab coherence under `useOptimistic` + `updateTag`.** Tab A optimistic-updates → action calls `updateTag` → Cloudflare tag cache invalidates → Tab B (same user) re-fetches on next focus or realtime broadcast. Open: whether the contract should mandate broadcast on every `updateTag` to close the cross-tab gap, or whether visibility-change refetch is sufficient.

---

## Dependencies

- `state.md § Actor sources` — `supabaseRealtimeBridge` is the XState binding for Layer 2.
- `state.md § Tier constants` — `STALE_REFERENCE` / `OPERATIONAL` / `HOT` exports.
- `web.md § Route caching policy` — marketing-side cache header policy (cross-link added at apply time).
- `studio-workspace/spec.md § Frontend runtime conformance` — per-route tier + adapter declarations (added at apply time).
- `db/README.md` — Supabase Realtime publication membership, RLS authority for Layer 2, `broadcast_changes` trigger conventions.
- `platform.md § Cloudflare Workers` — runtime substrate.
- `openspec/_methodology/projects/_template-kickoff-runbook.md § Frontend runtime adoption` — operationalises the conformance checklist for new engagements.
