# ADR — Private mode carries records in signed envelopes over a private bus, and keeps every trust decision in the fold *Status: accepted, and implemented end to end — the substrate, the write path, the operator commands, backup/restore, the wire two replicas speak, large-body transfer, the browser's own replica, the daemon's endpoint lifecycle and address publication (§20, §21), and — with §22 — the browser's. Both runtimes now bind an endpoint, publish where it is, dial the devices their directory names, and catch up over the same protocol; with §23 a tab founds a private space and mints invitations to it, so nothing about private mode is CLI-only any more, and with §24 a replica still waiting for its first peer keeps asking instead of reporting a failed open, and with §25 a hidden tab keeps the directory poll that decides whether it will serve anybody, and with §29 a device that has stopped being trustworthy can be withdrawn from every replica's dial list without a single record changing what it counts. §9's connect half was observed on 2026-08-05: a browser tab's relay-only WASM endpoint reached a running daemon through a public n0 relay, was authorized against the directory, caught up the space, and synced live writes both ways. Every test here still runs over `loopbackLink`, deliberately — the observation is recorded in §9, not automated. Decided while implementing the "private mode over iroh" plan. Everything asserted below about the fold, the envelope, admission, the write path, the archive, the wire and blobs is covered by tests in this repository — `packages/core/test/private-fold.test.mjs`, `packages/core/test/private-envelope.test.mjs`, `packages/core/test/private-archive.test.mjs`, `packages/core/test/private-writer.test.mjs`, `packages/core/test/private-wire.test.mjs`, `packages/core/test/private-sync.test.mjs`, `packages/core/test/private-blobs.test.mjs`, `packages/core/test/private-peers.test.mjs`, `packages/ingest/test/private.test.mjs`, `packages/sidecar/test/private-cli.test.mjs`, `packages/core/test/private-directory-writes.test.mjs`, `packages/daemon/test/private-dispatch.test.mjs`, `packages/daemon/test/private-transport.test.mjs`, and `packages/ui/src/lib/private-store.test.ts`, `packages/ui/src/lib/private-space.test.ts` and `packages/ui/src/lib/private-transport.test.ts`. The protocol itself is written up as `docs/design.md` §18.* --- ## 1. Context Radial's bus is public atproto: every record lands in its author's PDS and is ingested from there. Design §13 states the consequence plainly — goal titles, artifacts, review findings and conversation are visible to the network — and §17 argues the cost is transitional: membership already gives us the trust boundary to hang private state on, and when atproto's permissioned data matures the same signed-record fold runs over permissioned records instead of public ones. Private mode is the interim. A space's records never reach a PDS; they travel as signed envelopes over a private bus and are folded by the *unchanged* `materialize()`. The thing that makes this hard is not encryption. It is that a PDS was doing something for free: **repo custody proved authorship**. A record in a member's repo is that member's record, because nobody else can write there. Take the PDS away and something has to replace that proof — and where that replacement is evaluated turns out to decide whether Radial still converges. ## 2. Decision: two gates, and trust stays in the fold An envelope is admitted on cryptography and quotas alone (gate 1); the pure fold decides whether it counts (gate 2). Admission verifies a signature against a device key published by the envelope's DID and never asks about membership. Keeping trust in the fold reproduces stores what it reads out of member repos, and a removed member's records are *stored and ignored* (`materializer.ts`), never deleted. Private mode is deliberately not an exception. **Gate 2 is mandatory, not opportunistic.** A record with no signing device has no device to gate, so a first cut of this made gate 2 skippable: anything sitting in a member's public repo folded with no authorship check beyond membership, and membership grants are records too. In a private space therefore, a record outside the public directory (and `join`, which confers nothing) does not count unless it arrived signed. The space record is the one exemption — it declares the mode, so gating it on the mode would be circular — and a space counts as private when its record says so **or** when that record arrived in an envelope, so a public copy cannot switch the gate off. Ingestion narrows to match, polling members' repos for the directory collections alone, but the rule lives in the fold because that is the contract every `RecordStore` implementation has to satisfy. The same reasoning settles a smaller question the first cut got wrong in the other direction: signers **accumulate and are never taken away**. A copy of a version with no signers used to clear the set, on the theory that repo custody is the stronger proof — which handed an attacker a way to un-sign a record by republishing it, leaving a version with no device left to revoke. The migration case that rule was written for cannot arise: a space's mode is fixed at creation. **Private spaces have no per-device revocation.** A published device remains bound while its DID is a member, and everything it signed keeps counting. Rotation is fold-neutral local hygiene. The response to a device that has stopped being trustworthy is **retirement** (§29): withdrawing its published address, so no replica dials it or serves it, while the fold is left byte-for-byte identical. That is containment and not repair — for repair, the response is still member removal, which retroactively drops the member's whole corpus. Re-adding that DID restores every old record, including an attacker's; preserving the person after a stolen key therefore requires creating a new space. This is the accepted trade for small, trusting groups. Revocation may return through an additive collection and fold rule, coordinated by a private-wire protocol version bump. The retroactive design below was implemented experimentally and withdrawn before release; it remains rejected historical context, not current behavior. ## 3. Decision: the device directory is public, and only public `device` and `deviceAddress` records are written to members' ordinary public PDSes, and the fold reads them one: 1. **Bootstrap.** A freshly OAuth'd browser with no local state knows only what public records tell it. It lists its own `join` bookmarks to rediscover its spaces, and reads `deviceAddress` records to find a peer. Nothing else is available to it. 2. **A key published in an envelope would vouch for itself.** Verifying that envelope requires the key it carries. On the public path, repo custody proves the DID meant to publish that key — the same proof private mode otherwise gives up, borrowed back for the one record where it is indispensable. Binding a key is therefore **independent of membership**, which is also what stops the bootstrap eating itself: the membership grants that decide whose records count are themselves envelope-signed, so if binding required a grant, no first grant could ever be verified. A stranger's published key is bound and their records are then dropped by the ordinary membership rule, exactly like any non-member's are today. ### What this leaks, and to whom A DID's public repo will reveal that it uses Radial, how many devices it has, opaque space identifiers, endpoint ids, relay choices, and update times. `peerHints` — the optional list of members expected to be reachable — additionally discloses who is likely a participant, which is precisely the metadata this design otherwise minimises. Hence: hints are opt-in and default to the founder alone, labels stay local unless published, and **the disclosure is shown to a member before they enable private mode**. There is no anonymity claim, and pretending otherwise would be worse than the leak. **The roster is public, and `peerHints` is not what makes it so.** The paragraph above frames participant disclosure as a hints problem, and that is too narrow. Every member writes their *own* `join` naming the space, in their own repo, because §4 is what a fresh browser rediscovers its spaces with — so the set of DIDs bookmarking a given space URI is the space's human membership, and atproto repos are public and streamed on a firehose. Anyone indexing `com.disnetdev.radial.join` can build space → members for every private space in existence; anyone holding one space's URI can test a candidate DID with a single read, since the record key is derived (`joinRkey`) rather than minted. Hints make a subset of that roster *more* legible — they say who is likely to be *reachable*, which is a distinct and smaller claim — but the roster itself is disclosed by the bookmark that makes bootstrap work at all. This is inherent, not an oversight to be fixed: a bookmark nobody can read is a bookmark a fresh browser cannot read either. It belongs in the disclosure a member sees, in those words. What is NOT disclosed, and the boundary is mechanical rather than a matter of degree: nothing about the space's contents, and no way to obtain any. Serving catch-up requires an endpoint the serving replica's device directory names (`authorizeEndpoint`), that directory is polled only for the DIDs of **active members** (`ingest/src/private.ts`), and a DID nobody has granted never enters that set. A stranger holding every public record in existence — every bookmark, every device, every address, and a ticket besides — is refused by every peer, every time. The public path leaks *who and when*, and cannot be made to leak *what*. ## 4. Decision: `join` is extended rather than duplicated Fresh-browser rediscovery needs a bookmark in the member's own repo. §3 already defines one — `join` is a self-assertion, "a bookmark and not a credential", never read for trust, and the answer to "which spaces do I belong to" in a single `listRecords`. That is the same record, so it gains optional `founder`, `protocol`, `label` and `peerHints` fields rather than a parallel collection. A second collection for one concept is a permanent duplicate every future reader has to know about. §3's "a daemon never writes one" holds unchanged: a human writes their own under their own session when they accept a ticket, and an agent DID needs none because the operator's `radial.json` already names the daemon's spaces. **Recovery depends on a live peer.** A fresh browser can rediscover its spaces from its own bookmarks, but it cannot catch up unless at least one hinted peer is online. That is a real dependency of the design and not a bug to be fixed later; operator docs will recommend hinting always-on nodes. ## 5. Decision: `deviceAddress` is the third sanctioned in-place rewrite Nothing in Radial edits a primary record; the sanctioned exceptions are claim renewal and the agent profile republish. `deviceAddress` joins them, by name, in §18 and in `store.ts`'s `select()`. A device that moves networks has to be able to say where it is now under the *same* rkey, or every peer holding the old hint would need a new record key it has no way to learn. Latest-`rev`-wins is safe here for the same reason it is safe for `agent`: one author, so `rev` totally orders the versions. It is safe *at all* because address hints are excluded from the device-authorization fold by construction — the worst a stale or hostile republish achieves is making a peer dial an endpoint that then fails to authenticate. ## 6. Decision: eviction from quarantine is never silent loss An envelope whose signing key this replica has not seen yet is quarantined rather than rejected: a `device` record is public and may simply not have been polled. Quarantine is bounded by count and bytes, so it evicts — and on this bus eviction is not the harmless thing it is on the public one, because **no PDS holds a re-fetchable copy**. A silent drop would be a permanent fork that neither side could detect: version summaries would agree, because neither replica knows the record was ever there. So every eviction, and every refusal for capacity, writes a durable want entry `(did, collection, rkey, recordCid)`; catch-up re-requests wants **explicitly** rather than inferring completeness from summaries; and outstanding wants are surfaced. A burst costs a re-fetch. It never costs a record. The *held* set is memory-only, which the claim above is now careful not to overstate. A quarantined envelope was never admitted, so it is absent from this replica's summaries and a peer re-offers it on the next catch-up; a restart loses nothing that is not already coming back. The want list is for what that argument does not cover — an envelope this replica evicted while its peers believe it landed. And both bounds measure the whole envelope, with admission capping the envelope's own fields, because quarantine is the retaining path and a quota that counts only the record inside is not a quota. ## 7. Decision: guest comments are actively disabled in a private space, in two places The UI's guest-comment discovery asks a public backlink index which records point at a goal. In a private space that hands a public index the space's goal URIs and rkeys on every view — an irreversible leak from a feature that has nothing to offer a space with no guests. The fold refuses `setGuestComments` in a private space and pins `guestCommentsEnabled` to `false`; the UI hard-gates the discovery effect on `index.private` and has a test asserting **no request leaves** even when the component is told to discover. The redundancy is deliberate: one switch is one forgotten branch away from a leak that cannot be taken back. ## 8. Decision: the dependency invariant is amended, by name CLAUDE.md says: no third-party runtime or test dependencies outside `@radial/ui`. The private-mode substrate as built **does not touch it** — canonical encoding, CIDs, TIDs and Ed25519 all run on WebCrypto and hand-written code, isomorphically, with no dependency at all. The amendment is now **two** leaf packages, and naming them is the point of this section. The native one is `@radial/transport-iroh`, pinned to `@number0/iroh` 1.1.0. The browser one is `@radial/transport-iroh-wasm`: a Rust crate pinned to `iroh` `=1.0.3` (with `Cargo.lock` committed, so the pin is the whole tree and not just the top of it) plus the thin TypeScript adapter over its generated binding, which is what reaches the UI bundle. Nothing else may take a dependency — `core`, `atproto`, `ingest` and `sidecar` stay absolutely dependency-free. With both added: `pnpm lint` must still pass with a new package excluded from the type-strip check only if it ships no TypeScript of its own, `packages/ui/test/browser-bundle.test.mjs` must still enforce no `node:*` on the browser path with the WASM asset accounted for explicitly, and both CI workflow files change together. For the Rust crate that last obligation is specifically a `cargo check --target wasm32-unknown-unknown --locked` step in each workflow: a deliverable nothing compiles is a deliverable whose state nobody knows, and this one is written for a target no other check in the repo exercises. That step needs `clang` and an archiver on the runner as well as the target's `std`, because `ring` compiles C for `wasm32-unknown-unknown` — see §9 for what the build did and did not prove. ## 9. Rejected alternatives **A loopback WebSocket bridge through the daemon for browsers.** Rejected: it makes the daemon a privileged coordinator for exactly the spaces that most want not to have one, and it means a browser cannot participate in a space whose operator is offline. Browsers use the transport's public relays directly. Iroh's documentation is contradictory about browser support, so a build-and-connect spike against a pinned version is a **release blocker** for the transport phase — its failure is a product decision, not licence to quietly restore the bridge. *Spike result, build half — now complete.* `packages/transport-iroh-wasm/rust` compiles **and links** a `cdylib` for `wasm32-unknown-unknown` against `iroh` `=1.0.3` with default features off and `tls-ring` on, warning free, under `cargo check` and a `dev`-profile `cargo build`. It needed no `getrandom` or `ring` target configuration beyond that; what it did need was `clang`, because `ring` compiles C for this target and `cc-rs` will not proceed without one. `Cargo.lock` is committed so the tree that was verified is the tree CI resolves, and §8's CI step re-runs the check on every push. The `release` profile's fat LTO link, which exhausted the 2 GB spike container, **has since completed**, and so has `wasm-pack`'s `wasm-bindgen` post-processing behind it: `build:wasm` produces a 2.0 MB `.wasm` and 48 kB of glue in about 35 seconds on a developer machine. The failure was the container's memory and nothing in the code. That number is why §22 loads the binding lazily and serves it as an app asset, and it is why `.tangled/workflows/deploy.yml` — not `ci.yml` — is where the full build runs: a two-minute link on every push to every branch would buy nothing that `cargo check` does not already catch. *Spike result, connect half — observed 2026-08-05, and the release blocker is discharged.* A browser tab bound the WASM endpoint, reached `usw1-1.relay.n0.iroh.link` and dialled a running daemon's native endpoint through it: the connection authenticated, was authorized against the space's directory, the tab caught up the space record and corpus, and writes made on either side arrived on the other over the live link (a member's browser join, then projects and goals). Both endpoints ran on one machine, and the path was nonetheless the relay's, because the browser binding is relay-only and cannot shortcut it — which is exactly the QUIC-over-WebSocket question this section existed to answer. Versions: iroh `=1.0.3` in the crate (`Cargo.lock` committed), `@number0/iroh` 1.1.0 native. Everything in CI still runs over `loopbackLink`, deliberately: the relay path is an observation this document records, not a fixture the tests depend on. **Encrypted records on public PDSes.** Rejected for now: it leaks the shape and timing of everything (record counts, collections, rkeys, update cadence) while adding key distribution, and it makes the migration to atproto permissioned data *harder* rather than easier, because the records on the wire would no longer be the records the lexicon describes. **A private-network PDS.** Rejected: it reintroduces a server every member must trust and reach, which is the property §2 exists to avoid, and it does not actually remove the operator — it renames them. **Trust at the admission gate.** Rejected — see §2. It is the decision this ADR mostly exists to record. ## 10. Migration obligation Envelopes carry lexicon-valid `com.disnetdev.radial.*` values with atproto-correct `recordCid`s, and that is a commitment rather than an implementation detail: when atproto's permissioned data lands, a private space must be republishable onto it without rewriting a single record or breaking a single strongref. Design §13's long-term bet is unchanged; this is the interim, and it must not foreclose it. Any future change to the envelope that would make a record un-republishable is a change this ADR refuses in advance. ## 11. Decision: the write path is a substituted writer, not a second path `RecordWriter` is the single write seam behind `runCli()` for the CLI, the web app and the turn socket, and `ActorClient` is the equivalent for everything the daemon writes. Private mode replaces the *implementation* of both and adds no branch above them: `EnvelopeWriter` seals, admits locally and publishes, and `privateActorRegistry` swaps it in for an actor's three Radial write methods. Dispatch, claims, ledgers, checks and auto-review are untouched, and the same tests cover both modes (`daemon/test/private-dispatch.test.mjs` runs the claim race and the artifact write over `MemoryPrivateBus`). Two consequences are deliberate. The local commit is gate 1 — a record this replica writes gets no more credit than one a stranger offers — so a write that would not be counted fails at the command rather than being reported as written. And the substitution stops at Radial's own collections: a `sh.tangled.repo.pull` record and the blob behind a patch stay on the PDS, because a pull request is a public forge record by definition and a private space says nothing about where a project's code review happens. A ticket is the other half of the seam and is deliberately *not* a credential: it names the space, the founder and the founder's key so a fresh replica can start and can detect a directory that disagrees with what it was handed out of band. That check reports to a human and never enters the fold, for the same reason §2 keeps revocation out of admission — a trust input only one observer holds cannot be part of a convergent function. The ticket's `space.cid` is enforced on the same terms. Accepting a ticket persists it as the replica's `spaceCid`, and every use of the fold — catch-up, a local write, the daemon's index — refuses when the space record held at that URI is a different version, because a correctly signed second genesis passes both gates and would otherwise bootstrap an invitee into a space definition nobody handed them. It is a **local bootstrap refusal**, not a fold rule: replicas that accepted the same ticket refuse identically, a replica that holds no ticket checks nothing, and no observer's `materialize()` behaves differently from another's. The daemon's pin comes from `run.privateSpaces[].ticketFile` (§20), which is the same ticket by another route. ## 12. What is not decided here **The production transport lifecycle.** *Decided in §20; what follows is what this section said while it was open, kept because the shape it describes is still the shape.* `MemoryPrivateBus` implements the `PrivateBus` interface deterministically and is what every test above that layer runs on; it is what keeps CI off the network. `LocalOnlyBus` is its honest stand-in wherever no endpoint is held: writes commit locally, reach nobody, and are counted so a command can say so. The protocol they stand in for exists (§14), and so does the connection policy above it (§16). `@radial/transport-iroh` implements the native half: one bounded Radial frame per bidirectional QUIC stream, authenticated endpoint ids, relay-aware dialing, persisted endpoint-key material, and inbound-link delivery. What remained after it was wiring that endpoint into daemon replica lifecycle, publishing its live endpoint id as `deviceAddress`, and a browser WASM endpoint. The first two are §20. **The browser endpoint is implemented in `@radial/transport-iroh-wasm`.** Iroh 1 supports browsers when the Rust crate is compiled with default features disabled, but does not publish that build as an npm package. Radial therefore carries a small application-specific Rust `wasm-bindgen` wrapper that exposes the existing `PeerLink` seam to the UI. Its generated binding is lazy-loaded, and the TypeScript adapter is tested through an injected binding so ordinary CI stays off the network. Browser connections are relay-only (still end-to-end encrypted), an availability constraint rather than a trust-model change. §9's rejection of a loopback bridge through the daemon stands: a tab that cannot reach a relay or peer says so (§18) rather than being routed through an operator it would then depend on. *Attaching the endpoint to the open-replica lifecycle and publishing the browser device's address are §22, and turned out not to be UI wiring at all — most of what was left belonged in `core`.* **The browser.** *Closed by §22.* §18 records the half that turned out not to depend on the transport at all and §19 offers it to a person; §22 is the rest — a tab binds an endpoint, publishes its address, dials the devices its directory names, and catches up on the same tick that polls that directory. Fresh-browser recovery through `peerHints` works because the hints order that dial list. None of what remains may move a trust decision out of the fold. ## 13. Decision: a backup archive is a bag of envelopes, and never a trust input *Recorded after §12 because it is the answer to the state §12 left an operator in: with no transport, a private space is a single-replica space and its store is the only copy of it. §20 gave replicas a way to meet; it did not give them a durable copy, and nothing ever will — the archive is still the backup.* `radial private export` writes a replica's envelopes to a file — a JSON Lines header and one signed envelope per line — and `radial private import` reads one back through `PrivateSpaceIngestor.offer()`, which is `admit()`, which is gate 1 unchanged. That is the whole decision: **an archive is transport, not authority**. A file somebody edited cannot land a record that a peer could not have landed, and the failure mode is a named rejection in the import report rather than a corpus one record different from everyone else's. The alternative shape — sign the archive as a unit, then trust its contents — was rejected for the reason §2 rejects trust at admission. It would be a second authorship proof, weaker than the per-envelope one (it says "this replica exported these bytes", not "this DID wrote this record"), and a second proof is a second thing to get wrong: whichever is cheaper to check becomes the one that matters. There is nothing an archive signature could establish that the envelopes do not already establish better. Three exclusions follow from the same rule, and each is a decision rather than an omission: - **The device directory is not in it.** §3 keeps key material on the public path *because a key published inside an envelope would vouch for itself*, and a backup file is precisely the artefact an attacker would most like to hand you. A restore polls members' repos like any replica; a restore that cannot reach them quarantines the corpus, records the wants, and says so, and re-importing the same file once the directory is readable completes it. - **The signing key is not in it.** A restored replica reads a space and cannot write into it until it mints and publishes a device. The key is a different secret with different handling, and an archive that carried it would make every backup a credential. - **The bootstrap is not in it.** Which genesis record *is* the space comes from a ticket, out of band (§11), so restoring onto a machine holding no replica takes `--ticket`; the header's own `spaceCid` is compared and reported to a human, never adopted. Importing writes no public `join` either: restoring a replica is not joining a space. What the archive *does* carry beyond the envelopes is the exporting replica's **want list**, because dropping it would undo §6 across a restore — a want names an envelope this replica evicted while its peers believe it landed, which no summary diff would surface, and a restore that forgot them would look complete and never ask again. Adopting another replica's want costs a re-request and nothing more: a want changes what a replica asks for, never what it counts. The format is deliberately dull. Whole-corpus rather than incremental, because an incremental backup would need a completeness proof and the honest one is the explicit want-list catch-up already does. Deduplicated and ordered by envelope identity, so an unchanged replica exports the same bytes twice. And legible, because the first thing anyone does with a backup they are unsure of is open it. ## 14. Decision: the protocol is in `core`, and a transport only moves its frames *Recorded after §13 because it is what shrank §12’s "what is not decided here" to one item.* `PrivateBus` said what a transport has to **do**; nothing said what it has to **carry**, so the wire format, its version, its bounds and its catch-up algorithm were all implicitly deferred to whoever wrote the first transport. That was the wrong owner for every one of them. `MemoryPrivateBus` answers a catch-up by iterating the other endpoint's fields, which is exactly right for a test and is not a protocol: there is no batch, no resume point, no bound on what one request costs the peer serving it, and nothing serialised — so nothing could disagree, and nothing could be tested. The protocol therefore lives in `core`, isomorphic and dependency-free: `private/wire.ts` (one versioned ALPN, a domain-separated per-space topic, five JSON frames, a length-prefixed stream reader that bounds a declared length before buffering the body) and `private/sync.ts` (`SyncResponder`, the bounded resumable serving half; `WirePrivateBus`, a `PrivateBus` over `PeerLink`s). A transport moves frames between two authenticated endpoints and hands each inbound one, plus the remote identity it authenticated, to `accept()`. The bus requires and invokes the directory authorizer before serving or delivering the frame; the transport decides nothing else and cannot omit that invariant. Three consequences are the reason for the shape: - **One protocol, two runtimes.** The daemon and a browser tab run the identical encoder, the identical responder and the identical bounds, for the same reason they run the identical `materialize()`. A protocol that lived in a native Node binding would have needed a second implementation for the WASM path, and two implementations of a wire is how a fork starts. - **It is testable today, with CI off the network.** `loopbackLink` runs frames through the real codec into another endpoint's `accept()` with the caller's authenticated identity, so authorization, paging, cursors, want-list re-requests, duplication, partitions and two-replica digest convergence are all covered before any binding exists — and the same tests will cover the binding, because nothing above `PeerLink` will change. - **The remaining decision is smaller and better shaped.** Choosing the iroh packaging (napi binding versus subprocess for Node, the WASM package for the browser) is now a question about moving bytes, not about designing a sync algorithm under time pressure with a dependency already picked. Serving a peer is deliberately **not** trusting one, and connection authorization (`private/peers.ts`) is deliberately not a gate. A batch is a bag of signed envelopes and every one of them still passes gate 1 and gate 2 at the far end, which is why the interesting bounds in this layer are about cost. What authorization buys is that a private corpus is not enumerable by anyone who computed the topic — a topic is derived from a public URI and confers nothing — and it is stated in the vocabulary §2 insists on: it is about *connections*, never about the index. ## 15. Decision: a blob is authenticated by its own name, and pulled *Recorded after §14 because it is the last item §12 listed that the transport does not block.* A picture cannot travel inside an envelope: `image.blob` is ten megabytes at the lexicon's limit and an envelope is half a megabyte at admission's, because an envelope is what a stranger can make a peer hold. So bytes move separately, and the question is what makes them trustworthy on arrival, carrying no signature of their own. **Content addressing is the authentication, end to end.** A blob's name is the CID of its bytes; the record naming it is signed inside an envelope; the envelope's `recordCid` covers that record. A receiver recomputes the CID of whatever it was handed and keeps the bytes **only under that computed name**, so a peer substituting one blob for another lands under a name nothing references. There is nothing here to trust a peer about. The alternative shape — sign the transfer, then believe its contents — was rejected for the reason §2 rejects trust at admission and §13 rejects an archive signature. It would be a second authorship proof, weaker than the one already covering the record (it says "this peer sent these bytes", not "this DID wrote this record"), and a second proof is a second thing to get wrong. There is nothing a transfer signature could establish that the record's signature does not already establish better. Three consequences follow, and each is a decision the code enforces rather than a convention: - **Blobs are pulled, never pushed.** A replica asks only for the CIDs the records it has already admitted name, so there is no path by which a peer delivers bytes nothing references. An unsolicited `blob` frame is refused exactly as an unsolicited `envelopes` frame is (§14's one-push- path rule, applied to bytes). - **There is no blob want list.** §6 exists because an evicted envelope leaves *no trace* — no summary mentions it, so only a durable want can say it was lost. A missing blob always leaves a trace: the signed record that names it is in the store. What is outstanding is therefore **derived** from the corpus on every cycle, which is strictly better than recorded — it cannot go stale, cannot be lost by a restart, and cannot disagree with the records it is computed from. Adding a want table for blobs would be adding state whose only possible behaviour is to be wrong. - **The CID rule is atproto's, not ours.** CIDv1, raw codec, sha2-256 — what `uploadBlob` returns for the same bytes. That makes a private image record byte-identical to the record a public upload would have produced, which is §10's migration obligation holding for pictures and not only for prose: a republish re-uploads bytes rather than rewriting records. The archive carries them (§13's format, extended to a blob line and a header count) for the reason it carries envelopes: no PDS holds the bytes either, and a backup that restored the record for a picture but not the picture would be silent loss wearing a completeness report. A blob line is not a trust input any more than an envelope line is — the importer stores bytes under the name they hash to, so an edited line fails by name and is reported. What this does **not** decide is iroh-blobs. Transfer here is chunked over the same frames the wire already carries, bounded by numbers taken from the signed record rather than from the peer; a content-addressed transfer protocol with its own dependency remains available later, and nothing above `PrivateBus.fetchBlob` would have to change for it. ## 16. Decision: whom to dial is protocol, and a transport supplies one function *Recorded after §15 because it is what is left of §12's transport item once the parts that are not iroh questions are taken out of it.* §14 moved the wire into `core` and left `PeerLink` as the seam. What it did not move was the question of which links exist at all, and the only implementation of `PeerSource` was `fixedPeers()` — a list handed in once, never re-read, never redialled, never dropped. That is right for a test and is not a policy any replica could run on, and leaving it to the first transport would have repeated exactly the mistake §14 corrected: a native binding and a WASM endpoint would each have invented a dial policy, and two dial policies is how two replicas of one space stop being replicas. So `private/connections.ts` decides it, once, isomorphically. A transport implements **`dial(peer) → PeerLink`** and hands accepted connections to `adopt()`; everything else is here. Three of the decisions are worth stating because each has a wrong answer that looks reasonable: - **The roster is re-read on every round, never cached.** This is what makes revocation *sever* a connection instead of merely refusing the next one: an endpoint the directory has stopped naming has its link closed at the top of the round, before anything is sent to it. Caching it — the obvious performance move — would leave a revoked device connected for as long as the cache lived, and "revoked five minutes ago, still gossiping" is precisely the outcome the design forbids. Revocation, admin removal and retirement arrive here as one event, which is the shape §5 and `peers.ts` already chose: retirement is the absence of an address. - **`peerHints` order the list and never filter it.** A hint is a guess about who is awake, published in a public bookmark, and treating it as the dial set would turn a stale hint into a partition — and would make a public, self-asserted record decide who a replica can reach, which is the shape §3 and §4 keep it away from. Hinted peers are dialled first and everyone the directory names is dialled after them. - **A link that throws is closed; a peer that answers badly is not.** The bus already counted both as failures, and told the source about neither, so a dead connection was handed out again on every publish until the process restarted. `PeerSource.failed()` is reported only when the *link* threw, because that is the transport saying the connection is gone. A refusal, a wrong frame or a repeated cursor is an answer over a working connection; reconnecting would not improve it, and dropping the link there would let one bad blob cost a peer its envelope sync. Backoff is deterministic and has no jitter. Jitter breaks up a herd, and the herd here is a private space's device list — single digits, already staggered by whenever each replica last synced. What determinism buys instead is that "why is this peer not connected?" has the same answer on every machine and in every test, which is the question an operator actually asks. None of this is a gate, and the vocabulary §2 insists on holds: it decides who is **dialled**, `accept()` decides who is **served**, and gates 1 and 2 decide what **counts**. A peer this file connects to is trusted for nothing, which is why a stale roster costs a reconnect and never a wrong record — and why the tests for all of it run over `loopbackLink`, with CI off the network, before any binding exists. ## 17. Decision: a private replica in a tab is not a cache *Recorded after §16 because it is the first half of §12's other item — the browser — and the half that turns out not to depend on the transport at all.* `packages/ui/src/lib/store.ts` opens with a sentence that has been true of every row a Radial tab has ever written: *persistence is a cache of a public record stream; if IndexedDB refuses, the tab keeps working from memory and pays a cold scan next reload.* That sentence is exactly wrong for a private space, and wrong in the direction that loses data. Nothing in a private space is a copy of anything: no PDS holds an envelope, so a browser that shrugged off a refused write would be a replica quietly dropping the only copy of a record — and it would be indistinguishable, from inside the tab, from one that had not. So `private-store.ts` states the opposite rule and the storage layer now carries both: a `Durability` of `cache` on the public path and `required` on the private one, where the queue remembers the first failure and `settle()` rejects with it. Three consequences, each with a plausible-looking wrong answer: - **An envelope and its projection land in one transaction, or neither does.** `SqliteEnvelopeStore` buys this with a single connection and says why; the browser buys it by having the envelope store write through the record store's OWN queue, and by flushing every pending table in one IndexedDB transaction (`writeAll`). The obvious alternative — a queue per store, each writing its own table — reads as tidier and admits precisely the state neither store may be found in after a reload: a record this replica believes and cannot prove, or one it can prove and never reads. The public path inherits the same transaction and is better for it. - **No persistence, no replica.** `openPrivateSpaceStores()` throws where `openSpaceStores()` degrades to memory. This is §3's rule about a device key applied to a corpus: private browsing and a storage-blocked embed are real, and the degraded version works perfectly until the reload that loses the space. A tab that cannot store one has something to say, not something to hide. - **A want that is cleared leaves no row.** The want list is the one table that shrinks, so the storage layer grew a deletion rather than a tombstone — a marker per burst a replica ever survived would be a table that only grows, and §6's want list is supposed to cost a re-request, not a leak. What this deliberately does not do is decide how a browser *fills* one. There is no ingestor, no bus and no writer here, because a tab with no `PeerLink` has no peer to catch up from and the local-write path is the substituted writer §11 already describes. The one number worth recording as a cost rather than a decision: `PrivateBlobStore.get` is synchronous, so an open private space's blobs are held in memory. The honest fix, if that ever bites, is an asynchronous read on the rendering path — never a `get` that answers `undefined` for bytes the replica is holding, which would make `missingBlobs()` disagree with the corpus it is computed from and put a want-shaped hole where a picture is. ## 18. Decision: a tab holds a replica, not a view of somebody else's *Recorded after §17 because it is the rest of §12's browser item, and the surprise is how little of it the transport was actually holding up.* §17 built where a private replica's records live in a browser and stopped there, on the grounds that "a tab with no `PeerLink` has no peer to catch up from". That is true of *catch-up* and turned out to be true of nothing else. A tab can accept a ticket, publish a device to its own PDS, poll the device directory, hold a corpus, and write into it — every one of those is either a public-repo operation or a local one, and none of them needs a peer. So `packages/ui/src/lib/private-space.ts` assembles the same four parts `sidecar/src/private-space.ts` assembles on a machine, and `write.ts` and `session.svelte.ts` run over them unchanged. The shape is the CLI's, deliberately, and three consequences are worth stating: - **The replica's bootstrap is a row in the space's own database.** The CLI writes `space.json` inside the space's directory; a browser writes one `meta` row inside the space's IndexedDB. That is what makes forgetting a space forget its ticket pin *with* the corpus rather than after it — a pin left behind would send the next open looking for a replica that has been deleted. It also answers "is this URI private?" without a profile-wide index: a private space's URI is a NAME, so the only thing that can make a tab open one is already holding it. - **Which two records reach a PDS is unchanged, and now asserted from the browser.** Accepting a ticket publishes a `device` and writes the member's own `join`, and the test that covers it checks the member's repo holds those and nothing else. The write path's claim is the same one pointed the other way: a `project create` in a private space calls `createRecord` zero times. - **A local write is awaited to durability, and a refused one is a sentence.** §17's `required` queue rejects through `settle()`, and this is the caller that was missing: `write()` awaits it and reports "the record was written into this replica, but this browser could not store it" — which is neither "written" nor "failed", and is exactly what happened. What a tab still cannot do *at the time this section was written* is **fill** a replica from anybody else. `LocalOnlyBus` stands in, so an invitee who accepts a ticket holds a directory, a bookmark and a pin, and no space record — and the tab says that in as many words rather than rendering an empty space or a stack trace. That message names the CLI, because a replica on a machine that already holds the space is the only way to see one today. *§22 landed the transport, and the prediction at the end of this paragraph held exactly: nothing above `PeerLink` changed. `LocalOnlyBus` is still here for a tab that has no endpoint, and the message now branches on whether this tab has one — "wait for a member to come online" when it does, and the CLI sentence only when it does not.* One thing was deliberately *not* built with it: the device management UI and the privacy disclosure (§3's "the disclosure is shown to a member before they enable private mode"). They are surfaces over mechanism that now exists, and shipping the mechanism first is what lets them be designed against a replica that really works rather than against a mock of one. Until they exist, accepting a ticket in a browser is a thing the app can do and not yet a thing it offers. §19 is where they land. ## 19. Decision: the disclosure is what makes the door, and the door opens on what works *Recorded after §18 because it is the sentence §18 ended on, kept.* §18 built a replica a tab can hold and declined to offer it, on the grounds that §3 requires the privacy disclosure *before* a member enables private mode and that shipping a paste-a-ticket affordance without one would break that. So the disclosure comes first here, and everything else follows from where it had to go. - **The disclosure is a module, not a paragraph in a component** (`ui/src/lib/private-mode.ts`). Three points — the public device directory, the public bookmark, and "no server holds this space" — each have a stable `id`, so tests assert subjects rather than wording. Two surfaces show the same disclosure: the gated join card and the space page, where it is a disclosure to re-read. (It was four: the absence of per-device revocation was a point until 2026-08, when it was judged to confuse more than it disclosed — that trade is now stated by the retire flow, at the action.) - **The gate is behaviour, not layout.** The Join button does nothing until the acknowledgement is checked, asserted in a real DOM. "The paragraph is above the button" is a claim about a stylesheet; "the button will not write yet" survives somebody rearranging the card. - **Accepting and opening are two steps.** Accepting always achieves something worth keeping — a device published, a bookmark written, a pin recorded — while opening may honestly fail, because until a transport exists nobody can hand this browser the space record (§12). Running them as one action would report the second's failure as the first's, and re-pasting the ticket would be the obvious, useless response. - **The bookmark is what makes a private space findable again, so the picker reads it as one.** A private-mode `join` names a space whose record is on no PDS, so the picker no longer goes and asks for one: it draws the row from the bookmark, marks it private, and uses the optional published label as its name. Fetching would have been a request that can only 404, reported as "could not read this space record" — the wrong sentence about a space that is exactly where it should be. - **Rotate is the only per-device action.** It reopens the space because a replica binds its signing key when opened. Rotation is local hygiene only; the disclosure explains that a published old key remains trusted while this account remains a member. - **Guest comments become a sentence rather than a switch.** The fold refuses `setGuestComments` in a private space, so offering the toggle would be offering a write every materializer ignores — the same objection this app makes to editing a record in place. The section says why it is unavailable, which is the third stop beside the fold's and `Community.svelte`'s. What is still deliberately absent from the browser is **creating** a private space and **minting** a ticket for one. Both are admin operations the CLI has, and both need a replica that already holds a space record — which in a tab means a peer. Offering "invite somebody" from a browser that cannot fill the replica it would be inviting them into is the door §18 declined to build, and it stays declined until there is a transport behind it. *§22 put a transport behind it, and §23 opened it: a browser founds private spaces and mints tickets for them. Read this paragraph as the argument that the disclosure had to come first, which §23 keeps — not as a standing exclusion, which it no longer is.* ## 20. Decision: the daemon holds one endpoint, and the address is how anybody finds it *Recorded last because it is the item §12 opened with, and closing it is what makes a private space something two machines can hold between them rather than one machine can hold alone.* §14 put the wire in `core`, §16 put the dial policy there, and §15 put blob transfer there, so what a transport still owed was bytes — which `@radial/transport-iroh` now moves. None of that made a private space reachable, because nothing bound an endpoint, nothing said where it was, and `radiald run` refused to start with one configured. `daemon/src/private-transport.ts` and `private-run.ts` are that lifecycle, and four decisions in them are worth stating because each has a plausible-looking wrong answer. - **One endpoint per daemon, not per space.** A `deviceAddress` is a fact about a DEVICE and names one endpoint id, so a daemon binding an endpoint per space would have several answers to a record that holds one — two spaces rewriting each other's address forever. §14's per-space topic is exactly what makes one endpoint enough: an inbound frame names its topic and is routed to that space's bus, and a topic nobody attached is answered `unknown-topic` — the same answer a space this endpoint has never heard of gets, because a topic is derived from a public URI and must buy no information about which private spaces a machine holds. - **The endpoint identity is persisted, and the address is written only when it changed.** The secret sits beside the device signing key, 0600, because possession of it *is* this endpoint. A daemon that minted one per start would publish a new address per start: every peer re-reading a hint, every ticket carrying an id that stopped existing at the last restart. The corollary is that the ordinary startup writes nothing to a PDS at all — `publishDeviceAddress` compares what the repo already holds and returns unchanged, which is what keeps a restart free rather than a broadcast. Publication is the third sanctioned in-place rewrite (§5) and lands under the device key's own rkey, so the binding and the hint are two halves of one device under one name. - **Startup order is the argument.** A key published before an endpoint is bound (a replica refuses its own envelope if it cannot verify it), an endpoint bound before an address is published (there is nothing to publish otherwise), an address published before a replica is opened (a device the directory does not name is one nobody dials, so an unpublished daemon can accept connections it will never be offered), and the directory polled before the ticket is checked. Each step is the next one's precondition, which is why they are a sequence in one file rather than four independent initialisers. - **An inbound connection is authorized before it is adopted.** The bus authorizes every inbound *frame* — mandatory, §14 — and that is what stops a stranger enumerating a corpus. Adoption is the other direction: this replica gossiping OUT over a connection somebody else opened, which is what lets a replica that can only dial out receive anything at all. So it asks the same question of the same directory, once per attached space, because a peer in one space is a stranger in the next. Neither is a gate; both are §16's vocabulary held. Two consequences reach further than the transport. **A machine holds one replica of a space, and both programs on it hold that one.** `radiald` opens the replica in `/private/` that the `radial` CLI opens, reads and writes the same `space.json`, and shares the device key store — so `radial private status` describes the corpus the daemon is serving and `radial private export` backs it up. The path and the metadata file moved into `@radial/core/node` to make that structural rather than conventional: two definitions of where a replica lives would be two corpora on one machine with no PDS to reconcile them. The daemon must be **completely stopped** before any CLI access to that replica, not merely between syncs. For a CLI grant or ticket mint, use the same data directory: start `radiald` long enough to publish its address and catch up, stop it completely, run the CLI command, then restart `radiald` before the recipient syncs. The CLI and daemon must never overlap on the replica. **The CLI stays transport-free, deliberately.** It could have bound its own endpoint; it would then have been a second replica of the space on the same machine, and the native binding would have followed it into a package CLAUDE.md requires to stay dependency-free. So the endpoint belongs to the daemon, the CLI writes into the replica the daemon carries, and every private-mode command says which of those two it just did. The daemon imports the binding *dynamically*, only when a private space is configured, so an operator with none neither loads a native module nor fails to start without a platform binary for it. What this does not decide is still the browser (§12): a tab has no endpoint, and routing one through this daemon is the bridge §9 rejected. ## 21. Decision: a private space's steady state is the daemon's, and it is three separate promises *Recorded after §20 because §20 is a **startup** sequence, and the three things below are each a way a correct startup stops being true while the process it started is still running. None of them is a new mechanism; each is an existing one that was only ever run once.* **The address is republished when the endpoint moves, and only then.** §20's rule — publish once, and compare before writing — is right about the restart it was written for and silent about the far more common event: the machine stays up and moves. A laptop changes network, a VPN comes up, the endpoint fails over to another relay, and `deviceAddress` goes on naming a relay this endpoint has left. From every peer's side that is indistinguishable from a daemon that is simply off, which is the worst shape a failure can take — nothing to see, nothing to retry, and a directory that says the dial should have worked. So `radiald run` re-reads where its endpoint is reachable on an interval and republishes when the answer changed. Three decisions inside that are worth stating. The comparison is against **what this process last published**, not against the record, so the ordinary case — nothing moved — costs no PDS round trip at all and the interval is a poll rate rather than a write rate; `publishDeviceAddress` still compares against the record before writing, so the two guards compose rather than duplicate. The relay list is **sorted** before it is compared, because its order is the transport's and carries no meaning: a list that came back permuted is the same endpoint in the same place, and republishing over it would be a write per tick that changes nothing anybody reads. And a publication that did not land for **every** identity records nothing, so the next check retries it — publishing is best-effort per identity by design (§20: an operator running two agents whose second PDS is down should still have the first reachable), and recording a partial success as a success would make "the PDS was down when I started" a thing that resolves only when the machine happens to move. **Gossip wakes the loop, which is what makes `streaming` true.** `PrivateSpaceRuntime.streaming` is true as soon as its bus is joined, and `radiald run` reads that to decide it may drop to Jetstream's slow backfill interval. Nothing woke it. So a private space — the one mode with **no polling fallback**, because there is no PDS to poll — could slow the whole loop down while having no way to speed it back up, and joining the bus made a space *later* to notice a record than a public one that merely polls. `PrivateSpaceIngestor` now notifies on arrival and `radiald run` wires that to the same wake the firehose uses. It is deliberately a **notification and not a delivery**: the envelope still lands in the inbox and is still admitted by the one pipeline in `sync()`, so nothing about what counts depends on whether anybody was listening — and a listener that throws is contained, because gossip must land whether or not the thing it would have woken is still there. **A command that reports a number must have computed one.** `radial private sync` ran half a cycle — a directory poll — and then reported `lastReport.admitted`, a field only a whole cycle ever writes. It was not a small number; it was a field nothing had set, and a command whose output cannot vary is worse than one that says nothing, because it reads as an answer. It now runs the cycle and reports what the cycle and the replica actually say: records, envelopes, what is outstanding, and whether the replica folds at all. The one number that stays structurally zero is `admitted`, and it stays for the reason §20 gives: the CLI holds no endpoint, so the catch-up in the middle of a cycle asks `LocalOnlyBus` and is answered with nothing. Printing it beside a sentence that says so is the honest shape — the alternative, hiding it, would leave an operator unable to tell "nothing arrived" from "nothing was asked". The two states a closing fold refuses are reported rather than thrown, and they are identified by asking the replica what it holds rather than by reading the exception: a replica no peer has handed the space record to yet, and one whose pin disagrees with the record it has. Anything else is this command failing and is raised. **A refusal is readable by the peer it refuses.** `ErrorFrame.status` has carried `'unsupported-version'` since §14 and nothing ever produced one: `decodeFrame` threw, and a transport answered a throw by resetting the stream. A reset says only that something went wrong, which leaves a peer on a future wire version retrying a frame forever with no way to learn why. `decodeFrame` now throws a `FrameError` carrying the status the peer earned — the version mismatch names itself, and everything else is `malformed`, re-tagged in one place so a check added later cannot forget to — and `refusalFor()` turns that into the frame the transport sends back. Which failures are which stays a protocol question answered in `core`; the transport decides only whether there is still a stream to answer on. The consequence worth stating is the exemption it required. An `error` frame is now read for its shape whatever version it announces, because enforcing the version on it would make `unsupported-version` unreadable by precisely the peer it is addressed to — the refusal would be refused. That is safe for the narrow reason that a refusal is a statement about a request this endpoint already made: it can never carry a record, it settles nothing, and nothing downstream reads one except the bus that was waiting for an answer. ## 22. Decision: a tab holds an endpoint, and the lifecycle above it is the daemon's *Recorded last because it is the item §12 has carried since it was written, and closing it is what makes "a browser tab is a second full implementation of Radial" true of private mode too. §18 built a replica a tab can hold and ended on the one thing it could not do: fill one.* §20 gave the daemon an endpoint and left the browser with `LocalOnlyBus`. What was actually missing turned out to be smaller than "a transport" and larger than "wire up the WASM package", and the four decisions below are the difference. - **The endpoint lifecycle moved into `core`, and the daemon lost nothing by it.** `PrivateEndpoint` — one endpoint per runtime, each inbound frame routed to the space whose topic it names, each inbound connection authorized against that space's directory before it is adopted, leased so one space cannot disconnect another — was written in `daemon/src/private-transport.ts` when the daemon was the only thing that had an endpoint. Every argument in it is about Radial and none is about Node. Reimplementing it in the tab was the obvious move and the wrong one, for the third time in this document: §14 moved the wire into `core` and §16 moved the dial policy there for the same reason, which is that two implementations of one lifecycle is how a tab and a daemon stop agreeing about what a connection is. So it lives in `core/private/endpoint.ts`, and what stayed runtime-specific is exactly the two things that genuinely are — **which binding to import**, and **where the endpoint secret is kept**. The daemon keeps `EndpointKeyStore` (a 0600 file) and `irohPeerTransport`; the tab has `BrowserEndpointKeyStore` (one IndexedDB row) and `wasmPeerTransport`. `PrivateEndpoint.bind` now requires a transport factory rather than defaulting to the native one, which is what keeps `core` naming no binding. - **The endpoint belongs to the tab, not to the space.** One per tab, bound lazily on the first private space opened and kept while the tab lives, for §20's reason — a `deviceAddress` names one endpoint id — plus one the daemon does not have: a person navigates. Rebinding per space would cost a relay handshake per navigation, and unbinding on close would make the address this tab published a lie for as long as they were looking at something else. So closing a replica **detaches** the space and leaves the endpoint up, which is the one method `PrivateEndpoint` grew for the browser; a daemon's spaces live as long as its process and never needed it. The memo is on the *promise*, not the result, because two spaces opened in one tick would otherwise bind two endpoints with one identity, and a relay resolves that by routing to whichever registered last. - **The endpoint secret is raw bytes in IndexedDB, and that is not a retreat from §3.** A device signing key in a browser is a non-extractable `CryptoKey` and never has a serialized form; an endpoint secret cannot be, because the binding needs the bytes to bind with. The reason that is acceptable is not "it is only a transport": it is that an endpoint identity **authorizes connections and authors nothing**, so holding it buys being served this space's envelopes and never the ability to write one — and anything that can read the row can already read the corpus out of the space's own database on the same origin. It grants an attacker nothing they did not have. Losing it is a reconnect, not a fork (§20). A row that is not 32 bytes mints a fresh identity rather than throwing, because binding with a wrong secret produces an endpoint no peer's hint names, which is indistinguishable from being offline. - **Three states, three sentences, and the middle one is the point.** A tab with no endpoint, a tab whose endpoint nobody answered, and a tab with peers on it are different facts with different next steps, and the failure mode this design keeps rejecting is reporting the first as the second. `LocalOnlyBus` survives and is still honest — a test, an accept that has not opened the space yet, a browser where the binding will not load — and `replicaStatus` reports `localOnly` beside `peers` rather than collapsing them into a count. It is §21's argument about `radial private sync`'s structurally-zero `admitted`, applied to a screen: a bus that was never asked has not found the space empty. Two consequences reach past the transport. **The binding is served as an app asset, not bundled, and the build tolerates its absence.** `pkg/` is a build product of a Rust toolchain — `cargo`, `clang`, `wasm-pack` — which is a heavier requirement than anything else in this repository has. Making Vite resolve it statically would put that toolchain on the critical path of every `pnpm build`; so `scripts/copy-wasm.mjs` copies it into `static/wasm/` when it exists, says so when it does not, and the app built without it is a complete app whose private spaces report having no transport endpoint. The deploy workflow is where that tolerance stops: it installs the toolchain, runs `build:wasm`, and *gates on the files being in the bundle*, because a deploy that silently shipped an unreachable app would look exactly like a working one. The adapter therefore takes `moduleUrl` and has **no default** — `new URL('../pkg/…', import.meta.url)` is not merely wrong under a bundler but actively harmful, since Vite rewrites that pattern and emits a second copy of the glue beside a chunk with no `.wasm` next to it. And the 2.0 MB is why the import is dynamic and unanalysable, enforced the way the editor chunk's is: a rule about imports in `browser-bundle.test.mjs`, not a number in a budget file. **What §19 declined is now a choice rather than a constraint.** Creating a private space and minting a ticket from a browser stayed out because both need a replica that already holds the space record, "which in a tab means a peer". A tab now has peers. Nothing here opens that door — whether founding a space belongs in a browser is a product question, and it is being left as one — but the reason recorded for keeping it shut has expired, and a future reader should not mistake the remaining absence for the old argument. *§23 answered the product question: founding one is a tab's to do.* ### Was known and not fixed: two tabs of one profile share an endpoint identity The memo above is per **tab**; the endpoint secret is per **browser profile**. Open the same account in two tabs and both bound an iroh endpoint with the same node id, which the relay resolved by routing to whichever registered last — so one tab accepted no inbound connection and adopted no link while `replicaStatus` reported it as having a real endpoint. Both also published an identical `deviceAddress` under the same device key, so nothing detected the collision. It was recorded rather than fixed here because the degradation was narrow and the fixes were not, and the one that was right — "a `navigator.locks` holder that owns the endpoint and a `BroadcastChannel` the other tabs sync through" — was a mechanism this codebase had nowhere else and deserved to be designed on its own terms rather than smuggled in here. **§26 is that design.** The rejected patch is worth keeping: minting a *second* identity for the second tab would have been worse, not better, because the two tabs share one device key and the second would have overwritten the first's published address and taken the working tab down with it. ### Discharged: the connect half of §9 This section recorded the one thing that could still have changed the design. It no longer can: on 2026-08-05 a tab reached a real relay and a real daemon through it, and caught up (§9). Everything above remains exercised over `loopbackLink`, by design. ## 23. Decision: founding a space and inviting somebody are a tab's to do, and creating does not mint *Recorded after §22 because it is the door §19 declined to build and §22 unlocked without opening.* §19 kept two admin operations out of the browser — creating a private space, and minting a ticket for one — on the grounds that both need a replica which already holds a space record, "which in a tab means a peer". §22 gave a tab peers and was careful to say that the *reason* had expired rather than that the answer had changed: what was left was a product question. This is the answer to it, and every decision in it is about what a browser does not get to skip. - **The sequence is `private-cli.ts`'s, step for step, because each step is the next one's precondition.** Publish this browser's device *first*: a replica verifies its own genesis envelope through gate 1, against a key it reads back out of the founder's public repo, so a founder whose key is not published cannot found anything — the one place where "sign your own work" is circular-looking and is not. Then mint the URI, *then* open the store, because a browser's replica is a database named after the space and the name has to exist before the record can be sealed into it — which is why `space create --private` writes at exactly that rkey rather than at a fresh TID. Then poll the directory, which is how the key published in step one becomes readable *here*. Then seal the genesis and settle it, because a record that did not reach durable storage is a space that does not exist (§17). Then pin it: the founder records their own genesis cid for the reason the CLI does — every ticket they hand out carries it, and they are the one observer nobody could ever tell out of band that it had been replaced. The public bookmark is last, and that ordering is the whole of the failure story: **a create that could not seal the genesis announces nothing.** The device survives a failure because publishing one is idempotent and harmless; the bookmark does not exist, so there is no member pointing at a space whose only copy was never written. - **The disclosure gates founding exactly as it gates joining.** §19 called the disclosure "what makes the door", and it would be a strange reading of that to put a second door beside it with no disclosure on it. Founding a private space *is* enabling private mode — it is the ADR §3 moment, and it publishes the same two public records an accept does — so the checkbox is the same behavioural gate, asserted in a real DOM in the same terms: not that the paragraph is above the button, but that the button will not write yet. - **Creating does not mint, in either surface.** `radial space create --private` prints the space URI, not a ticket. A CLI operator starts `radiald` long enough to publish an address and catch up, stops it completely, mints the requested ticket, then restarts the daemon before the recipient syncs; the two programs never overlap on that replica. A tab has the space page one navigation away, and a ticket is a secret with a lifetime — minting one nobody asked for yet is producing something to be looked after, in the flow least likely to be the one where somebody has an invitee in mind. So the space page mints, on demand, and mints a *current* ticket against the directory as it stands rather than one frozen at creation. There is exactly one surface in the app that displays a ticket, which is also how many places have to get displaying one right. - **A ticket is minted from a fresh fold, not the displayed index.** `mintSpaceTicket` first re-reads the directory-only collections for every active member, then refolds; it does not catch up envelopes or blobs just to mint. Everything in a ticket is the space record plus the founder's *published* key. Two things follow. The refreshed fold asserts the pin, so there is no way to mint an invitation to a genesis this replica is refusing to fold. And the key named is the **founder's** whoever is minting, because a ticket bootstraps somebody who can read nothing yet and therefore has to name the key that verifies the space's oldest envelopes — read out of the directory the fold holds, or, for a founder whose own record has not been read back yet, out of this browser's key store, which is the same key by the shorter route. Two consequences worth stating. **No endpoint hint goes in a browser-minted ticket, and that is not an omission.** The founder's `deviceAddress` is in their public repo — published by the ordinary open, on the same tick that syncs (§21) — and reading the founder's directory is the *first* thing an accept does. A hint copied into the ticket could therefore only be a staler answer to a question already asked, and it would freeze whichever relay this tab happened to be on into a string somebody keeps in a chat log. **The published label is asked for separately, and defaults to nothing.** A space's name is a private record and stays one; the `join` bookmark's optional `label` is the founder choosing to disclose what the space is called, and prefilling it from the name would turn the minimum disclosure into the default one for the sake of a nicer row in the picker. So the form asks, and says what leaving it blank costs: a space listed by its identifier alone. ## 24. Decision: a replica with no space record yet is a state the tab stays in, not an open that failed *Recorded after §23 because it is the bug §23 shipped into: two browsers, one account, and a space the second could not open until it was asked a second time.* §18 gave a tab a replica it could hold and not fill, and wrote the sentence for it — "no peer has handed it the space record yet… this tab is connected and will pick it up as soon as one is". §22 gave the tab a transport, so the sentence became true in principle. It was still false in fact, because the open that printed it also **tore the replica down**: `session.status = 'failed'`, `stopLive()`, no timer. There was no *it* left to pick anything up. The reported symptom is exactly what that produces — create a space in one browser, open it in a second, get the sentence, refresh, and have it work — and refreshing was not a workaround so much as the only surviving retry. The cause underneath is ordinary and permanent, which is what makes the failure the wrong shape. A second browser publishes a `device` and a `deviceAddress`, then dials. The peer it dials authorizes inbound connections **against its own directory** (`endpoint.ts`, `peers.ts`), and its directory is a poll behind — so the first dial is refused by a peer that is up, willing, and simply has not read the record naming this device yet. That is `PeerRefusal = 'unknown'`, which §16 defines as *ask again*. One catch-up attempt in the open path is not asking again; it is asking once and calling the answer final. - **So the tab waits, and keeps syncing.** `waiting` is a session status of its own rather than a flavour of `failed`, because the two differ in the only way that matters: one has a loop running. It draws what `failed` draws — the picker, and the sentence — since somebody waiting on somebody else should be offered something else to do, and the picker is where they would go anyway. What it does not do is stop. The tick that would have refreshed a space that opened is the tick that finishes opening this one, with nobody asked to press anything. - **A throw on that path is the ordinary answer, and a pin mismatch still is not.** `PrivateSpaceIngestor.sync()` runs the whole cycle and *then* refuses to fold a space it has never seen, so on a waiting replica the throw means "still nobody" and reporting it would replace the sentence that explains the wait with one about a fold. `catchUpWaiting` therefore swallows the cycle's throw and asks `indexOrNothing()` instead — which asserts the ticket's pin and rethrows, so a replica holding the wrong genesis says so here exactly as it would have at the open. The distinction is the same one §21 drew for `radial private sync`: identify the state by asking the replica what it holds, never by reading the exception. - **A refused address publish outranks the wait.** It is the one failure that can make waiting permanent — a device the directory does not name is one nobody offers a connection to (§20) — so on a network where only the peer can dial, a tab reporting "waiting for a member" while its own address never landed would be describing somebody else's problem. That message wins. - **Showing the space is one function now.** Three copies of publish-and-mark-ready existed, and this added a fourth caller in the tick; the version that opens a space from a tick is precisely the one that would have been written slightly differently and left the status saying it never opened. So `present()` is one function, and every route into a rendered space goes through it. What this deliberately does **not** do is shorten the window by making the serving side re-poll on an unknown inbound endpoint. It is tempting — §16 does contemplate a bounded refresh on `unknown` — but it puts a PDS read on a path a stranger can trigger, and the bound would have to be designed against that rather than against the convenience. With the loop running, the existing numbers already close it: the dialler's backoff is 1s, 5s, 15s and the peer's directory poll is 10s, so a device published seconds ago is served within the first half-minute, unattended. Making that faster is a performance question. Making the tab keep asking was a correctness one. ## 25. Decision: a hidden tab stops reading, and keeps answering *Recorded after §24 because it is the rest of the same bug. §24 made a tab that could not open a space keep trying; this is why it had nothing to try against.* The visibility rule is older than private mode and right for what it was written for: a hidden tab polls nothing, because a view nobody is looking at can go stale and catch up on return. Ten seconds of a foreground tab is a `getLatestCommit` per member; ten seconds of a background one should be nothing. On the private path that rule quietly ends a service other people depend on. This tab has published a `deviceAddress` saying "you can reach me here", and it authorizes every inbound connection against **the device directory it has polled** (`endpoint.ts`, `peers.ts`). Stop polling and it goes on advertising the address while refusing every peer whose device is newer than its last poll — which is precisely the case that matters, because *a peer dialling for the first time has just published one*. The person opening the space in a second browser sees "no peer has handed it the space record yet" while the first browser sits there, up, holding the whole corpus, and saying no. Neither side can tell that from being offline. That is §21's failure mode — "nothing to see, nothing to retry, and a directory that says the dial should have worked" — arrived at from the other end. It also explains why it was intermittent, which is the detail that makes the diagnosis stick: a window is `hidden` when it is minimised or fully occluded and *not* when it is merely unfocused, so whether the second browser could open the space depended on how the first browser's window happened to be stacked. - **The beat is reachability, and nothing else.** A hidden serving tab keeps `refreshAddress()` and a device-directory poll, and drops the catch-up, the fold and the repaint. Both halves it keeps are cheap by construction — the address comparison is local and writes only when this tab actually moved (§21), and a directory poll is a `getLatestCommit` per bootstrap DID unless a repo changed — and both are about *being reachable* rather than about being current. A hidden tab still stops reading. It just stops lying about being available. - **It is an obligation a published address creates, not a thing every private tab does.** The guard is `serving` — a replica with an endpoint — rather than "is private". A `LocalOnlyBus` tab has nobody to stay reachable for, and doing repo reads on its behalf would be a background tab making requests for nothing. - **Failures are swallowed.** Nothing on screen is waiting for a beat, the next one retries, and a transient PDS error surfaced into `session.error` from a tab nobody is looking at is read minutes later as the explanation for something else. - **`PeerConnections.retryNow()` exists because the ladder is answering a different question.** Backoff answers "this peer is unreachable, stop hammering it", and that is right. It is wrong about the peer that is up and refused this replica because its directory is a poll behind — §16's `unknown`, which means *ask again* — and the two are indistinguishable from the dialling side, because the peer closes the connection either way. So the first dials of a newly published device spend strikes on a peer that was always going to say yes. `retryNow()` clears the wait and **keeps the failure count**, so it costs one attempt and a peer that really is gone resumes the ladder where it left off. It is called when a tab becomes visible: a person coming back to it is a moment worth spending a dial on, and it cannot loop. The waiting loop is deliberately **not** given `retryNow()` on every tick. It would make recovery a second faster and turn a tab parked on an unreachable space into an indefinite dialer, which is the thing the ladder is for. With the beat running, the peer's directory is at most one poll behind, so a first open recovers in about one or two poll intervals unattended — bounded, self-healing, and without a tab that has given up still dialling somebody every ten seconds. One sentence was added beside the mechanism, because a diagnosis nobody can make from the screen is not much of a diagnosis. A waiting tab whose connection state list is **empty** was not refused by anybody — it had nobody to dial — and that is not the same fact as "asked and unanswered", for the third time in this document (§21's structurally-zero `admitted`, §22's `localOnly` beside `peers`). When this was written it had exactly one common cause worth naming: §22's known limitation, where two windows of one browser profile bound two endpoints with one identity and each therefore filtered the other out of its roster as *itself*. **§26 removed that cause** — every window of a profile now shares one endpoint, so the empty list means the honest remaining thing, that this browser is the only device in the directory to have said where it is, and the sentence says that instead. The rule the sentence is an instance of did not change. *Not reproduced here.* The visibility mechanism above is exercised in tests; the original report — two browsers, one account, one machine, over a real relay — was diagnosed from the code and is not something this repository's tests can stage, since everything here runs over `loopbackLink` by design (§9). ## 26. Decision: one transport per browser profile, and the other tabs relay through the tab holding it *Recorded to discharge §22's "known and not fixed", which named this fix and declined to build it.* The endpoint identity is a row in IndexedDB, so it is per **profile**; §22's memo is per **tab**. Two tabs therefore bound two iroh endpoints with one node id, a relay resolved that by routing to whichever registered last, and the loser accepted no inbound connection while reporting a real endpoint. Both published the same `deviceAddress` under the same device key, so nothing detected it. `private-tabs.ts` elects one tab to bind and relays the others through it: `navigator.locks` decides which, and one `BroadcastChannel` carries dials, frames and connection offers between them. Six things in it are decisions rather than mechanism. - **The holder owns the transport and *not* the replica, and that boundary is the whole design.** The tempting shape was one tab that holds the endpoint *and* every space any tab has open, with the followers proxying a `PrivateBus`. It is wrong for a reason the browser makes concrete: a replica's stores are a **hydrated in-memory mirror** of IndexedDB (`private-store.ts` — `records()` is synchronous, which is what lets the fold run at all), so a holder serving a space out of *its own* handles would answer a peer's catch-up out of a corpus that lags whatever the tab actually looking at that space has ingested. The profile would serve peers less than it holds and nothing would say so. So a follower keeps its own `PrivateEndpoint`, its own `WirePrivateBus` over its own stores, its own `PeerConnections` and its own ingestor, and the holder moves bytes. The seam is `PeerTransportFactory`, which is the same seam the daemon and the WASM binding satisfy — and that is why nothing above `private-transport.ts` changed: `PrivateEndpoint` cannot tell a holder from a follower, and neither can `private-space.ts`. - **Election is a lock, not a message.** A `BroadcastChannel` protocol that elected a leader by agreement would have a window in which two tabs both believe they bind, which is the bug. The lock is held for the life of the holder's endpoint and released by the *browser* when that tab closes, crashes or is killed — so the one property that has to hold, at most one bind per profile, is not something this code can get wrong. Everything else here is bookkeeping around it. What the browser cannot do for us is the failure path: a bind can throw for entirely ordinary reasons — a WASM module that would not load, a relay that would not answer — and the lock must go **back** when it does, because a tab holding it while binding nothing takes the whole profile down. Every other tab would find a holder that never answers `hello` and report that another window is wedged, which is both wrong and unactionable. The same reasoning names the third state on the channel: a tab that has the lock and is still binding says so (`binding`), because "working" and "wedged" look identical from the outside and an electing tab that collapses them blames the window doing the work. A cold fetch of two megabytes of WebAssembly is *seconds*, so this is the common case, not the corner. - **A promotion re-reads the identity; it does not remember it.** The tab that takes the lock next binds the SAME node id, so every peer's `deviceAddress` hint stays good and a handoff costs a relay reconnect rather than a republish. The secret is deliberately **not** shipped over the channel: re-reading the key store at the moment of promotion is both simpler and honest about the case where there is nothing to read — private browsing keeps no identity, so a promoted tab mints and republishes, which is §20's "losing it is a reconnect, not a fork" and not a new state. What cannot carry over is the connections: they were the closed tab's sockets. Every relayed link is **orphaned**, so it *throws* rather than resolving to nothing, because `WirePrivateBus` reports a link that threw to `PeerConnections`, which closes it and dials a fresh one. A silent failure would leave a replica believing it is connected to a tab that no longer exists — and with three tabs open that is not only the promoted tab's problem, which is the easy thing to get wrong here. The tabs that stay followers hold links to the same closed socket, so a holder going away orphans them *wherever* they are held (`#orphanThrough`, on `gone` and on a `holder` message naming somebody else) rather than only in the tab that won the lock. Otherwise the tabs that were not promoted are the ones that hang, for a relayed frame's whole timeout, which is the state this bullet exists to rule out. - **An inbound frame is offered to every tab, and that leaks nothing.** The holder asks its own endpoint first — the tab a person is looking at is nearly always the holder, so the ordinary case costs one function call and no channel hop. `unknown-topic` from it is not yet the peer's answer: it is *this tab* saying "not mine", and another tab may have that space open. Only when nobody claims it does it become the answer, which is exactly what a single-tab `PrivateEndpoint` would have said. Asking every tab is safe for the reason §22 gives for answering `unknown-topic` at all: a topic is derived from a public URI and confers nothing, and these are tabs of one origin that can already read each other's databases. The `peerId` forwarded is the identity the transport authenticated, so a relayed frame is authorized in exactly the place an unrelayed one is — the serving tab's `WirePrivateBus.accept`, against the directory that tab polled. **This file moves bytes between tabs. It decides nothing about them**, and a rule that ever needs it to is in the wrong file. - **An inbound connection is leased per tab, one level up from where it already was.** `PrivateEndpoint` leases one physical link across every *space* that wants it so none can disconnect another (§22); the same argument holds between tabs, so the holder leases across tabs and closes the socket when the last lease goes. A tab is always given a lease rather than asked whether it wants one, because `PrivateEndpoint.#adopt` closes an unwanted link *synchronously* — the release is immediate and answering "do you want it?" would mean asking the endpoint a question it has no method for. `gone` covers the ordinary close; a **liveness sweep** over each tab's own lock covers the crash, because otherwise a browsing session's worth of tab churn leaves the holder with a connection per tab that ever existed. - **A browser with neither API binds directly and says `unshared`.** Not a degraded mode with a message: it is exactly what this app did before, and such a tab has no way to coordinate with another even in principle. `share()` is reported beside `localOnly` and `peers` for the fourth application of the same rule this document keeps applying (§21, §22, §25) — "this window holds the connection", "another window does", and "there is no connection" are three different facts, and a person who cannot see the second has no way to explain why closing an unrelated window paused their sync for a second. Nothing acts on it. A relayed connection is a connection. **What this does not fix, and is not pretending to.** Two tabs with the *same* private space open still hold two hydrated mirrors of one IndexedDB replica, so a write in one is not visible in the other until it comes back around through a peer. That is a separate hazard with a separate shape — it is about the store, not the transport — and folding a fix for it into this one would have produced exactly the holder-owns-the-replica design rejected in the first point above. *Not reproduced over a real relay.* Everything here is exercised over `MemoryTabNetwork`, which implements the two contracts this depends on — broadcast-to-everybody-else, and a lock exactly one holder holds with the rest queued behind it — for the same reason `MemoryPrivateBus` stands in for a transport (§9). The property that a second tab now binds nothing is asserted directly, and it is the property the original report was about. ## 27. Decision: a bookmark bootstraps a machine, and a ticket is only ever a first step *Recorded because §22 gave a browser this and left a machine without it, and the gap was being paid for in re-sent secrets.* §22 taught a fresh browser to rebuild a replica from the account's own `join` bookmark, on the argument that "the bookmark carries everything a bootstrap needs (the space's `{uri, cid}` pin, the founder, the protocol, the hints): that is what §3 put it on the public path FOR". Everything in that sentence is true of a machine too, and the CLI could not do it — `radial ticket accept` was the only way a replica came into existence there. So the standard answer to "I want a daemon on this box for a space I am already in" was *ask an admin to mint you another ticket*: a secret re-sent, out of band, for a space the asker had already published a bookmark about. That is the shape this whole document argues against, arrived at by omission rather than by decision. `radial private recover` is `acceptTicket` with the ticket's two load-bearing halves read out of the member's own record instead. Four things in it are decisions. - **It writes no `join`.** Reading one is how it got here. A second create would be a rewrite of a record this protocol does not rewrite, and the bookmark it would rewrite is the *evidence* the recovery was legitimate. - **It runs no `ticketDirectoryMismatch`, and does not pretend to.** That check compares an out-of-band claim against the founder's published directory, and there is no out-of-band claim here — only this member's own earlier statement, which cannot corroborate itself. What survives is the check that was never about the ticket: the pin travels into `space.json` and every later fold runs `spacePinMismatch` against it, exactly as it does for an accept. A founder with no readable device is a **warning**, because the daemon polls again and one appears. - **The device is published first, in the accept's order**, for the accept's reason: a device the directory does not name is one no peer will serve (§20), so doing it second would enter §24's wait on purpose rather than by accident. - **With no `--space` it recovers everything bookmarked and not held.** A member setting up a machine knows they are a member and routinely does not know the URI of a space whose records they have only seen in an app, and the discovery costs one `listRecords` of a collection this DID authored. The parser is shared rather than reimplemented: `privateBookmark()` sits beside `joinRecordFor()` in `sidecar/src/private-records.ts` — the read half next to the write half, on the isomorphic side — and the browser's recovery now goes through it. Two parsers for one record would be two answers to "is this space private?", which is the class of divergence that file was created to prevent. **What this changes above it.** A ticket is now, in both runtimes, a bootstrap for somebody who holds *nothing* — never a way for a member to reach their own space a second time. The space page's member rows offer one anyway (a lost first copy is real), but the rationale for offering it everywhere is no longer "the CLI cannot recover"; it is that `bootstrapped` is poll-dependent observer state, and an action that appears when a directory poll lands is a control that was missing a moment ago. ## 28. Decision: a private space's genesis carries a nonce, because its hash is published *Recorded when the question "why are the bookmarks safe?" was asked of §4 and the honest answer turned out to have an exception in it.* §4 put the space's `{uri, cid}` strongref in the public bookmark, carried over from the public `join` it extends. In a public space a genesis cid is meaningless as a disclosure — the record it commits to is readable by anyone. In a private space the same field became a **commitment to a record that never leaves a replica**, and nothing in §3's leak analysis noticed the change of meaning. The preimage was `{name, description, private, createdAt}`. A name is guessable, a description is not required by the create form and is routinely empty, `private` is a constant, and `createdAt` is already disclosed to the microsecond by the space's own TID rkey. So the pin answered "is that space the one called *X*?" for anyone holding a member's public repo — measured at ~6ms per candidate name over a one-second timestamp window, single-threaded. Not extraction: it cannot recover a name nobody guesses. But confirmation is the whole of what an observer usually wants, and a private space answering it to the network is not a property worth keeping. `space.nonce` is 128 random bits in the genesis, and it works because of where the record lives: - **It is not public.** The space record travels in envelopes and reaches no PDS; only its hash escapes. A nonce is only useless when the thing it pads is readable anyway — which is why a public space gets none. - **Verifying the pin never reveals it.** Whoever checks a pin already holds the candidate record, handed over by a peer, so the nonce is one more field in bytes they were hashing regardless. An ordinary hiding commitment, opened by producing the whole record. - **Nothing reads it**, and that is a rule rather than an accident of the current code. A value with a meaning is a value some future check will depend on, and this one cannot be regenerated by anybody. It is opaque padding; a second implementation preserves it verbatim and asks nothing of it. Two consequences worth stating. Spaces created before this have no nonce and **cannot be given one** — a genesis is never rewritten, and rewriting one would invalidate every ticket and bookmark already handed out — so the field is optional in the lexicon and the old exposure is permanent for old spaces. And it does nothing about `label`: a founder who publishes one has disclosed the name on purpose, which is exactly why §23 keeps it opt-in and refuses to prefill it. ## 29. Decision: a device is withdrawn from the network and never from the fold, and the mechanism was already here *Recorded when "how would we implement device removal?" was asked and the answer turned out to be a missing write rather than a missing design.* §2 says private spaces have no per-device revocation and leaves the compromise response as `removeMember`, which drops a member's whole corpus. That is a large hammer for the ordinary event — a laptop is stolen, a phone is lost — and the retroactive revocation this document experimented with and withdrew was a larger one. But §16 and `peers.ts` had already described a smaller answer in passing: *"retirement is the absence of a published address"*, a **connection** statement in the vocabulary §2 insists on, changing nothing about what any replica counts. Nothing produced one. `rotateDeviceKey` set a flag in the local key store; the old key's `deviceAddress` stayed in the repo, so every replica went on dialling the key that machine had stopped using and went on authorizing it inbound. The sentence in `peers.ts` describing what a retired device looks like was aspirational — there was no way to become one. `deviceAddress` gains an optional `retiredAt`, `readDeviceDirectory` keeps a retired address out of `directory.addresses`, and `retireDeviceAddress` is the write. Everything else follows from machinery that was already load-bearing: absent from `addresses` means absent from `connectablePeers()`, which means `authorizeEndpoint` answers `unknown`, and because `PeerConnections` re-reads its roster **every round and never caches it** (§16), a link that is already open is *closed* rather than merely not re-opened. Five decisions are worth stating. - **It is a connection statement, and the fold is where that has to be true.** Address hints are excluded from the device fold by construction (§5), so a retired device stays bound and everything it ever signed goes on counting, on every replica, identically. The test asserts the whole index and the whole digest are byte-for-byte unchanged rather than checking that the retired key's own records survived, because the interesting failure is not "some history vanished" — it is any difference at all. That is also why this needs no lexicon migration, no fold rule, no permutation coverage of a new record, and **no protocol version bump**: an older build that ignores the field makes a worse *connection* decision and still folds the identical corpus. A mixed-version space degrades; it cannot fork. That asymmetry is the whole argument for solving it at this layer. - **A retired device is refused as `unknown`, not as a status of its own.** The withdrawn revocation design had typed refusals (`revoked`, `removed`) and they were a real cost: the peer being refused is, in the case this exists for, the one holding the stolen key, and a status that says *why* is a status that says whether the theft has been noticed. `unknown` also happens to be the correct instruction to a dialler — ask again — and a retiring owner is exactly who makes the answer stay the same. §16's bounded refresh on `unknown` finds nothing, which is what "excluded from new connections" means. - **It is rewritten in place and is reversible, because the owner's repo is the authority.** `deviceAddress` is already the third sanctioned in-place rewrite (§5), so this needs no delete semantics from any ingestor and no tombstone. Publishing an address again un-retires the device — which forced the one non-obvious line in the diff: `sameHint` must treat a retired record as *different* from a live hint, or a device coming back would be stranded forever precisely because the address it wants to publish is the address it was retired at. Retirement DELETES from the address map and publication SETS, so latest-wins runs in both directions. - **Rotation retires the outgoing address, and that is what makes rotation mean anything to anybody else.** It was the sentence §18 already claimed. The cost is stated rather than engineered away: between rotating and the next address publication a machine names no reachable device, which for a daemon or a tab is one sync tick, and for the CLI — which holds no endpoint (§20) — is nothing, because there was no address to withdraw. - **What it does not do is the part that must not be blurred.** A key stolen *without* its owner's repo credentials becomes useless on this bus: it cannot be dialled and cannot be served. A key stolen *with* them — which is the ordinary shape of a stolen laptop, since the session is on the disk beside the signing key — republishes its own address on the next tick and undoes this. So the honest instruction is two steps, and the surfaces say so in those words: **end the session at your PDS, then retire the device.** And records the key already landed are already everywhere and go on counting. This is containment, never repair. (The disclosure carried a `no-revocation` subject saying so until 2026-08; the sentence now lives on the retire surface itself, beside the action it qualifies.) **What this leaves open, deliberately.** There is still no way for an *admin* to withdraw somebody else's device, because a device's address is in its owner's repo and that is the same fact that makes retiring work at all. A space-scoped, envelope-borne removal that only `peers.ts` reads would fit here without touching the fold, and it is not built: `removeMember` already answers the case where the group has stopped trusting a person, and this answers the case where a person has stopped trusting a machine. Nor is there repair — an attested cutoff, where a surviving device names the versions that keep counting so a removal need not be all-or-nothing, remains the shape any future revocation should take, and remains unbuilt. ## 30. Decision: connection decisions are settled at read time, and a replica learns whom to poll from whoever dials it *Recorded after "a fresh browser can only open a private space while another browser has it open" turned out to be a discovery deadlock with a connection lifetime at the bottom of it — and revised five times in review before the design stopped moving. This section states the settled design first; §30.1–§30.3 keep their original numbers because the source cites them, each rewritten as the rule it converged on; §30.4 is the history that produced it, kept because every hole had the same shape and the shape is the lesson.* **The problem.** With a daemon serving a space and no other browser running, a fresh tab reached `waiting` (§24) and stayed there forever. A fresh replica polls the founder and itself — all a bookmark, a ticket or a space URI can name — and a daemon's identities are its *agent profile* DIDs, in none of them, so the tab never dialled the daemon. The daemon *did* find the tab (its `knownDids` grows from the fold's members) and dialled it — and the tab, authorizing the inbound endpoint against a directory that had never heard of it, closed the connection on the accept. No frame carried the dialer's identity, so the refusal taught the tab nothing about whose repo to read next. Both sides retried forever. **The fix is one sentence in two halves:** a dialer says who it claims to be, and a replica keeps the connection long enough to hear it. What that claim is *worth* is the entire rest of this section, and pricing it correctly converged on one invariant: **What any directory entry is worth is settled at read time, by what the corpus can prove at the moment of the decision — never by how or when the entry got into the store.** The store never deletes, admission never checks membership, and bootstrap has to run before membership is answerable, so any rule keyed to an entry's *provenance* eventually trusts an entry that arrived wrong. Entries stop mattering rather than existing — the same shape as retirement (§29). Concretely the corpus has three footings, derived from records and never from arrival time (the **signed genesis projection** — the space projection carrying `deviceKeyIds`, i.e. the one that arrived in an envelope — is the boundary between bootstrap and membership): | Footing | Inbound | Outbound | Claims | |---|---|---|---| | **Cold** — no private projections | raw directory | raw directory, full catch-up | spent | | **Partial** — projections, no signed genesis | serves nobody | raw directory, but empty summaries, empty wants, **no blob requests** | spent | | **Foldable** — signed genesis folds | ever-member directory | ever-member directory, plus an inventory-free route whose bootstrap cursor has not finished | discarded | A cold replica leaks nothing by dialling or serving whoever its directory names; a partial one holds private bytes it must not describe but still needs a route to genesis; a foldable one can ask the materializer who was ever a member, and does, per frame. The narrow outbound exception in the last row prevents genesis itself becoming a page boundary that severs the only route to a later membership grant; it confers no inbound authorization and carries no private inventory in either direction. The mechanism, in the order a frame meets it: - **The claim is on the frame, and it is worth nothing on arrival.** `catch-up` and `hello` carry an optional `from` — the dialer's DIDs, bounded at 8 — which `WirePrivateBus.accept` buffers **only when it is refusing** the frame, before returning the identical refusal a stranger has always got. The answer must not change: a claim that bought a different reply would be a way to ask an endpoint which DIDs it already trusts. The buffer is drained by the layer that can settle it (`PrivateSpaceIngestor`), which polls each claimed DID's **public** repo — bounded per cycle, per claim, and by a per-endpoint cooldown — and keeps the DID only if that repo binds the endpoint the transport authenticated, and only on the cold or partial footing (§30.1). A kept DID is provisional (`#claimedDids`): the first fold evicts any the fold does not count. - **An unnamed inbound connection is held for a bounded grace, and served nothing.** Both iroh bindings call `onLink` the moment they accept, so closing an unrecognized connection there destroys the round trip the claim travels in — the frame is gone before it was read. `PrivateEndpoint` keeps such a connection (8 at once, 30 s each), refuses every frame over it exactly as before, and adopts it if the directory catches up in the meantime — and only into a space with nothing to displace, because a held link is older than anything adopted since it began waiting. Fresh connections still replace; held ones only fill a gap. A held link is not a lease. - **Hints reach the poller, and tickets carry them.** `meta.peerHints` are bootstrap DIDs in every runtime (a browser once passed them as `prefer`, which orders a roster and cannot extend one), and a ticket may carry `peerHints` minted from the fold's active addressed members, so an invitee's first replica can dial a daemon directly instead of waiting to be dialled. The cost is stated rather than hidden: those DIDs land in the invitee's **public** `join` bookmark. Anyone who considers that too much can leave hints out and still converge by the claim path, one poll slower. Everything here is a *connection* statement in §2's vocabulary — `admission.ts` and `devices.ts` are untouched, `materialize()` counts exactly what it counted, the digest is unchanged — so, as with §29, there is no lexicon change, no permutation coverage and **no protocol version bump**: `from` is an optional field an older `readFrame` ignores, and a peer that ignores it makes a worse connection decision over an identical corpus. **What this leaves open, deliberately.** A claim is a hint and never a handshake — there is no signature on the wire proving the dialer holds that DID, because the repo read that follows settles it better and costs the claimer nothing to be honest about. A foldable replica therefore cannot learn a member whose grant it has not yet folded: if the only reachable peer is that member, it waits for a peer it does know rather than trusting the claim — the same "wait rather than guess" the fold makes everywhere else. Fixing that means a signed handshake, which nothing yet needs. First-open latency is bounded by the daemon's directory-poll interval, a tuning question and not a correctness one. And the polled PDS operator learns that *some* replica was interested in that DID, which is the one residual disclosure the claim path buys. ### 30.1 The claim is spent only before genesis folds A claim buys a bounded number of public, unauthenticated repo reads — the same reads `pollDirectory` makes of a member — and it buys them **only while this replica holds no signed genesis projection**. Both edges of that boundary are load-bearing, and each was found by asking what the rule would be worth to somebody it was not written for: **A foldable replica must spend nothing**, because the poll is a write. Reading a claimed DID's repo puts that repo's `device` and `deviceAddress` into the same `RecordStore` every connection decision is folded from. The attack that follows needs no secrets: a space's topic is derived from its public `at://` URI (`wire.ts`); anybody may publish a `device` and `deviceAddress` to their own PDS, since binding a key is independent of membership (§3); so a stranger dials, is refused, claims *their own* DID — which verifies, being true and irrelevant — and `authorizeEndpoint`, deliberately not a membership check, says `ok` to the next dial. A private corpus must not be enumerable by whoever computed the topic, and for the price of two public records it had become exactly that. "Not a membership check" is safe only while every entry the directory holds got there by being polled as a member (the non-member it tolerates is a *removed* member, who already holds the corpus); polling on a stranger's say-so is what broke that premise. Note that "dialable but not servable" is not a repair — a dialer sends its `summaries`, so a stranger on the roster learns the corpus's shape without ever being served. **A partial replica must still spend**, because the boundary is genesis and not the first envelope. Admission deliberately does not check membership, so a gate-1-valid fragment from whoever caught the cold window is exactly the kind of thing a partial replica may hold — and if holding *any* envelope ended the bootstrap, one junk envelope would end discovery forever: every later claim drained and discarded, the one legitimate holder never found, the replica stuck the moment its known peers go offline. The spend stays leak-free on the partial footing because that footing serves nobody and describes nothing (§30.3). The deadlock this section opened with is a cold tab by construction, and it still resolves: the cycle that spends the claim is the same one that throws `Space record not found`, which is §24's `waiting` and not a failure. After genesis folds a claim could only re-poll a member (`knownDids` already holds every active one) or introduce a never-member, which is the case that must not exist — so claims are drained and discarded, and what a stranger's earlier entry is worth is §30.2's question. ### 30.2 What an entry is worth is settled at read time The claim-spend condition alone is not enough, because the poll's side effect is a **directory entry**, and the store never deletes anything. A stranger who caught the bootstrap window — polled in while the replica was cold, in the very cycle that warmed it — would otherwise stay warm forever: in `knownDids`, re-polled every cycle, dialled by `connectablePeers`, authorized by `authorizeEndpoint`, served the corpus for the rest of the replica's life. A condition on the *spend* narrows the violation to a race; it cannot remove it, because the entry outlives the state that admitted it. **The repair is `connectionDirectory`** (`core/src/private/peers.ts`): the directory every connection decision reads — `PrivateEndpoint.attach` folds it per frame, exactly where `readDeviceDirectory` was folded before — with the three footings above as its cases. Once the signed genesis projection folds, only devices of DIDs that have EVER been members remain. "Ever" and not "active", because the non-member `authorizeEndpoint` was always written for is a *removed* member: they already hold the corpus, so refusing them would disclose the removal without protecting anything. A never-member with a directory entry — however the entry got there — is out of the roster and out of the serve path. The membership rule is the materializer's own: `everMemberDids` shares `foldMembership` with `materialize()` rather than paraphrasing it, folded over only the collections membership can read so a per-frame caller pays a scan and not a validation of the whole store. Because the filter is evaluated per frame against the live store, there is no window between "genesis folds" and "refuses the stranger": an envelope and its projection land in one transaction (§17), so the frame after genesis folds is refused already. The ingestor's part is hygiene rather than safety — the first fold evicts a claim-sourced DID the fold does not count, so the stranger's repo also stops being re-polled. Their records stay in the store — records are never deleted — and stop *mattering* instead. The symmetry is the point: a foldable replica cannot learn a member whose grant it has not folded (§30.1's giveup), and it will not dial or serve a device whose owner's grant it has not folded either. The invitee's path is untouched — their grant is an envelope in the corpus every serving peer already holds, and the bootstrap dance (refusal, claim, poll, dial-back) runs on footings where nothing is filtered. ### 30.3 The partial footing: hold the route, disclose nothing "No space record folds" and "the replica holds no corpus" are different states, and conflating them was a hole. The transaction guarantee (§17) makes one envelope and **its own** projection atomic — it says nothing about order across envelopes. Gossip may deliver a work envelope before genesis, and bounded catch-up may stop on a page whose ordering has not yet reached genesis. Such a replica holds private bytes while membership is still unanswerable, and passing its directory through unfiltered would let a stranger polled during the truly cold window fetch that partial corpus. So the partial footing keeps the route and closes every mouth: inbound authorization serves nobody; outbound catch-up still dials through the raw directory — that is how genesis arrives — but sends empty summaries and empty wants; and `syncBlobs` asks for **nothing**, because a `blob-request` names the CID of bytes a private record carries, which is an identifier of private content handed to whoever is on the other end of an unfiltered dial. (The blob gate was the last hole found: summaries and wants were withheld while blob requests still went out, same footing, same disclosure.) The store still derives what is missing — `missingBlobs` reads records, not the wire — so member routes can fetch it after genesis, while a route retained only by bootstrap progress is skipped until its scan ends. Empty summaries cannot resume a bounded catch-up by themselves: if every cycle restarted without a cursor, a genesis envelope beyond the per-cycle page bound would never be reached. While inventory is withheld, `WirePrivateBus` therefore retains the responder's opaque cursor per peer across cycles. The cursor describes only progress through that peer's corpus, is bounded like every other peer-allocated table, and survives genesis when that envelope lands before the bounded scan reaches its end. `catchUpDirectory` keeps only endpoints with such cursors beside the ever-member roster, and the bus treats each as outbound-bootstrap-only: its catch-up stays inventory-free, gossip and blob requests skip it, and inbound authorization remains the ever-member directory. When its cursor clears, the next connection round removes it unless the envelopes just fetched establish its membership. Other member peers receive fresh summaries normally throughout. The completed peer gets fresh summaries on its next round, restarting its scan so a record that landed behind the old cursor is not skipped. The footing is derived from records, never arrival time: a projection carrying `deviceKeyIds` proves a private envelope is held, and the *space* projection carrying `deviceKeyIds` marks the transition to foldable. This preserves the bootstrap route without turning a partially filled replica into a server for whoever caught the claim window. ### 30.4 How this section got here Five findings, in order: the deadlock itself (shipped against `c771e2a` with `c382c96`'s held connections); a claim spent by a warm replica made the corpus enumerable (§30.1, found the next day); the entry outliving the cold state that admitted it (§30.2, against `30bf7bf`); genesis arriving non-atomically with the corpus (§30.3, against `a000ea8`); and, from a final review, the blob-request disclosure and a claim boundary of "any envelope" that let one stranger's fragment block discovery permanently — both folded into §30.1 and §30.3 above. Every hole had the same shape: a rule keyed to how state *arrived* (a claim was trusted for being verified, an entry for being polled while cold, a corpus for usually arriving genesis-first) where the invariant had to be keyed to what the records *prove at read time*. That is the test for whatever changes this next: if a proposed rule mentions when or how something got into the store, it is the next hole. The reviews also kept finding the same two fixture bugs, worth naming because each let a real hole survive a green suite. **One-directional doubles:** a loopback where the accepter could close its half while the dialer went on being served reports every accept-side lifecycle decision as working — both doubles now share one open flag across a connection's two ends. **Fixtures that arrange the broken state:** the §30 regressions offered the corpus before syncing and then asserted a stranger *was* authorized; UI fixtures served peers whose corpus was one space record to a tab no grant ever named. The fixtures are now cold by default, `corpus: true` is the warm replica that must refuse, and the grants are the ones a real peer would hold. When a test has to arrange a state to make an assertion pass, check whether the state is one the system is meant to be in. ## §31 Explicit ticket handoff supersedes bookmark hydration and reverse discovery Private-space `join` bookmarks remain public listing metadata only. They never create or hydrate a missing browser or machine replica. A fresh replica requires an out-of-band current transfer ticket whose `peerHints` names at least one active member with a live, non-retired addressed device. Tickets remain bootstrap introductions, never membership credentials. This supersedes §27 bookmark recovery and §30's claimed-DID reverse discovery and unknown-link grace. Wire `from` claims, candidate polling, and held unidentified links are removed; an inbound link that no attached space authorizes closes immediately. §30.2–30.3's lasting safety rules remain: the folded ever-member connection directory, inventory-free partial catch-up, cursor retention, and blob/gossip suppression on a retained bootstrap route. ## 32. Decision: a native object handed to a native async call is pinned for the call `radiald run` died intermittently at startup — `SIGSEGV`, `SIGBUS`, or a `SIGABRT` out of the allocator — after the phase log that says the native endpoint is binding and before the one that says it bound, and came up cleanly on a rerun. That is one use-after-free wearing three signals. `@number0/iroh@1.1.0` declares `async fn bind(_, relay_mode: Option<&RelayMode>)`. The future borrows the Rust box the JavaScript `RelayMode` owns, and it runs on after the call has returned to JavaScript; napi keeps nothing reachable on this side meanwhile. Written into the argument list directly — which is how §14's adapter wrote it — the `RelayMode` is garbage the moment the call is made, so a collection landing in the window frees the box under the running bind. A `FinalizationRegistry` sees exactly that: the object finalized while `bind` is still pending. The window is a few microseconds wide, which is the whole reason it reads as flaky rather than broken: the fixture in `transport-iroh/test/bind-once.mjs` died in 9 fresh starts out of 12 before the fix, and 0 out of 12 after. Three arms over one script separate the reference from the timing — the temporary died 11 times in 12, holding it for the call died 0, and a control that perturbed the same statement without holding anything died 9. **The rule is the argument's lifetime, not the signature's.** `pinned()` holds a native object in a module-level set for the length of the native call, and everything `transport-iroh` hands to a native async call goes through it — `bind`'s relay mode, and `dial`'s endpoint address, which a finalizer probe never caught being collected but which is the same borrow and runs every sync round. Do not re-derive which of the binding's signatures are safe to pass a temporary to; pin it. There is nothing upstream to move to. 1.1.0 is the current release and the borrow is in the binding's shape, so §8's exact pin stays where it is; a report belongs to `n0-computer/iroh-ffi` with the fixture above, and this repository does not need it to be accepted before it is safe. Nothing here touches the wire, the fold, or the endpoint's identity: an endpoint that survived its bind was always correct, and the ones that did not never reached a peer.