Closed the transport's remaining half: radiald run now serves a private space over a real
connection, and this daemon publishes where it is so peers can find it (ADR §12 → new §20).
What landed #
PrivateEndpoint(daemon/src/private-transport.ts) — one transport endpoint per daemon, not per space. AdeviceAddressis a fact about a device and names one endpoint id, so an endpoint per space would give one record several answers; §14's per-space wire topic is what makes one endpoint enough, and each inbound frame is routed to the attached space its topic names. A topic nobody attached gets the sameunknown-topicanswer a space this machine never held would, so a topic (public knowledge) buys no information. Endpoint identity persists in<dataDir>/private/endpoint.key(0600, atomic rename). The native binding is imported dynamically, so a public-only daemon never loads a native module.publishDeviceAddress(core/src/private/directory-writes.ts) — create, or rewrite in place under the device key's own rkey (the third sanctioned in-place rewrite), and only when the endpoint or its relays actually changed, so an ordinary restart writes nothing to any PDS. A writer that cannot read back the value rewrites rather than guessing.openPrivateSpaces(daemon/src/private-run.ts) — the startup sequence, in the one order that works: publish each identity's device key → bind the endpoint → publish the address → open each replica → poll the directory → check the ticket. Each step is the next one's precondition, and the file says why at each step.- Adoption — an accepted connection is authorized against the same directory
accept()uses, once per attached space, before it is kept; one no space will serve is closed. This is what lets a replica that can only dial out receive gossip. runDaemontakes a per-space actor registry, so one daemon serves public and private spaces at once with no private-mode branch anywhere below the loop. Jetstream stays on the public list only.- The replica path and
space.jsonmoved to@radial/core/node—radialdopens the same<dataDir>/private/<space>/replica theradialCLI does, soradial private statusdescribes what the daemon serves andradial private exportbacks it up. One machine, one replica, structurally rather than by convention. The CLI deliberately still binds no endpoint (a second one would be a second replica, and would drag the native dependency into a package that stays dependency-free); its private-mode messages now say which of the two things just happened. - The
radiald runrefusal is gone, and the docs it named are updated: ADR §20 (new), design §18 ("The endpoint"),operators.md§8/§9,radial-json.md, CLAUDE.md, and the daemon/sidecar/ transport READMEs.
Coverage #
packages/daemon/test/private-transport.test.mjs(6) — two daemons, shared public repos, and a loopback transport carrying real encoded frames through the production startup path: the two public records a private space writes and no others, an invitee refused until the founder has read its device record, catch-up over the wire, gossip back, every work record staying off both PDSes, endpoint identity surviving a restart with the address left unrewritten,space.jsonwritten for the CLI,unknown-topicfor an unattached space, inbound adoption, and an unauthorized inbound link closed.packages/core/test/private-directory-writes.test.mjs(6) — the publication rules, including "writes nothing when unchanged" asserted against a call log.packages/transport-iroh/test/transport.test.mjs— one added native check that an iroh endpoint id is one adeviceAddressrecord can actually carry (64-char lowercase hex), which was previously an assumption crossing a package boundary.
Provenance #
- Branch:
radial/impl-d78d52bbc3f5 - Commit:
431315b002221e6764127a2122e403f7b65ba241 - Pull request: the existing tangled chain, updated from the pushed branch (no forge-side PR CLI here)
Checks #
pnpm codegen (no diff), pnpm build, pnpm typecheck, pnpm lint, pnpm test — all pass. Tests:
core 369, transport-iroh 5, atproto 34, ingest 42, sidecar 100, daemon 565, ui 64 + 617 vitest.
What is deliberately not here, and the risks #
- The browser endpoint remains undecided (ADR §12). Verified against the registry rather than
assumed: no iroh browser/WASM package is published (
@number0/irohis a napi binding with per-platform native artifacts only). §9's rejection of a loopback bridge through the daemon stands, so a tab still says it cannot fill a replica rather than being routed through an operator. - No CI test dials a real network. The native binding's bytes are covered by its own loopback tests; the daemon's lifecycle is covered over a loopback transport. Relay-based discovery between two machines is untested here by construction — first real deployment is where a relay misconfiguration would show up.
- The CLI and the daemon are two SQLite writers on one replica. Documented (do not run a CLI
command against a space the daemon is syncing) rather than enforced with a lock; a concurrent write
costs a
SQLITE_BUSY, not a corrupt store. - The dial roster is folded from the store on every round, per §16's no-cache rule — a scan of
the replica per publish and per catch-up. Fine at private-space scale; the honest fix, if it ever
bites, is a version counter on
RecordStore, not a cached roster. - A fresh invitee can back off up to 5 minutes while the peer it dials has not yet polled its
device record: its connection is refused and closed, and
PeerConnections' deterministic backoff grows. Self-healing and bounded, but it is why an always-on node in the space matters.