# ADR 0007 — The render channel (v0.8)

- **Status:** Accepted
- **Date:** 2026-07-02
- **Context:** render behavior becomes effract's fourth compile-checked channel, alongside `R`, `E`,
  and the `S` loading channel. It rides the exact pipeline [ADR 0001](./0001-fiber-reconciliation.md)
  established (channels recovered by yield-type inference, discharged at `mount`) and extends the
  devtools seam from [ADR 0005](./0005-dx-moat.md). Everything below is **default-on** — escape
  hatches exist, opt-ins do not.

## Decisions

### 1. Every reactive cell is named

`atom(label, initial)` — label first, exactly like `Context.Service<X>()('X', …)`. The label is
captured as a literal type (`<const L extends string>`) and one name serves four masters: the
type-level `RendersOn` literal, the devtools `atom:write` name, the SSR serializable key, and the
ledger attribution. `atom.serializable(key, initial, schema)` moves its key first — the key **is**
the label (this also fixes the existing gap where the serializable key never reached the devtools
name). `atomFamily(label, make, opts?)` is family-labeled; member atoms get runtime names
`` `label(key)` ``. Derived cells are never manually named — they **inherit**: `.derive(f)` carries
its source's `L` (and `V`), and the multi-source forms (decision 2) carry the union over their
sources. Labels live in the existing WeakMap (`reactivity-core.ts`), not on the atom object.

### 2. Free-form `derive` is removed; sources are explicit

`derive(($) => …)` — and `derive.writable(read, write)` / `derive.effect(($) => …)` with it — is
**removed**. It was the one primitive whose dependencies exist only at runtime: the type channel
cannot see them, so labels and volatility would have needed manual re-declaration — a lie waiting
to drift. The replacement is explicit sources: `derive([a, b], ([x, y]) => x + y)`, and the same
shape for `.writable` / `.effect`. A mapped tuple type infers the value types; `L`/`V` are the
unions over the tuple — labels and volatility flow through derives with zero annotation.
Single-source stays the ergonomic `a.derive(f)` method. Runtime dividend: the explicit forms
subscribe statically, so the dynamic dependency-tracking machinery (`sameDeps`/re-subscribe in
`reactivity-core.ts`) goes; `computation` tracking survives **only** for `observe`/`<Observe>`,
which are JSX-level observation — a different concern, unchanged by this ADR.

### 3. Volatility is an obligation, not a hint

`atom.fromStream(label, stream, initial)` yields an `Atom<A, Volatile, L>`. The `Volatile` phantom
marker copies the `Suspends` template (`domain/protocol.ts`); `V` bubbles through placements exactly
like `S` (a phantom slot on `RecPlacement`, a `VolatilityOf<Eff>` extractor), and the client `mount`
(`infrastructure/react/rec.tsx`) and `renderToStream` refuse to compile until `[V] extends [never]`
— a branded `VolatileNotHandled`, mirroring `LoadingNotHandled`. The server `mount`
(`server/mount.ts`) accepts-and-ignores, for the same reason it ignores `S`: no re-renders
server-side. Two discharges: `defer(a)` reads the atom through `useDeferredValue` (brand stripped,
label kept — the read lags instead of storming), or `.hot()` on a REC — a deliberate acknowledgment,
the `.catchDefect` of scheduling; in dev the ledger reports hot-subtree render frequency, mirroring
how absorbed defects are reported. There is no `MountOptions` discharge: unlike loading, a boundary
fallback makes no sense for scheduling.

### 4. Memoization is explicit and type-guided

