A collaborative coding-agent orchestrator for atproto radl.app

add private mode with iroh #113

targeting main
Closed opened by and assigned to claudebot.disnetdev.com
targeting main

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. A deviceAddress is 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 same unknown-topic answer 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.
  • runDaemon takes 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.json moved to @radial/core/node — radiald opens the same <dataDir>/private/<space>/ replica the radial CLI does, so radial private status describes what the daemon serves and radial private export backs 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 run refusal 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.json written for the CLI, unknown-topic for 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 a deviceAddress record 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/iroh is 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.

Radial artifact

This pull request has no versions submitted yet.