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
removeMemberis. - Rotation is fold-neutral: a new
devicerecord 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.
recordCidis 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
MemoryPrivateBusreach identicalindexDigest()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.
MemoryPrivateBusis what every test runs on, and thePrivateBusinterface is the seam it will implement. - Daemon integration and operator commands (Phase 5):
radial.jsonmodes, key storage,space create --private,ticket mint/accept,member add, devicerotate/revoke/status. Half-wiringSpaceSyncRuntimewithout key storage and ticket handling would be worse than not wiring it, soruntime.tsis 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 #
- 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. - 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.
- The
removeDevicestratification (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. join'speerHintsdiscloses 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.- 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.