# ADR 0002 — Async & runtime lifecycle hardening (v0.6)

- **Status:** Accepted
- **Date:** 2026-07-02
- **Context:** the v0.6 correctness milestone. Several decisions about how async work,
  cache keys, and runtimes are managed across React's render lifecycle needed recording,
  because each trades against a subtle React or Effect semantic. Builds on [ADR 0001](./0001-fiber-reconciliation.md).

## Context

ADR 0001 established the in-render interpreter and the suspensable store. v0.6 hardens the
edges that ADR left open or under-specified: raw async leaked its fiber; cache keys were
serialized lossily; the server reused one runtime across requests; and the client built its
runtime in render with no disposal for a discarded render. Each fix touches the seam between
React's fiber lifecycle and Effect's.

## Decisions

### 1. Raw `yield* asyncEffect` joins the sync/async probe fiber (one execution, interruptible)

The interpreter detects async by running `runSyncExit`, which fails with an `AsyncFiberError`
that **carries the still-running fiber**. Rather than re-running the effect through
`runPromise` (a second execution) or leaving the probe fiber to settle unobserved, effract
now **joins that fiber** (`Fiber.join`) and routes it through the same suspensable store as
`suspend`/`query`. On a cache hit — a later render attempt's probe fiber — the redundant
fiber is interrupted immediately. Consequences: the effect's continuation executes exactly
once across pre-commit retries and StrictMode; the fiber is interrupted (finalizers run) on
unmount; raw async and `suspend`/`query` share one cancellation standard.

The sync prefix of the effect (e.g. the `() => fetch(url)` thunk) still runs once per render
attempt — that is intrinsic to `runSyncExit`-based detection and unchanged. `query`/`suspend`
remain the recommended path for real fetches precisely because they never go through the probe.

### 2. Cache keys compared by Effect `Hash`/`Equal`, not `JSON.stringify`

The store keys entries by hashing the user key (`Hash.hash`) into a bucket and confirming
membership with `Equal.equals`. Effect v4's `Hash`/`Equal` are structural and
property-order-insensitive for plain records, arrays, `Date`s, `Map`s, and `Set`s, so
`{a:1,b:2}` and `{b:2,a:1}` hit one entry and two distinct keys never collide even if their
hashes do. An opaque class instance that implements neither is compared by identity — the
same behaviour `Equal.equals` already gives. This replaces `JSON.stringify` (property-order
sensitive; ambiguous for `Date`/`Map`) and its `String(key)` fallback (which collided every
object at `"[object Object]"`).

### 3. Per-request server runtime isolation by default

The server `mount` builds and disposes a **fresh runtime per render** by default, so per-user
state a service holds in an `atom` cannot bleed across concurrent requests — a correctness and
security fix. `{ scope: 'shared' }` opts back into one process-global runtime per layer for a
genuinely global, stateless layer. Module-level atoms (declared outside a service `make`)
remain process-global by construction and are *not* isolated by this — the documented footgun
is to keep per-request state inside a service's `make`.

### 4. Client runtime disposal via a render-phase error boundary

The interpreter needs the runtime *during* render, so it is built in render. A render that
builds a runtime and is then discarded before committing (a sibling under the same runtime
throws) would leak the runtime's scopes/finalizers, and no commit effect runs to dispose it.
A `setTimeout`-based sweep was rejected: for a suspended render, React defers the commit
arbitrarily, and the timer fires first — disposing a live runtime. A `FinalizationRegistry`
cannot dispose the collected runtime (holding a reference to call `dispose()` prevents
collection). The chosen mechanism: because `mount` wraps the whole tree under the runtime
provider, a discarded render surfaces as a **render error the provider's own error boundary
catches** — it disposes the runtime only if it was never adopted (a committed runtime is owned
by the StrictMode-safe unmount latch) and re-throws so the error still reaches the app's
boundary. This is sound (it never disposes a live runtime) and deterministic (no timer race).
A silently-abandoned concurrent render that is neither committed nor errored is not covered;
its runtime is reclaimed as ordinary garbage.

## Consequences

- Raw async is now interruptible and executes its continuation once — but its sync prefix still
  re-runs per probe; the docs steer real fetches to `query`/`suspend`.
- The store depends on Effect v4's structural `Hash`/`Equal`. If a future Effect changed that to
  identity semantics, plain-record dedup would regress — pinned by tests.
- Server renders pay a per-request layer build by default; `{ scope: 'shared' }` and
  `Layer.memoize` are the escape hatches for expensive stateless layers.
- The runtime provider now renders an internal error boundary; a REC's own `.catch` boundaries
  sit inside it and are unaffected.

## Alternatives considered

- **Re-run the effect via `runPromise` for raw async** (ADR 0001's original). Rejected: a second
  execution, and the probe fiber leaked. Joining the probe fiber is strictly better.
- **Timer / FinalizationRegistry for runtime disposal.** Rejected as above (races the deferred
  commit; can't dispose the collected object).
- **Keep the shared server runtime.** Rejected: cross-request state bleed is a security issue.