`.memo()` on a REC wraps its client component in `React.memo` — and nothing is memoized without it.
The method is compile-gated: it returns a REC only when every prop is **value-comparable** — a
primitive, or an `Equal.Equal` implementor — and otherwise returns a branded
`MemoRequiresComparableProps` naming the offending prop(s); using that result as a REC is the
compile error (hold functions in a service; give structured data an `Equal` implementation). The
runtime comparator is *bounded* by construction: same own-key set, then per key `Object.is`, or
`Equal.equals` only when **both** sides implement the `Equal` trait — nothing ever deep-compares a
plain structure, so the comparator's cost is the number of props, never the size of a value. A
props-free REC qualifies trivially and skips parent-driven re-renders entirely; its own atom
subscriptions still re-render it, and `.memo()` preserves every channel (`E`/`R`/`S`/`V`/`L`).
The workflow is ledger-guided (decision 5): when the dev ledger attributes re-renders to `props`
whose values did not change, `.memo()` is the named, reviewable fix.

### 5. The dev render ledger is always on in dev

Render-cause attribution — today gated behind a connected inspector and atom-only — becomes
dev-default and complete: every render is attributed to **atom write, query settle, prop change,
lifted-hook state, or parent render** (the `render` DevEvent in `devtools-core.ts` grows a
discriminated `trigger`). Production keeps the current zero-cost `devConnected()`-style gate. The
ledger also carries a dev-only **sync↔async flip detector**: the interpreter remembers per yield
index whether an effect resolved synchronously, and a flip across renders reports through the
devtools seam — the runtime backstop for the invariant the type system cannot see (see decision on
raw yields under Alternatives).

### 6. `RendersOn<C>` is public

`RendersOn<typeof C>` is the union of label literals a REC can re-render on, computed by a
`SubscribesOf<Eff>` extractor (atoms contribute `L`; placements contribute `never` — a child's
re-render does not re-render its parent, so labels do not bubble). It is documented alongside the
repo's type-test pattern, so a render surface can be locked in CI: widen the subscription set and
the type test names the new label.

## Consequences

- Breaking, sanctioned pre-1.0 by the version policy: `atom(0)` → `atom('name', 0)`, serializable
  arg order, `fromStream` label + volatility obligation, free-form `derive` removed. The migration
  is mechanical and the compiler names every site.
- The "two compile-checked obligations" become three: `E`, `S`, `V`.
- Write-side scheduling (tagging volatile writes in `notify`) is deliberately out of scope —
  discharge stays at the read side (`defer`) or the component (`.hot()`); a future step.
- Memoization is a visible contract: `.memo()` in the diff, a compile gate that names any prop the
  comparator could not soundly compare, and a bounded comparator on the render hot path. An
  unmemoized placement re-renders with its parent exactly like plain React.
- The ledger costs dev builds a per-render trace; production is unchanged (tree-shaken consumers,
  gated core, per [ADR 0005](./0005-dx-moat.md)).

## Alternatives considered

- **Opt-in labels.** Rejected — a dead letter. An optional name is a name nobody writes; every
  downstream (RendersOn, ledger, SSR keys) would degrade to `string`. Canonical-or-nothing is the
  point of this ADR.
- **Raw effect yields carry `S` by default.** Rejected — it kills `yield* Console.log(...)`
  ergonomics and taxes every synchronous service read. The compromise is the dev-time sync↔async
  flip detector (decision 5), which catches the actual hazard at runtime in dev.
- **Volatility does not bubble through placements.** Rejected — the gate would be unreachable: any
  volatile read wrapped one component deep would silently escape `mount`, exactly the hole `S`
  bubbling was built to close.
- **Auto-memo on every placement.** Rejected after review — `Equal.equals` deep-compares plain
  structures, so the comparator cost is unbounded on the hot path (a large array or JSX-valued prop
  pays a full walk that can exceed the render it hoped to skip), and a silent optimizer contradicts
  the channel's philosophy of visible contracts. Memo is sound exactly when props are
  value-comparable — which is a fact the type system can state, so it does.
- **A runtime-only solution à la why-did-you-render.** Rejected — no agent feedback loop. A log an
  agent may or may not read is not an obligation; a `mount` that refuses to compile is.
- **Keeping free-form `derive` with manually declared labels/volatility.** Rejected — a manual
  re-declaration of runtime-only dependencies drifts silently; explicit sources make the type
  channel and the subscription set the same fact.
