itx implementation decisions & learnings
Status: historical decision log (pre-migration). These decisions were made while implementing the PRE-itx-v4 itx layer, deleted in the itx-v4 replacement. Kept as the record of what changed on contact with reality; the current implementation and its contract live at
apps/os/src/.
Running log of choices made while implementing the itx layer (design of
record: src/itx/types.ts; roadmap: apps/os/docs/itx-design.md),
especially where reality diverged from the plan or where it left room.
Newest entries at the bottom. See also README.md (the architecture document)
once it exists.
D1: SQLite table is the registry state; stream events are the audit record#
Spec §4.2 says "registry mutations append events to the context's stream and the registry table is the fold." Implemented as: the hosting DO's SQLite table is authoritative, mutations append audit events to the context stream best-effort (fire-and-forget with error logging). Rebuilding registry state by folding the stream on every DO wake would need snapshotting machinery for no benefit — the DO's own SQLite is the snapshot, transactional with the mutation. The stream keeps the full history for audit/UI/time-travel.
D2: No merged name index yet — cap misses delegate up the chain per call#
Spec §3.3 calls for a merged name index fetched at itx construction. For now the fallthrough proxy optimistically returns a path-proxy for any unknown name and resolution happens at invoke time inside the hosting DO; a child context DO that misses delegates to its parent. This is one extra DO hop on parent-owned caps, which is fine at current scale and deletes a whole cache-invalidation concern. Add the index only when latency data says so.
D3: Old provideCapability/getConnection stay untouched until deleted#
The legacy connection table (#capnwebConnections) uses keys like
slack-sdk that are not valid JS identifiers and are accessed via
project.connections.get(key), never via name fallthrough. Migrating them
onto the registry would mean relaxing name validation for a surface that is
scheduled for deletion (user: no backwards compatibility needed). They live
side by side until the old capnweb e2e suite is ported, then both go.
D4: The supervisor is always in the invoke path#
Even for invoke: "members" live caps we route calls through the hosting
DO's itxInvoke rather than handing the raw stub to the caller. One extra
hop buys: a single audit/policy point (per-cap egress rules later), uniform
offline errors, and no stub-lifetime leakage to remote callers. Exception:
nothing yet — if a hot path needs raw-stub borrowing we'll add it then.
D5: itx.project returns the Project DO stub directly#
Spec §9 wanted ProjectCapability's 16 forwarders gone. Workers RPC lets us
return the DO stub itself over RPC (auto-proxied by capnweb), so the project
admin surface is just... the DO's public methods. Zero forwarders, types stay
honest via ProjectCapability (the Pick<> of ProjectDurableObject).
D6: itx.projects.get() returns a narrowed Itx handle, not a project object#
Narrowing is construction (Law 4): a global handle narrows by constructing a
project-context handle. So itx.projects.get("x").streams and a directly
connected project handle's itx.streams are literally the same object shape.
The old ProjectsCapability/ProjectCapability split disappears.
D7: Simplified access model#
Per user direction: access is "all" projects, a list of named projects, or
none. ItxProps = { context, access?, cap? } where access only matters on
global-context handles. Project-context handles imply access to exactly that
project regardless of what props claim — the restorer overwrites, mirroring
the old "config worker can't escalate scopes" rule. Org-membership flows stay
in oRPC for now; itx.projects.get(slug).create is admin-only.
D8: Worker caps gain network access via ProjectEgress (they had none)#
The legacy config worker loads with globalOutbound: null. Worker caps (and
the config worker, once rewired) get globalOutbound = ProjectEgress instead:
bare fetch() routes through the Project DO egress path with secret
substitution. This is a strict capability upgrade and deletes the
fetch-monkeypatch in the old /run harness.
D9: Audit event stream path is /itx#
Registry events (itx.cap.defined etc.) append to the context's stream at
path /itx in the project namespace. Child contexts will use their own
namespace/path when ContextDO lands.
D10: Child contexts delegate misses upward per call, depth-recursive#
ContextDO.itxInvoke checks its own registry, then calls its parent's
itxInvoke (project DO or another ContextDO by id prefix). Arbitrary fork
depth works with zero index machinery; itxDescribe merges the chain with
child entries shadowing and owner carrying provenance. One DO hop per
chain level per miss — revisit only if latency data complains (see D2).
D11: The restorer is async#
Child contexts cost one descriptor lookup (ctx_… → owning project) at
restore time. ItxEntrypoint.context is a getter returning a Promise, which
await env.ITERATE.context already handled — no isolate-side change.
D12: Facet classes must be NAMED exports#
worker.getDurableObjectClass() against a default-export DO class produces a
facet whose every call fails with workerd's opaque "internal error;
reference = …" (observed locally on miniflare 4.20260424 / workerd ~2026-04;
Cloudflare's own docs only ever show named exports). define({ kind: "facet" })
therefore requires source.entrypoint, turning the footgun into an
instructive error at definition time.
D13: Facet names exclude codeId — data survives code upgrades#
The facet is cap:${name} regardless of source version, so redefining a
facet cap keeps its private SQLite database (Cloudflare's AppRunner relies
on the same property). If a cap wants a clean slate it can be revoked and
redefined under a new name; explicit facet deletion is future work.
D14: Registry logs invoke failures at the supervisor#
Errors crossing the Workers RPC boundary back to remote callers can be
masked as "internal error; reference = …". ContextRegistry.invoke logs the
real error (cap, kind, path, context) before rethrowing — the supervisor is
the one place the truth is always visible.
D15: agents.e2e "slack-agent event-mode codemode" timeout is pre-existing#
The e2e test "completes slack-agent event-mode codemode calls without blocking the stream callable queue" times out waiting for the slack-agent processor chain to emit tool-provider-registered on the routed agent stream. Verified by running the IDENTICAL test from origin/main (3cc317ad8) against a main-code dev server: it times out there too. Not caused by the itx branch; also failed on the itx preview deployment, consistent with an upstream slack-agent/stream-subscription regression that predates this work.
D16: Security hardening from adversarial review (round 1)#
- Global context is admin-only.
accessForPrincipalhands non-admins their named projects; the global ("all") handle — and global/api/itx/run, which inherits the platform's own egress with no per-projectglobalOutbound— are gated toaccess === "all". Non-admins must narrow to a named project. (Closes ambient-SSRF for authenticated non-admins.) replayPathCallfilters reserved segments server-side.itxInvokeis a public DO method reachable with a hand-builtpath; the reserved-name gate can't live only in the consumer proxy.RESERVED_PATH_SEGMENTSis now one exported set (protocol.ts) used by both the proxy and the replay, and also feedsRESERVED_CAP_NAMES, so a cap can never be named a prototype-pollution vector orFunction.prototypemember.- The path proxy never falls through to
Function.prototype. String keys always extend the path (unless reserved), so SDK methods namedname/call/bind/lengthtraverse correctly. itx.projectis a narrow facade (ItxProject: describe/ingressUrl/ getSummary), not the raw Project DO stub — internal lifecycle methods (afterAppend, createProject, getConfigWorker, the itx* verbs) are no longer reachable from a project-scoped handle.- Worker/facet entrypoint stubs are disposed after each invoke (live targets are not — the provider owns them).
- Cap HTTP routing matches names case-insensitively and dispatches with the registry's exact name.
D17: itx.project is the full Project DO surface (reverses D16's facade)#
Owner decision: within a project you get its whole surface — itx.project
returns the Project DO stub directly, so Workers RPC auto-proxies every
public method/getter and a newly-added DO method is instantly callable as
itx.project.foo() with zero forwarder code. The round-1 review narrowed
this to a facade for safety; the owner explicitly prefers the full surface
under the project-level access model ("you have the project or you don't").
The genuinely dangerous direction — a hand-built path into
reserved/prototype names via itxInvoke — stays gated in replayPathCall
(D16), which is exactly why that server-side filter is load-bearing now.
D18: ItxError — five codes, duck-typed, tag-don't-redact#
Structured kernel errors live in errors.ts; full rationale in its doc
comments. The compact version:
- Wire shape over class identity. capnweb serializes an error's own
enumerable props and reconstructs a plain
Erroron the far side, soItxErroris justError+ enumerablecode/detailsand detection is duck-typed (getItxErrorCode), neverinstanceof. No rehydration layer. - Exactly five codes: NOT_FOUND, FORBIDDEN, CONFLICT, BAD_REQUEST, INTERNAL. No UNAUTHORIZED — auth happens at connect (Law 3), so auth failures are transport-level 401s before a session exists.
- Existence masking: resolution boundaries (
itx.projects.get,/api/itx[/run]context resolution) answer NOT_FOUND for missing AND forbidden, byte-identical, so callers cannot probe which ids/slugs exist. FORBIDDEN is reserved for cases where existence is established or not secret (global streams, append policy, create/remove projects). - Tag, don't redact: the
/api/itxsessions'onSendError(tagOutboundItxError, wired infetch.ts) rewrites every non-ItxError toItxError { code: "INTERNAL" }keeping the original message and stack — we trust our callers; returning the error from the hook is also what makes capnweb transmit the stack at all.detailsships in v1 (maximum info). - Retry semantics downstream:
useItxQueryretries only code-less (socket) or INTERNAL errors, once; the stream-tail multiplexer skips retry exactly for access errors (NOT_FOUND/FORBIDDEN). - Verified by the worker harness (
pnpm test:itx-stream-subscribe) thatcode/detailsalso survive plain Workers RPC hops, so kernel throws born insideStreamsCapabilitykeep their codes on the way to capnweb.
D19: SSR reaches itx in-process; loader prefetch is best-effort#
Three pieces, one seam each:
getServerItx(server.ts) builds a handle inside the OS worker from the request context:accessForPrincipal→resolveAccessibleContextId→resolveItx— the SAME chain/api/itxconnect runs, now shared via access.ts so the two auth boundaries cannot drift. No socket, no Cap'n Web: the handle is a plain RpcTarget and each built-in call is one Workers RPC to the owning DO, which is exactly what SSR latency budgets want.getLoaderItx(loader.ts) is the isomorphic accessor (the orpc/client.ts shape): server → getServerItx (dynamically imported, so cloudflare:workers and the db layer never enter the browser graph); browser → the per-tab socket singleton, moved to react/browser-client.ts so loaders share the hooks' socket and project-handle cache without importing React.prefetchItxQuery(loader.ts) seeds the QueryClient with the sameItxQueryDefinition({key, queryFn, staleTime} defined ONCE in lib/itx-queries.ts) the component's hook consumes, and swallows every failure. Prefetch is an optimization, never a gate — a FORBIDDEN thrown during route loading crashed the streams page into the generic error boundary in prod (2026-06); the component's own useItxQuery re-surfaces the same error inline instead. Only the streams index prefetches today; breadcrumbs stay lazy by choice and are seeded for free through the shared per-path cache keys.
D20: Stream subscriptions carry reduced state — the ONE reactive primitive#
ItxStream.subscribe is now the single reactive primitive for both events
and reduced state; getState-polling reactivity is superseded.
- Every batch carries
state: the stream's public state (the exactgetState()shape —coreStateToStreamStatein stream-runtime.ts is the one projection both paths share, so subscribe-state === getState-state by construction) as ofstreamMaxOffset. The Stream DO already holds its core reduced state current after each append; the pump attaches it to every delivery in the same synchronous block that readsstreamMaxOffset, so the two always correspond. During backlog catch-up the state can be ahead of the batch's last event — it is state-at-streamMaxOffset, not state-at-batch-end-of-a-partial-drain. events: falseis state-only mode: the same batches withevents: [](an empty array, not an omitted key — shape stability), one delivery per state advance with missed appends coalesced. Replay without events is meaningless, so state-only subscriptions are implicitly live-from-now andafterOffset/replayAfterOffsetis ignored.- The initial push: EVERY subscription (both modes) immediately receives
one batch — current state,
streamMaxOffset, plus the replayed events when events-mode replay yields any. A live-only subscription no longer waits for the next append to hear anything: a subscriber paints its first render from the subscription alone, no separate getState call. onStateChange(cb)sugar on ItxStream is exactlysubscribe(batch => cb(batch.state), { events: false, afterOffset: "end" })(with callback-stub retention, since the sugar's wrapper outlives the originating RPC). This serves the upcoming one-hook browser layer:useItxstream.onStateChange(setState), no query cache.
Verified at the pump (packages/streams stream.workers.test.ts), through the
capability loopback (pnpm test:itx-stream-subscribe), and over capnweb
(itx-subscribe.e2e.test.ts).
D21: The browser layer is ONE file — itx-react.tsx (supersedes D19 and the shared-socket-per-tab decision)#
Owner decision: the TanStack-Query-for-itx + SSR + shared-socket architecture
was too complicated for what the browser actually needs, which is "a connected
handle, please". apps/os/src/itx/itx-react.tsx is the entire browser
surface in one file — the socket lifecycle plus FOUR small primitives:
useItx(override?)suspends until connected (React 19use()on a module-cached promise per context) and returns the liveRpcStub<ItxHandle>.connectItx(override?)is its imperative sibling — the same socket as a Promise, for event handlers / mutationFns / closures that can't call a hook. A connection is addressed by a plain{ projectId?, path?, baseUrl? }tuple (onlyprojectId— a project slug — keys the socket today;path/baseUrlreserved).ItxProviderholds that address in context souseItx()resolvesoverride ?? provider ?? globalthen always connects. Mutations are just imperative calls on the handle (itx.projects.remove({ id })) — no primitive.- Reads ride TanStack Query; subscriptions are one hook.
useItxQuery({ key, query })is the one read — it wrapsuseSuspenseQuery(the QueryClient already exists, anduse()needs a cached promise, so a hand-rolled cache earned nothing).useItxEffect((itx) => cleanup, deps)is the one live-subscription hook: its cleanup is() => sub[Symbol.dispose]()and it threads the connected handle into its deps so a reconnect re-subscribes (D20 is the protocol — every subscription's initial push carries current state, so a subscription alone paints a first render). - One socket PER CONTEXT in a module
Map, no refcounting, no teardown on unmount. The Map exists because Suspense needs a stable promise across render replays AND so every component on a context shares one socket that persists across client-side navigations.admin,global, and a project are just different context keys, never different primitives — the global repl, project repl, activity tail, and admin layout all ride the shared socket; no component opens its own. A component never disposes the root stub (capnweb closes the WebSocket when the root stub is disposed, killing the shared connection); per-component RPC objects (subscription stubs, callbacks) ARE component-owned and disposed on unmount. The only deliberate root-dispose isreconnectIterateSession()(evict + re-dial), used when the connect-time principal must change (creating a project; unlocking admin). - No reconnect machinery. Socket death drops the Map entry and wakes mounted
readers (
useSyncExternalStore); the next render dials fresh and re-suspends. No backoff, no offset resume, no liveness probes: re-reading current state via the initial push IS the recovery. - No SSR for itx components — the hook THROWS on the server. A forever-pending
use()during streaming SSR keeps the response stream open (React waits for suspended boundaries before closing it), while a server-side throw inside a Suspense boundary streams the fallback and recovers client-side. Consumers sit underssr: falseroutes (the streams pages), behind<ClientOnly>(the repl's activity tail), or reach itx lazily viaconnectItxinside a closure (so a slow/down socket degrades only that widget — the ⌘K tree, the agent feed).
Deleted with this: itx/react/ (provider, connection with backoff-reconnect,
query bridge, stream-tail multiplexer, useStreamEvents), itx/server.ts
(getServerItx), itx/loader.ts (getLoaderItx/prefetchItxQuery),
lib/itx-queries.ts, and the itx-server-handle worker test harness — D19's
SSR/loader-prefetch design and the plan's "one global-context WebSocket per tab"
decision are superseded. A later simplicity pass then collapsed the surface into
this single file: it dropped the interim use-itx.ts / getBrowserItx, the
hand-rolled useItxResource read (TanStack Query does it), and the
createSocketSuspenseCache factory (a plain module Map + ~10 lines of dial).
errors.ts (getItxErrorCode/isItxAccessError) stays: catch blocks and error
boundaries still read codes; there is just no retry-predicate layer to feed them.
D22: fetch on the project IS egress; ingress is stateless; creation is events#
The Project DO cleanup (PR #1466) landed three postures the spec/notes had been circling:
project.fetch= egress. In itx vocabularyitx.fetchis project egress (Law 5), so the DO's barefetchnow routes toegressFetch— the worker'sfetchis the project's homepage. Endgame, not built: egress as a stateless capability with policy cached outside the DO, and interception as a live egress-shadowing capabilityprovided over capnweb-with-WebSockets.- Ingress never touches the DO.
ProjectIngressEntrypoint(stateless) asks the DOgetWorkerVersion()(freshness + deduped rebuild semantics), loads the worker itself viaenv.LOADERwith the shared cache key, and dispatches; the code payload only crosses RPC inside the loader's cold-isolate miss callback. The DO is where the worker's source of truth lives, nothing more. - Project creation is event-sourced and explicitly born.
projects.get(slug)returns a prospective handle without side effects.handle.create()registers the slug, adopts the directory-issued project ID, then atomically appendsproject/create-requested, Notification's birth, and both platform processor subscriptions. The Project processor explicitly births the root capability host, scheduler, config repo, and email router. After the trusted seeded config worker builds and answers a readiness probe, the saga atomically installs its permanent root feed (starting after the request), appends terminalproject/created, and publishes the firstproject/worker-updatedusing the OS-stamped seed commit. Same-batch ordering makesproject/createdthe first event delivered to the userspace worker; creation does not wait for that reaction or the worker-update reaction. A config-repo or deterministic worker source-build failure appends terminalproject/create-failed; availability and build-in-progress outcomes stay open for durable redelivery. The subscription and terminal event keys are ordinary coordination conventions rather than a separate authorization layer. Create waits for either terminal fact by default, returning the same handle on success and throwing the recorded failure. Callers that render bootstrap progress themselves pass{ waitUntilCreated: false }as the second argument to skip only the final creation barrier. This lifecycle change is an empty-state cutover: production projects are erased and recreated, so there is deliberately no reducer fallback for journals that predateproject/create-requested. The processor is a public RpcTarget getter on the DO, anditx.projectis a path proxy (replayPathCall awaits intermediate segments), so deep traversal works in one expression even though workerd itself does not pipeline calls through property accesses on raw stubs (capnweb's RpcTarget IS cloudflare:workers' inside workerd). Callers that need routing before the processor catches up (dashboard,itx.projects.get(slug).create) insert the D1 projects row themselves first, as they always did. The default worker readiness probe gates create; ingress also self-heals builds.config-worker-builtremains the historical event string. The DO keeps NO bespoke tables: the processor snapshot is the project's durable state (with a pureprojectFacts()+ D1-slug fallback for cold snapshots), and "config worker" is now just the worker (durable-objects/worker.ts,callWorkerFunction,itx.worker). - Post-creation worker source changes become a platform lifecycle fact.
Creation's successful worker probe publishes the first
project/worker-updated; the trusted seed commit remains creation input and is not separately translated. Each later config reporepo/commit-completedis copied onto/; the Project processor recognizes its exact provenance and probes the authoritative current default worker. Success appends the same rootproject/worker-updated, which is the plain userspace switch case for arbitrary direct ITX code. Deterministic source failure appendsproject/worker-update-failed; transient build or availability states keep the processor cursor open. This keeps repo topology out of userspace without inventing a lifecycle framework or reusing the one-shot creation facts.
D23: The grand cleanup — §8 and §9 graduate, captun intercept dies, auth mints#
One PR (deliberately breaking; prd gets redeployed), five moves:
- §8 shipped in full. repos, workspace, worker, project, egress, and ai
are ordinary capabilities on
platform:project(code-contexts.ts), resolved through the fallthrough and SHADOWABLE per context. The kernel shrank tocap, caps, describe, fetch, fork, projects, streams(streams stays hardwired: its global-namespace/admin gating is handle logic, not a project capability).itx.project's default is adurable-objectref — now implemented, with the instance name defaulting to the owning project id — so the whole-DO-surface posture (D17) and one-expression deep traversal survive unchanged, now supervised by the registry like every other cap. ProjectCapability (the hand-wired forwarder entrypoint) is deleted; nothing called it. - §9 shipped: egress is a capability.
ProjectEgress(every isolate's globalOutbound, and itx.fetch's target) dispatches thefetchcap through the context's registry (#1487's naming); the default target is the statelessEgressPipeloopback, superseding #1487's ProjectEgress.call → DO egressFetch pipe. The Project DO still supervises dispatch (live shadows live in its registry), but secrets are D1 rows scoped by the dial-time projectId, so substitution + the terminal fetch run in a plain isolate and secret material never enters the DO. The old intercept path is replaced byitx.caps.provide({ invoke: "path-call", name: "fetch", target })over capnweb-WS: a live provider receives every egress Request with placeholders RAW (never material; the "withheld text" substitution mode died), and disconnecting restores the default. The Project DO now has NO fetch surface at all. Captun remains only for the public/__iterate/captunrelay. - One isolate-wiring seam. itx/isolate.ts is the single place the platform's trust posture (Law 4 ITERATE scoping, Law 5 egress outbound) is wired into loaded isolates; the registry's source caps and the project worker both use it.
- Auth is the ONLY project-id minter. Even operator/recovery creates call
AUTH.mintProjectId()on auth's private Workers RPC entrypoint;mintProjectIdis deleted from OS. - Legacy afterAppend/runner shapes are gone from the agent, slack-agent,
slack-integration, and repo DOs (delivery has been on the host model for a
while; those were catch-up/observability vestiges). Agent runtime state is
now the honest
{ agentPath, processors: { [slug]: snapshot } }. { type: "url" }addresses andUrlDialare deleted. The url kind's only consumer was an e2e test dialing the deployment's own/api/itxback over the network — a self-proof, not a use. Addresses are"rpc"only; re-adding an outbound-capnweb variant later is purely additive. (It also carried a known gap: url dials bypassed fetch-cap shadowing.)- Share URLs are deleted; exposed caps are public.
shareUrl/itx_sharewas the realm's one bearer-token edge — a query-string token signed with the ADMIN API SECRET, with no consumer outside tests. Cap-host HTTP is now one switch:meta.http.expose(404 unless exposed; exposed answers anyone).meta.http.publicdied with the gate. - A context IS a stream coordinate; CapabilityHostDurableObject is the ONLY host.
Context ids (
itx_…), theitx_contextsD1 directory, the reserveditxstream segment, and the embedded cores in the Project and Agent DOs are all deleted. Identity is the REF<projectId>:<path>— also the node's DO name and address. The project context lives atprj_x:/(its events interleave with the project's own on the root stream, exactly like agents already did on theirs); child contexts live at theextendpath or the generated/itx/<id>path. - The generic host derives nothing; creation is an event by the CREATOR.
createContextappendssubscription-configured(pointing the stream at the node's hosteditxprocessor) +context-created { name, parent }— both idempotent by key, so creation is get-or-create (the fold takes the first birth certificate). Parentage comes strictly from the event; the platform defaults parent is the one loopback (code) address. - The project-host
/__itxconnect door is deleted (premature). Connect is/api/itx[/:target]on the OS origin only;:targetis a project id/slug (the root context) or a full URL-encoded ref.
D24: ONE socket per tab — a Session gate + slug-addressed itx + invisible reconnect (supersedes D21's socket-Map-per-context)#
D21 keyed a WebSocket per { projectId, connectionKey } context in a module
Map, so a dashboard held THREE sockets at once: a global one for the sidebar
catalog, a project one for the page, and an isolated one for the browser stream
mirror (whose aggressive reconnect loop must not reject the page's reads). The
map + connectionKey + identity-bound eviction existed ONLY to keep those
independent sockets from killing each other. Collapse to one:
authenticate()is the top noun. The provider/hook gives a Session (useIterateSession()/connectIterateSession()); a project itx is derived from it —useItx(slug)/connectItx(slug)=session.projects.get(slug), andprojects.getnow accepts a URL slug (orprj_id), so the browser passesparams.projectSlugstraight through with no client-side slug→id hop. There is no provider: the socket is module-global and every hook dials it lazily, so<ProjectScope slug>just carries the ambient slug (foruseItx()with no arg) and pre-warms the socket — the sidebar / ⌘K / admin use itx with no<ProjectScope>at all.- Reconnect is invisible. React reads an immutable
Snapshot;snapshot.sessionkeeps the last live session across a transport gap, souseIterateSession()suspends exactly once (first load) and reads are stale-while-revalidate (TanStack keeps cached data; only in-flight reads retry, on a finite transport-only policy). A dropped socket always re-dials — with a session in hand invisibly, without one by keepinguse()suspended on ONE stable first-connect promise (per-dial rejections never reach the suspended tree — React replays a never-committed component against the thenable it first used, so rejecting it would error-boundary a page whose paced re-dial is already underway) — the connection hook is never a re-suspend point and never wedges. (The session is the AWAITEDauthenticate()result — one settled stub identity for the snapshot, imperative awaiters, and the stub cache; resolving with the raw pipelined RpcPromise would assimilate and fork identities. The dial timeout spans the whole handshake, and a REAL auth rejection over a working socket is terminal — surfaced from the connecting promise — while a transport death mid-handshake re-dials. This supersedes the earlier publish-on-open-without-awaiting design, which codex's thermo review showed forked the session identity.) - Project stubs are session-owned, cached in a module
WeakMapkeyed by the session stub (NOTuseMemo— React may discard/re-run memo calcs and leak undisposed capnweb import entries). The handle stays a REAL capnweb stub (a lazy wrapper that awaited the session per call would break pipelining, fork v0.8.0); its identity changes once per reconnect, re-running keyed effects. The cache serves the PERSISTENT handle paths (useItx/connectItx);useItxQuerydeliberately bypasses it with a per-fetch stub it disposes when the read resolves. Nuance:await connectItx(...)hands back the pulled resolution of the cached pipelined stub (thenable assimilation, one level down) — same underlying import, so it shares the cache's lifetime; unifying that identity too needs a capnwebdup()-semantics probe and is deferred. - Transport health is socket-owned and generation-guarded. One verifier per
generation (periodic + visibility/online, two-strike) is the only thing that
retires the socket; the mirror and subscription watchdog REPORT suspicion
(
reportTransportSuspicion) rather than evicting. Generation OBJECT IDENTITY (current === generation) is the compare-and-swap that stops a late verdict from closing a healthy successor (the monotonic snapshot number exists only as the React effect dep).reconnectIterateSession()remains the deliberate SEMANTIC reset (new claims after create/unlock), distinct from transport recovery. - Deleted: the socket
Map,connectionKey,ItxAddress,evictItxSocket/evictItxSocketIfCurrent, theSession & Projecthandle intersection, the mirror's dedicated socket,<ItxProvider>(no provider is needed above a module-global socket — its pre-warm folded into<ProjectScope>), and the 12 per-page<ItxBoundary>wrappers (TanStack Router already wraps every route match in<Suspense>via itsdefaultPendingComponent). The mirror rides the shared session and can no longer blank the page on its own reconnect.
Reviewed before implementation by codex (gpt-5.6-sol, max reasoning) for React
19 / TanStack Start idiom; its corrections — immutable snapshot, session-owned
(not useMemo) stub cache, generation-guarded reconnect, typed transport-only
query retry, unconditional scope read — are folded in. Its
confirm-auth-before-publish suggestion was initially rejected (the original
shipped optimistic, publishing the pipelined root on open) and then ADOPTED in
the second thermo round: the optimistic design turned out to fork the session
identity via thenable assimilation and to leave a hung authenticate un-timed-out,
so publish now happens on the awaited authenticate result. One suggestion stays
NOT adopted: auto-prefixing query keys with the slug (it broke the existing
invalidateQueries/setQueryData sites and duplicated the project id the keys
already carry — the ambient slug drives the connection, not the cache key).