A collaborative coding-agent orchestrator for atproto radl.app

add private mode with iroh #104

Closed disnetdev.com
targeting main from radial/impl-1ed41c71b806
Closed opened by and assigned to disnetdev.com
targeting main from radial/impl-1ed41c71b806

Private mode — the two-gate substrate #

Implements the substrate half of the v3 plan (plan-25lmgjz4rzjuz6wvkgpe3zlk): a space's records can travel as signed envelopes instead of living on public PDSes, and the unchanged materialize() folds them. The iroh transport, daemon wiring and browser flows are not built — see What is not built below, which is stated as plainly as the plan asked.

Provenance #

Branch radial/impl-1ed41c71b806
Commit 1479166d34928026e1ff9451d482934fdeaf54d0
Base main @ 6a9fe39
Pull request none opened by me — tangled has no CLI for it; Radial opens it from the pushed branch
Checks pnpm codegen (generated file current), pnpm lint, pnpm test, pnpm build — all pass

Test counts after the change: core 250, atproto 32, ingest 26, sidecar 60, daemon 548, ui 48 + 554 (vitest). 0 failures anywhere.

The decision this change mostly exists to make #

The two-gate rule. Envelope admission (core/src/private/admission.ts) checks cryptography and quotas only: protocol version, envelope shape, space binding, canonical encodability and size, recordCid correctness, validateRecord(), and a signature under a key the envelope's own DID has published — revoked or not. That last check is the private-path analogue of "the record was in that repo": it proves DID authorship and nothing more.

Every trust decision stays in the pure fold (core/src/devices.ts): a record counts when its author holds an active grant (existing rule, untouched) and at least one of its signing devices is bound.

Checking revocation at admission — the shape the first plan took — would make a store's contents depend on when a revocation arrived, and with no PDS to re-fetch from, no later sync could repair the fork. Consequences, stated rather than discovered:

  • Revocation is retroactive in the index, exactly as removeMember is.
  • Rotation is fold-neutral: a new device record retires the old key for connections while its history keeps counting.
  • Recovery is re-enveloping: a stored version carries the set of devices that signed it, so a surviving key re-signing the same value lands on the same version — same recordCid, same URI, every strongref intact.

What landed #

Lexicons (additive). space.private; four optional join fields (founder, protocol, label, peerHints) rather than a duplicate collection; new device (immutable key material), deviceAddress (mutable — the third sanctioned in-place rewrite), revokeDevice (owner-authored) and removeDevice (admin, space-scoped). Validation covers key algorithm and length, key-id charset, endpoint-id shape and relay scheme allowlist. Valid/invalid fixtures for all four.

The fold. readDeviceDirectory reads directory records from the public path only — a key published inside an envelope would vouch for itself, and a fresh browser has nothing else to read. Binding is membership-free (or no first grant could ever be verified); the view filters by membership. removeDevice authority is evaluated against a membership fold computed without the removals — a deliberate stratification, which is why there is no fixpoint to iterate — and the root DID is exempt. MaterializedIndex gains private and devices, and indexDigest gains a devices section so a revocation one replica has not seen reads as that rather than as scattered missing records.

Guest comments, refused twice. The fold ignores setGuestComments in a private space and pins guestCommentsEnabled to false; the UI hard-gates the Constellation query on index.private, with a test that hands the component discover: true and asserts no request leaves.

The envelope stack (core/src/private/), isomorphic and dependency-free: DAG-CBOR, atproto record CIDs, TIDs, Ed25519 sign/verify over WebCrypto, EnvelopeStore, the admission pipeline, and quarantine with a durable want-list. Bounded quarantine never loses an envelope silently — every eviction writes (did, collection, rkey, recordCid) and catch-up re-requests wants explicitly rather than inferring completeness from summaries.

Transport seam and ingestion. PrivateBus states what a transport must do in Radial's concepts; MemoryPrivateBus implements it deterministically with controllable ordering, partitions and duplication. PrivateSpaceIngestor (packages/ingest) runs bus frames through one admission pipeline into the envelope store and folds with the unchanged materialize(), polling members' public repos for the directory. Node persistence: SqliteEnvelopeStore (envelope and projection in one transaction), SqliteWantList, and signer sets in SqliteRecordStore.

Evidence #

  • CIDs are atproto's, not invented. recordCid is pinned against eight records real PDSes computed CIDs for — two Radial artifacts, a review, a claim, a profile with blobs, three posts with facets (packages/core/test/fixtures/cid-vectors.json). Map-key ordering is pinned on a pair that disagrees with a plain string sort, because getting length-first ordering wrong is the classic way to mint CIDs nothing else agrees with.
  • Permutation coverage, as the invariant requires: privateScenario() in the shared fixture, shuffled 200 ways, plus revocation-first / revocation-last / revocation-mid orderings that produce one index and one digest.
  • Convergence across replicas: two ingestors on MemoryPrivateBus reach identical indexDigest() under permuted and duplicated delivery, across a partition, and after a revocation that lands early on one and late on the other — with the envelope sets identical on both, which is the gate-1 property the whole design rests on.
  • Recovery: a replica whose quarantine holds one envelope at a time evicts the rest, leaves wants, and catches every one of them back from a peer once the key is published.
  • The golden (public) scenario is asserted to be untouched: no devices, no gate, no new diagnostics.

What is not built #

Deliberately, and it is a large fraction of the plan:

  • The iroh transport (plan Phases 0.3, 0.4, 3.1, 3.2). The browser feasibility spike is a build-and-connect exercise across Chromium/Firefox/WebKit against a pinned version, and the plan makes its failure a release blocker requiring a product decision. That cannot be faked from here. MemoryPrivateBus is what every test runs on, and the PrivateBus interface is the seam it will implement.
  • Daemon integration and operator commands (Phase 5): radial.json modes, key storage, space create --private, ticket mint/accept, member add, device rotate/revoke/status. Half-wiring SpaceSyncRuntime without key storage and ticket handling would be worse than not wiring it, so runtime.ts is untouched.
  • Browser device management, IndexedDB persistence and fresh-browser recovery (Phase 6, except the Constellation gate, which landed).
  • Large-body blob transfer (Phase 4.4) and the real-relay smoke tests (Phase 7.2).

Risks worth a reviewer's attention #

  1. Retroactive revocation is the semantics, and it destroys folded history by design. It matches member removal and it is the correct compromise response, but the operator-facing docs that would teach it (Phase 7.4) are not written — only design.md §18 and the ADR say it today.
  2. Ed25519 in WebCrypto. Available in Node 24 and in current browsers, but it is a recent addition; the browser spike should confirm it on every target before the transport ships.
  3. The removeDevice stratification (admin status judged against a fold without the removals) is deterministic and terminates, but it means an admin whose own device is removed in the same cycle still authorises their removal in that cycle. Documented; worth a second opinion.
  4. join's peerHints discloses likely participants — the metadata the design otherwise minimises. Opt-in and founder-only by default, and the disclosure UI is Phase 6 work that is not built, so nothing should enable private mode for a user before it is.
  5. No central durable copy. A replica's SQLite is the backup. Backup/restore tooling and the recovery drill the plan gates wide release on are not built.

Radial artifact

Commits 0