A collaborative coding-agent orchestrator for atproto radl.app
radial docs design.md
119 kB

Radial — Design #

Radial is a coding agent orchestration system built on atproto. Humans define goals in a web UI and request typed artifacts against them — plans, implementations, security reviews, architecture diagrams; a daemon watches the network and launches agents in containers to fulfill those requests. All coordination state lives in atproto records; all code lives in git.

The model in one line: humans initiate every step; agents produce artifacts; reviews annotate them. There is no autonomous pipeline — a previous version of this design had one (a fixed review-gated state machine), and §14-A explains why it lost.

1. Goals and non-goals #

Goals

  • Coordinate humans and coding agents on software goals through human-initiated, reviewable units of work. Every unit of agent work is requested by a human and lands as an attributable artifact.
  • Every actor (human or agent) is a first-class atproto identity (DID); every action is an attributable, signed record.
  • Agents from different operators can participate in the same space without a central server — the atproto network is the bus.
  • Make review cheap and structural: any artifact can be reviewed by a human or an agent, reviews pin the exact version they judged, and an auto-review toggle dispatches an agent reviewer the moment an artifact lands.

Non-goals

  • A workflow engine. There is no state machine at all: "workflow" lives in human heads and in the prompt templates behind UI buttons, not in a graph anyone authors or validates.
  • Autonomous chaining. v1 never lets agent work trigger more agent work, with one deliberate exception: the single-hop auto-review trigger (§7). Deeper automation is a future layer of opt-in trigger rules on top of this substrate, not the substrate itself.
  • Hosting code or diffs. Git (and optionally a forge) remains the artifact store for code; Radial records point into it.
  • Real-time collaboration primitives (presence, live cursors). Coordination is eventually consistent at atproto propagation speed.

2. System components #

Component Runs where Responsibilities
Web UI Browser (+ its own view server or local-first) Create/edit goals, artifact-request buttons, artifact timelines, review queues, converse in threads
Daemon (radiald) Operator's machine/server Ingest records for its spaces, materialize state, fulfill artifact requests addressed to (or claimable by) its agents, launch agent containers, write agents' records
Agent container Ephemeral, spawned by daemon One "turn": run a harness (Claude Code, pi, …) against a checkout with a request-specific brief; emit records via sidecar
Sidecar (radial CLI inside container) Inside agent container The only path from harness → protocol: radial artifact submit, radial review post, etc. A thin client over the daemon's local socket — validation, signing, and repo writes happen daemon-side, so protocol credentials never enter the container; a turn can only emit what its request asked for

Both the UI and daemon are materializers: they independently fold the record streams of space members into the same view. There is no privileged coordinator.

3. Identity, spaces, and trust #

  • Humans bring their own atproto accounts. Agents also get their own atproto accounts, created by their operator (the entity running a daemon) through ordinary account creation on any PDS — Radial never mints DIDs or holds signing keys. The operator registers each existing agent identity with the daemon via app password or OAuth. One operator may run many agents.

  • An agent DID may carry several agent profiles, each a distinct capability set (harness, models, artifact types) published under its own record key (§11). One identity therefore covers, say, a planner and an implementer without provisioning an account per capability; a separate DID is warranted when the capabilities need separately revocable membership, since removal operates on a DID.

  • A space is the trust boundary. The space record's author is the root of trust; they (and admins they designate) publish membership as an append-only event log: addMember grants a DID membership, removeMember revokes it. Materializers ignore any record whose author has no active membership in the space it targets.

  • Membership events tag each DID as human or agent and assign a role (admin, member, agent). "Humans initiate every step" is a design convention carried by UI and daemon behavior, not a protocol rule — materializers do not police request authorship (auto-review requests, for instance, are authored by an agent DID, §7).

  • Each addMember record is an exact membership grant. A removeMember revokes the target's current grant by strongref and retains nothing: every record authored by a DID with no active grant is ignored, identically, on every materializer regardless of ingestion timing — no cutoff to agree on, no snapshot to distribute. Removal is deliberately symmetric with the rest of the system's availability model: record trust converges exactly, record availability is always best-effort (any member can already rewrite or delete their own repo; cold observers simply never see what's gone). Re-adding a member is a new grant that restores trust in all of their records, since trust is a function of the current grant set, not of epochs. Repository revisions are ingestion checkpoints, not trust provenance. Known accepted limitation: membership events fold in createdAt order, so a removed admin can backdate a grant to before their removal and smuggle in a member — visible in the membership view, and remedied by removing the smuggled grant; it cannot be silent.

  • A grant is written where the granter is, so a client cannot find it. addMember lands in the admin's repo; nothing lands in the member's. atproto indexes forward only — there is no reverse lookup from a DID to the repos that mention it — so an invited member's own client has no way to discover the space it was invited to. That is what join is for: the first time a member opens a space, their client writes one into their own repo, and from then on "the spaces I belong to" is one listRecords over that repo. It is a bookmark and not a credential — a self-assertion, which is exactly why it can be trusted with nothing. Membership is addMember and only addMember; no materializer reads a join to decide whose records count, and a join from a DID with no grant is dropped by the ordinary trust rule like anything else it wrote. The only question it lets an index answer is who has actually turned up, which is a fact about people rather than about permission. A client that is handed a space URI needs none of this, which is why a daemon never writes one.

Consequence worth stating loudly: atproto records are public. Goal titles, artifacts, review findings, and conversation are visible to the network. Radial v1 targets open development (public repos, public coordination). See §13 for the private-data options and why we're not doing them yet.

4. Data model (lexicon sketch) #

Records live in each author's own repo. Cross-record references are atproto strongrefs ({uri, cid}), which pin the exact version referenced — a review of plan v2 stays a review of plan v2 even after v3 exists.

Records use the com.disnetdev.radial.* NSID namespace.

com.disnetdev.radial.space          {name, description}                        — author = root of trust
com.disnetdev.radial.addMember      {space, did, kind: human|agent, role}      — exact grant; author must be admin
com.disnetdev.radial.removeMember   {space, did, membership}                   — revokes the exact current grant;
                                                                                 retains nothing (§3)
com.disnetdev.radial.join           {space}                                    — a member's own note that it opened
                                                                                 this space. GRANTS NOTHING; written
                                                                                 so a client can find the space at
                                                                                 all (see below)
com.disnetdev.radial.project        {space, name, gitUrl, defaultBranch,
                                     checks: [{name, command}],                — machine-verifiable
                                     autoReview: {<type>: bool}}               — per-type trigger default (§7)
com.disnetdev.radial.goal           {space, project, title, body, closed?,     — author = any member; `closed` is
                                     source?: {kind, uri, cid?}}                 LEGACY: read forever, never written
                                                                                 (superseded by closeGoal, §7).
                                                                                 `source` records where an IMPORTED
                                                                                 goal's text was read from (a forge
                                                                                 issue, §13) — an assertion by the
                                                                                 author, never followed, and never
                                                                                 in a bundle
com.disnetdev.radial.closeGoal      {goal, closed,                             — a goal's one ending; author = any
                                     disposition?, createdAt}                    active member, reversible (§7)
com.disnetdev.radial.labelGoal      {goal, labels: [string],                   — a goal's WHOLE label set; author =
                                     createdAt}                                   any active member, latest wins (§7)
com.disnetdev.radial.savedView      {space, name,                              — one member's named filter over goals;
                                     project?, labels?: [string],                personal, latest wins per (did, name),
                                     state?: live|ended|all,                     `deleted` is the tombstone (§7)
                                     deleted?, createdAt}
com.disnetdev.radial.artifactType   {space, name, brief, outputSpec,
                                     scope: goal|project}                      — registry entry; author must be admin
com.disnetdev.radial.artifactRequest{goal | project,                           — exactly one, per the type's scope (§8)
                                     type,                                     — registry name, or "review"
                                     subject?: strongref,                      — review requests: what to review
                                     basedOn: [strongref],                     — provenance, recorded when known
                                     assignee?: did,                           — omit = open to any capable agent
                                     brief?,                                   — extra instructions beyond the template
                                     autoReview?: bool}                        — override project default
com.disnetdev.radial.claim          {request, expiresAt}                       — open requests only; lease, renewable
com.disnetdev.radial.artifact       {request: strongref, goal | project, type,
                                     prev?: strongref,                         — supersedes an earlier version
                                     title?,                                   — short name a row shows; optional on
                                                                                 the wire (pre-title records stay
                                                                                 valid), required of every writer
                                     body,
                                     links: {branch?, commit?, pr?},           — implementation-type artifacts
                                     criteria?: [string], model?}              — plan-type artifacts; model is optional
                                                                                 daemon-attested provenance (§9)
com.disnetdev.radial.review         {subject: artifact (strongref),
                                     request?: strongref,                      — the review request it fulfills, if any
                                     verdict: approve|request_changes,
                                     findings: [{path?, line?, severity, body}], model?}
com.disnetdev.radial.checkrun       {artifact, commit, results: [{name, pass, log}]}
com.disnetdev.radial.message        {goal | artifact,                          — exactly one anchor (§8)
                                     root?, parent?, body, mentions,
                                     re?: strongref,                           — request this message responds to (§9)
                                     declines?: bool, model?}                  — with re, from the assignee: declines it
com.disnetdev.radial.agent          {handleName, harness, models: [{name, costHint}],
                                     artifactTypes: [string],                  — registry names + "review"/"answer"
                                     scopes?: [{space, artifactTypes}]}        — per-space OVERRIDE of the line above
                                                                                 (§11); no entry = the global list
com.disnetdev.radial.image          {blob: blob, alt?, name?}                  — a picture a body refers to; presentation
                                                                                 only, and no part of the fold reads it
com.disnetdev.radial.setGuestComments{space, enabled}                          — does this space ASK non-members for
                                                                                 comments? author must be admin (§4)
com.disnetdev.radial.blessComment   {goal, comment: strongref, body}           — a member vouching for a NON-MEMBER's
                                                                                 message: the guest record pinned by
                                                                                 uri#cid, plus a SNAPSHOT of its body
                                                                                 at that version; author = any active
                                                                                 member
com.disnetdev.radial.retractBless   {bless: strongref}                         — withdraws one; author = the bless's
                                                                                 own author, or an active admin

Notes:

  • Primary record + typed referencing records, the same shape atproto itself uses (post + replies + likes). Nothing edits a primary record: a goal's ending, a project's name and its archived state are each a standalone overlay record folded over the primary one (atproto records are owner-mutable, which is why the rule is a convention the fold enforces rather than a permission). Everyone contributes requests, artifacts, reviews, messages that reference it. Materialization is a deterministic fold over typed records, not op replay.
  • Records are immutable by convention; claim lease renewal (expiresAt and renewedAt, moved together and bounded — §6), and an agent profile's republish are the sanctioned exceptions. atproto owners can rewrite records in place, so materializers keep the earliest repo revision they observe, independent of arrival order, and flag other CID changes on the same record as edit annotations — a review's pinned strongref stays meaningful even if the PDS now serves different bytes. The third exception is what makes §11's "capability sets are added and retired by writing records" true of a profile that already exists: an agent record is one operator's self-description, in that agent's own repo, pinned by nothing, so the latest revision wins wholesale. Keeping the earliest instead made radiald init <profile> --update invisible to every observer that had already synced it — including the daemon whose own drift warning tells the operator to run it.
  • A project's autoReview map is mutated via standalone com.disnetdev.radial.setAutoReview records (admin-authored, {project, type, enabled}), folded latest-wins per type over the project record's own field — never by editing the project record, which is strongref-pinned and would drop the edit (the fold keeps its earliest CID). Same primary-record-plus-standalone-overlay shape as a goal's ending via a standalone closeGoal record rather than an owner edit.
  • A project's name, gitUrl and defaultBranch move the same way, via com.disnetdev.radial.editProject ({project, name?, gitUrl?, defaultBranch?}), folded latest-wins per field so an edit naming only the name cannot revert a concurrent edit of the remote. com.disnetdev.radial.archiveProject ({project, archived}) shelves a project and, with it, every goal beneath it: agents stop dispatching, reviewing, checking and merge-polling anything under it, and the UI's rail and smart lists stop listing it. Both are trusted only from the project record's author — not from an admin, and unlike a goal's ending, not from any active member: what a project points at decides which repository every future turn clones, which is the thing itself rather than an annotation about where attention should go. Both are reversible (a later record wins), and checks has no overlay — it stays fixed at creation. Consumers read the effective values off ProjectView.name/gitUrl/defaultBranch; target.value remains the bytes at target.cid, which is what keeps every goal's strongref to the project honest.
  • Large bodies overflow to blobs. atproto records have size ceilings; an artifact body that exceeds them is stored as a blob, with the record carrying a summary and the blob ref.
  • A picture in a body is a record, and the body names the record. com.disnetdev.radial.image holds one blob plus its metadata, in the author's own repo; prose refers to it as ![alt](radial-image:<the record's at:// URI>). Two things follow, and both are the reason it is not simply a URL. A blob that no record references is one the PDS may reclaim, so the record is what makes an uploaded image storage rather than a file that quietly stops existing — which is why the record is written before the markdown is inserted, and why a failure between the two is reported as "nothing was inserted" rather than as a partial success. And a …/xrpc/com.atproto.sync.getBlob?did=… URL pins whichever PDS the author was on the day they pasted it, whereas a record URI names the author: a reader resolves that DID to its current PDS at read time, so an author moving PDS does not rewrite anybody's prose. Resolution is closed and one-way — read the record the locator names, validate it against the image lexicon, take the blob CID from the record, fetch it from the author's current PDS. A DID/CID pair lifted out of markdown is never fetched; that would be an arbitrary-fetch primitive with a Radial scheme painted on it. The format allowlist is four raster containers (image/png, image/jpeg, image/webp, image/gif) with a 10 MB ceiling, and SVG is not on it: displayed through <img> it is still a document, with script, external references and an unbounded decode. Uploads are checked against the container's own magic number rather than against the browser's filename-derived MIME type.
  • An image record is infrastructure, not a unit. It carries no space, goal or project reference, so — like an agent record — it is bound to a space by its author's membership and by nothing else. The fold indexes them (MaterializedIndex.images) purely so a reader can resolve a locator without a second network round trip: nothing downstream reads that list. An image cannot open, close or fulfil anything, and it contributes nothing to goal state, timelines, claims, provenance or the convergent digest (§12) — asserted by removing every image record from the golden scenario and comparing the two indexes. Images are also never deleted when the text referring to them is: records are immutable by convention, and safe reclamation is a retention design rather than a side effect of pressing backspace.
  • Availability follows the author's PDS. Radial does not proxy or replicate images: a picture is served by its author's blob service, and a member whose PDS is unreachable has unreachable pictures, exactly as they have unreachable records. Readers render a failure as a placeholder naming what should have been there. A turn's bundle carries prose verbatim, so a locator reaches an agent as text; bundle.md lists every image the bundle refers to with the two steps that turn a record URI into bytes, so a picture pasted into a brief does not disappear from turn context.
  • A comment from outside the space is a message in the guest's own repo, and it is invisible until a member copies it. Anybody with an atproto account can write a com.disnetdev.radial.message naming a goal — repos are self-sovereign, so there is no permission to grant and none to withhold. What no outsider can do is reach the fold: ingestion polls member repos only (§3, §5), so a guest's record is in no RecordStore, in no materialize(), and in no turn bundle. Discovery is therefore view-time and client-side: the web app asks a public backlink index (Constellation) which records point at a goal, fetches each hit from the guest's own PDS, re-validates it against the message lexicon, and checks it really names that goal — the index is a hint, never an authority. Results never enter a store, the index, or the digest (§12), so two materializers with the same records agree whether or not that third-party service was reachable. Crossing into the trusted world takes a member's blessComment, and the crossing is a copy, not a pointer: the blessing carries the exact bytes the member read, signed by them. Three things follow, and the first is why it cannot be a pointer at all — the fold does no IO, so a pointer would oblige every materializer, browser tab included, to fetch a non-member's PDS before it could render or bundle anything; a guest can rewrite or delete at will, and the snapshot is what a member actually vouched for; and what an agent reads is bytes a member signed rather than whatever a guest's PDS serves at dispatch time. retractBless withdraws one (the bless's own author or an active admin, retractRequest's rule), which matters more here than it does for a request because a blessing is the one path untrusted prose has into a turn (§13). setGuestComments is admin-authored and folded latest-wins over a default of false: it decides whether the app asks, not what anybody may write, and turning it off leaves existing blessings standing — a solicitation switch is not a revocation. A guest later granted membership needs no migration: trust is a function of the current grant set (§3), so their old comments simply become member messages.
  • Artifact types are data, not lexicon. plan and implementation ship as built-ins; a space admin adds security-review or architecture-diagram by writing a registry record with a brief template and expected output shape. New capabilities are configuration, and the UI grows a button, not a schema.
  • Scope is a property of the type. Registry types declare where their deliverables live: goal (the default — plans, implementations) or project — system artifacts (architecture docs, ADRs) that outlive any one goal and collect in the UI's System section (§8). A request's goal-or-project reference matches its type's scope; basedOn carries cross-links between scopes.
  • Two senses of "review", deliberately distinct. A com.disnetdev.radial.review is a verdict — a mechanical annotation pinned to an exact artifact version, produced by the Review button or the auto-review trigger. A review-type artifact (e.g. a security audit) is a standalone deliverable that can itself be reviewed. Verdicts are for judging work; artifacts are work.
  • Linkage is loose; provenance is recorded. basedOn is filled from UI context for free (clicking "generate implementation" on a plan card links that plan) and never required — the agent gets the goal's full context regardless and is trusted to orient itself. But recorded-when-known provenance is strictly better for humans auditing later, so the UI always writes what it knows.
  • prev chains versions of the same deliverable (plan v2 supersedes plan v1). "Current" is a UI concept — newest in the chain — not protocol state; nothing mechanical depends on it, because reviews pin exact versions.
  • criteria (human-judged, on plan artifacts) and checks (executable, on the project) are deliberately separate. A reviewer needs to know which bullet points a machine already guaranteed.

5. Ingestion #

The daemon and UI need to see members' records promptly.

  • v1: poll each member repo via com.atproto.sync.getRepo/listRecords on a short interval (spaces are small — tens of DIDs), plus subscribe to Jetstream filtered to com.disnetdev.radial.* collections when available.
  • Records are ingested per author in repo order (each repo is internally ordered). Cross-author ordering doesn't matter for correctness because the fold (§6) is order-insensitive.

6. Materialization #

Materialization is nearly a pure index, by design:

  1. Collect all trusted records referencing a goal (project-scoped requests and artifacts group under the project instead — §8).
  2. Group artifacts under it; attach reviews and checkruns to their subjects by strongref; chain versions by prev.
  3. A request is open if no trusted artifact or review references it via request (and no declining message from its assignee — re + declines, §9); open requests are the daemon's work queue and the UI's "in flight" view.

There is no derived state machine, no authority-checked transitions, and only one rule that needs a tie-break: competing claims on an open request resolve deterministically (earliest createdAt as an instant, since one instant has several legal RFC-3339 spellings; then lowest record URI by code point, not by locale collation — the URI, unlike the CID, is stable across lease renewals), so a losing daemon sees it lost within one ingestion cycle and releases. Two materializers that ingested the same records always compute the same view.

A lease is a duration, not a deadline. Whether a claim is still live is the one question whose answer legitimately differs between two observers, and it is deliberately made to differ on lag rather than on clock skew. A claim version declares expiresAt − renewedAt — both stamped by the writer, from one clock reading, for that version — and each observer honours that duration from the moment it first stored the version. Nothing compares one machine's clock to another's, so a writer running fast no longer shortens everyone else's lease and a writer that jumps forward mid-lease no longer writes a lease nobody can retract (the jump moves both endpoints; the duration is unchanged). Two bounds are wire contract, evaluated identically by every observer and by the store's version selection: a declared lease of at most 1 hour, and — for every version — an expiresAt at most 24 hours after its own createdAt. The horizon has to be that generous because createdAt is frozen across renewals, so the span it bounds is age + lease for a perfectly healthy claim; it is not redundant with the lease bound because renewedAt is self-reported, and createdAt is the only field on the record that corroborates it — a clock that jumps forward, renews and is then corrected otherwise leaves a legal-looking lease sitting a week out that outranks every honest renewal after it. The price is that a claim's whole LIFE is bounded by the horizon: a request still open a day later is claimed again in a new record (the claim rkey counts generations), with a fresh createdAt and a fresh place in the tie-break, rather than held by a version every observer would refuse — in the same pump, so a turn already running keeps a covering claim rather than losing one for the rest of the turn (that claim commits no new turn, so it is charged against the outstanding-claim budget but not against free turn capacity, which the running turn it covers is itself holding). A version outside its bound is not honoured by anyone, is named in ignored, and is filed as a rejected edit; its writer may repair it with a shortening rewrite, which the store adopts only while the version it replaces is out of contract, so "a live lease cannot be retracted" still holds. What remains unfixed is the tie-break: createdAt is self-reported with nothing corroborating it (rev is observer-local, the claim rkey is a content hash), so a chronically slow clock wins every race. That is starvation and unfairness, not divergence, and closing it means reintroducing a corroborated time source — its own decision, deliberately not taken here.

7. Artifact lifecycle #

Every artifact moves through the same four moments. None of them is a state the protocol tracks — each is just a record landing.

Request. A human presses a button. Each button is a prompt: the artifact type's brief template from the registry, plus whatever the human adds, plus basedOn provenance from the card they pressed it on. Requests either name an assignee (the common case) or are open to any member agent that produces that type (claims arbitrate, §9).

Fulfillment. One turn produces exactly one artifact answering the request. Implementation-type artifacts link branch, commit, and PR (§10).

Review. Every artifact card has a Review button — it writes an artifactRequest with type: "review" and the artifact as subject; the fulfilling turn's terminal record is a review verdict (referencing the request) rather than an artifact. The auto-review toggle (per artifact type per project, overridable per request) makes the daemon write an open review request automatically the moment a matching artifact lands. One eligible local agent identity authors the record (§3), but it is not assigned to that identity: ordinary claims arbitrate execution among every capable agent in the space, including agents run by other operators (§9). That bounds duplicate execution per request, not authorship: two operators can still author separate requests for the same artifact before either observes the other's record. The reviewer needs no distinct identity — the same agent DID may still win the claim and review its own artifact: what buys a review its value is a fresh context (every turn starts from nothing, §9) plus, when the operator configures it, a different model routed to the review type (§11). Auto-review is deliberately terminal: a verdict never triggers further generation. It is the only automation in v1, and it is exactly one hop.

Supersession. A request_changes verdict blocks nothing mechanically — it is information on the card. The human reads the findings and decides whether to request a v2 (prev-linked, findings included in the new turn's bundle) or move on. Radial is review-annotated, not review-gated: a team that wants "no implementation without an approved plan" gets it as soft UI policy (the button warns when the basis plan lacks an approving verdict), not a protocol guarantee. That's acceptable precisely because the thing gates protected against — agents autonomously spending tokens past an unapproved plan — cannot happen when every generation has a human click behind it.

Future trigger rules. Auto-review generalizes: it is a degenerate trigger rule (on artifact of type X → request review). Later rules — auto-revise-once on request_changes, auto-implement on plan approval — can layer onto the same substrate as opt-in per-project config. The lesson carried over from the state-machine design: any rule that can cause generation must carry an iteration bound, or two models ping-pong tokens forever. v1 sidesteps this entirely by shipping only the terminal hop.

A goal's ending is an explicit human act, and a goal has exactly one. It is a standalone com.disnetdev.radial.closeGoal record — {goal, closed, disposition?} — which any active member may write, which is reversible (latest wins, so closed: false reopens), and which carries a one-word disposition saying which kind of ending it was: completed · dropped · superseded · parked. Consumers read one predicate, GoalView.ended; nothing in the fold branches on disposition, so the vocabulary can grow without two materializers disagreeing about which goals have ended (it is typed as a free string with a documented vocabulary, not a lexicon enum, for exactly that reason — packages/lexicons/README.md carries the contract).

This supersedes two earlier spellings, both of which are still READ and neither of which is written any more:

  • goal.closed, an owner edit of the goal record, formerly the one sanctioned primary-record edit in the protocol. It ended the goal and could only be written by its author, because an edit lands in the author's repo or nowhere. That rule protected nothing — any member could achieve the identical effect by archiving — and it stranded a goal whose author had gone. Legacy edits are read as completed, with no ending instant of their own. The record store keeps adopting the "differs in closed alone" version for that data, and goal strongrefs stay lenient (matched by URI, not uri#cid) because such an edit minted a second CID.
  • com.disnetdev.radial.archiveGoal ({goal, archived}), any member's reversible "stop looking at this". It is read as parked. It could only ever ADD that reading: a legacy un-archive cannot cancel a closure, since a writer of either record knew nothing about the other.

The two had the identical effect on every daemon pump — one capability with two spellings, two permission rules and two answers to "where does this goal go now" — which is a choice nobody outside the fold could make. An observed PR merge annotates the implementation artifact (§10) but never ends the goal by itself.

A goal's labels are the same shape one axis over: a standalone com.disnetdev.radial.labelGoal record — {goal, labels: [string]} — which any active member may write, matched to the goal by URI, latest-wins. The winning record carries the goal's whole set, so a write replaces rather than merges and labels: [] clears; GoalView.labels is the one predicate anything mechanical reads, and it is a typed field precisely so no filter, view or agent is ever tempted to grep a body for #infra. Any-active-member rather than author-only for the same reason closeGoal is: a label is an annotation about where attention should go, not a statement about what the project is (contrast editProject, §4). The cost of whole-set latest-wins is that two members relabelling one goal inside one ingestion window lose an edit — the same race an ending already accepts, and undone by relabelling. The alternative, a per-label add/remove OR-set, is a second CRDT shape with a tombstone story this fold does not otherwise have; it is deliberately deferred, as is a colour-carrying admin registry of labels (labels are free strings, and the vocabulary is simply whatever is in use — writers normalize whitespace and case-fold duplicates, packages/lexicons/README.md carries that contract).

A saved view is one member's named filter over the space's goals: com.disnetdev.radial.savedView — {space, name, project?, labels?, state?, deleted?} — folded latest-wins per (author DID, name), which is what makes a view editable at all under record immutability (re-saving a name is the edit; a deleted: true record under it is the delete). A goal matches when it carries all the listed labels, matches state (default live), and, when project is present, sits under it; an absent field does not constrain, so a future additive field stays compatible with views written today. Views are personal — the fold surfaces every member's because records are public regardless, and a client shows its own human's. They are on-protocol rather than in browser storage because there is no server to sync them to and per-origin storage is single-device and evictable. Nothing in the fold or the daemon acts on a view: it is UI derivation that happens to be durable, and filtering itself never leaves the reader's tab (a filtered list is also a URL, which is what a view names).

8. System artifacts (project scope) #

Most artifacts are working memory: a plan matters for the lifetime of its goal, then survives only as historical context. Some deliverables outlive any goal — an architecture overview, an inventory of what the system is made of, a glossary of the names the project uses for those parts, decision records (ADRs), feature design records (FDRs), conventions and onboarding docs. These are system artifacts: ordinary artifacts whose type declares scope: project in the registry. Nothing else about them is special — same request lifecycle, same prev versioning, same reviews (auto-review included; a wrong architecture doc is the most expensive artifact to leave unreviewed), same conversation — messages can anchor to an artifact directly (§4), so a system artifact carries its own thread without any goal.

The UI's System section is just "the current version of each project-scoped artifact chain" — a view the fold already computes. It collects two durability patterns with different maintenance stories:

  • Decision records (ADR-style — the seeded adr for a decision about the system's shape, fdr for the design of one feature) are append-only and immutable: never edited, only superseded by a later record (prev). No staleness problem — the chain is the value.
  • Living documents (architecture, architecture-inventory, glossary, conventions) are revised across many goals as prev-linked versions. Durability's real cost lives here: not generation, but maintenance.

Generation surfaces:

  1. Project-level buttons in the System section ("update architecture doc", "new ADR") — direct project-scoped requests.
  2. Capture buttons on goal artifact cards and threads ("capture as ADR", "fold into architecture doc"). Capture is a request, not a flag: it writes a project-scoped artifactRequest with basedOn pointing at the source, and the fulfilling turn distills rather than copies — goal artifacts are written in the goal's register, and pinning them verbatim would collect stale plans, not documentation. (A flag also has no update story — and artifacts are author-owned records the button-presser usually can't mutate anyway.) The daemon assembles the turn's bundle from the source artifact's goal, reached through basedOn.
  3. Future: a trigger rule ("on implementation merged → request architecture-doc update") — generation-causing, so it must carry an iteration bound per §7; not in v1.

Staleness is annotation, not automation. For living documents the UI computes a signal from state it already materializes — "4 implementations merged since architecture v3" — and badges the card; the obvious click is "request update". Information for the human, no protocol change, consistent with review-annotated-not-gated.

System artifacts are the agents' long-term memory. Agents are stateless between turns (§9); goal artifacts and threads are working memory. The current versions of a project's system artifacts ride in every goal bundle, so every turn — plan, implementation, review — starts oriented by the architecture and standing decisions. Humans reading the System section is the side effect; keeping the robots oriented is the point.

On-protocol, not in-repo (decided for now). System artifacts live as records like everything else — that's what buys versioning, reviews, provenance, and the System section for free, and bundle inclusion solves agent visibility. A possible later addition: the fulfilling turn also commits a markdown mirror to docs/ so non-Radial agents and repo-browsing humans see them.

9. The turn model #

A turn is one ephemeral container run that fulfills one request. There is no scheduler ticking agents; turns are triggered by requests appearing in the fold.

Trigger: a daemon's fold shows an open request that names one of its agents, or an open request whose type one of its agents produces. For open (unassigned) requests it first writes a claim (lease, e.g. 15 min, heartbeat-renewed) and confirms after an ingestion cycle that its claim won; assigned requests need no claim. It then:

  1. Launches a container with: a materialized goal bundle (goal, the basedOn artifacts and their version ancestry, open review findings on them, checkrun results, current versions of the project's system artifacts (§8), thread history — as JSON/markdown), a git checkout at the right ref, per-turn credentials (the operator's forge token (§10) plus a spend-capped model key — §13), and the composed brief (type template + request brief).
  2. The harness does its work and emits records only through the sidecar, which forwards them over a local socket to the daemon — validation, signing, and the PDS write all happen daemon-side (§13). A turn can emit exactly one terminal record plus messages, and which one is fixed by the request's type before the container starts: the requested artifact, a review of the named subject (for review requests), or a thread reply — a message threaded under the named subject — for answer requests. The last two are built-ins of this layer rather than registry types (§4): a member cannot redefine what judging or replying means, and neither produces an artifact or a row in a goal's unit list. The daemon owns every anchoring field of all three; a container supplies a body and, for an artifact, its criteria.
  3. Container exits; any claim is released; the request is now fulfilled in the fold.

Key properties:

  • Agents are stateless between turns. All memory is on-protocol (artifacts, findings, messages) or in git (commits). This is what makes harnesses pluggable and lets a v2 request be fulfilled by a different agent than the one that produced v1.
  • Can't-complete is first-class without a state. A turn that needs input its bundle can't answer posts a message with the question (re pointing at the request) and exits without an artifact. The request stays open, flagged in the UI as awaiting input; the human replies in-thread and either re-requests or lets the same request be picked up again with the richer thread as context. The reply is structural: what clears the flag is a message whose parent names the question, authored by somebody other than the asker. A sympathetic paragraph in the thread with no parent does nothing — the fold is looking for the field, not for the prose. It is also what tells a question apart from an answer turn's reply, since both carry re.
  • Leases expire on crash; partial git work sits on the branch for the next claimant. Per-goal spend budgets are deliberately post-v1: every generation has a human click behind it, and that click is v1's spend control.

10. Forge integration and code review #

Implementation artifacts flow through the forge as ordinary PRs: the fulfilling turn pushes a branch and opens the PR itself, and the artifact links branch + commit + PR URL. Turns act on the forge directly, using standard tooling baked into the container image (git, gh, per-forge equivalents later) — this is also how agents do forge recon (issues, PR discussions, CI logs, code search) without the daemon proxying an ever-growing operation surface.

  • Forge auth is the operator's own; agent attribution is on-protocol, mirrored into content. The signed Radial record stream is the authoritative attribution for agent work — the forge only mirrors it. Turns therefore carry agent identity in content: the commit author / Co-Authored-By: trailer names the agent and its DID, and a PR-body footer links the artifact record. No forge account per agent. The token a turn pushes with is the operator's own GitHub auth: radiald init reuses an existing gh CLI login or runs GitHub's OAuth device flow once; turns push and open PRs as the operator's account. Zero setup beyond a browser click, same shape as the atproto registration flow. The token is broader and longer-lived than a per-turn token would be, so the blast-radius bound is forge-side controls (branch protection, required review) plus the human merge — the same properties §7's human-click argument already leans on. Operators who want scoping today can substitute a fine-grained PAT restricted to the space's repos (contents + pull-request write, expiring).
    • Deferred hardened tier: one GitHub App per operator (not per agent), created in two clicks via GitHub's App Manifest flow (radiald init --github-app), installed with contents + pull-request write and no merge/admin rights, with the daemon minting a short-lived (~1 h) installation token per turn. Scoped, expiring, audited and revocable at the forge — the right upgrade for shared or long-running deployments, but post-v1: v1's audience is the solo operator, for whom the operator-token bound is adequate and the setup cost isn't.

The forge stays behind a small adapter interface, so Radial stays forge-agnostic. It is a registry, selected per project by the host of its gitUrl, not one global switch: a space may hold a GitHub project and a tangled project at once, and a credential missing for one forge must not stop work on the other.

ForgeAdapter:
  matchesGitUrl(gitUrl) -> bool             # host routing; ForgeRegistry.for(gitUrl)
  pullBelongsToProject(prUrl, gitUrl) -> bool
  ownsPullUrl(prUrl) -> bool                # route a bare URL back to an adapter
  getPullRequestState(prUrl, {gitUrl}?) -> open | merged | closed
  compareUrl(gitUrl, base, commit) -> url
  projectIdentity(gitUrl) -> string         # owner/repo (GitHub) or the REPO's did (tangled)
  openPullRequest?(...)                     # ONLY where the container cannot open one itself
  turnEnvironment?(...)                     # ONLY where the turn needs credentials the adapter owns

The adapter is daemon-side (it holds the operator's observation credentials); where an adapter implements neither optional capability, turn-side forge writes use the turn's forge token exactly as before (§10, §13). The UI only ever consumes URLs from records. getPullRequestState's second argument is the project the pull belongs to, when the caller happens to know it — a hint, never part of the routing (a pull request URL alone is all the merge poller is guaranteed to hold). GitHub ignores it; tangled needs it because a pull request URL there names the repository's own DID and not the owner whose repo a merge is recorded in.

GitHub and tangled, side by side. GitHub is observation-only, as above. tangled is an atproto forge, which changes two things and nothing else:

Radial GitHub tangled
where the repo lives github.com a knot (headless git server); browse at https://tangled.org/<owner>/<repo>
how a repo is named owner/repo the repository's own DID — not its owner's; at://<ownerDid>/sh.tangled.repo/<name>'s repoDid maps one to the other, and the repo DID resolves to a knot that holds no atproto records
how a turn pushes https + GH_TOKEN ssh only, with an ed25519 key registered to the agent's DID as an sh.tangled.publicKey record
what a pull request IS a page an sh.tangled.repo.pull record in the author's own repo, with an append-only rounds array of gzipped git format-patch blobs
what links.pr carries …/pull/<n> a verified …/pulls/<n> only when that web page names the pull's exact at-uri; otherwise the stable pull-record at:// URI
pull state the API the newest sh.tangled.repo.pull.status record naming that pull — folded by Bobbin, tangled's public read-only XRPC appview (https://api.tangled.org), because the status lives in the repo of whoever ruled on it. Bobbin's open means "no ruling in my index", so it is corroborated against the two repos Radial can name; a merged or closed stands
v2 of an implementation push, gh pr edit push, append a round to the same record
who opens the pull request the turn, with gh the daemon, at submit time

That last row is the only real asymmetry, and it is forced: tangled.org/core ships cmd/knot, cmd/appview, cmd/spindle and no user-facing CLI, so there is nothing a container could run to open a pull request. Writing the record daemon-side is also where record construction already belongs (§9: the daemon constructs records, the harness supplies prose), and it lets the pull be authored by the same DID that signs the artifact — a stronger form of the attribution the Co-Authored-By: trailer achieves on GitHub, with no forge account and no operator token in the middle. openPullRequest is therefore an optional adapter capability: --pr stays required on a submit for every adapter that does not implement it.

The ssh key does not weaken §13's boundary 5. An sh.tangled.publicKey authenticates git transport to a knot and nothing else — it cannot sign atproto records and cannot reach a PDS — so it is scopeable and expendable exactly like GH_TOKEN, and is revoked by deleting one record. It is mounted read-only per turn at /run/radial-forge, used through a GIT_SSH_COMMAND with IdentitiesOnly=yes and a pinned known_hosts (the operator supplies the lines; Radial will not fall back to StrictHostKeyChecking=no, which would make every push MITM-able), and a tangled turn is given no GH_TOKEN at all. Tangled's own CI (spindles) is explicitly out of scope: Radial keeps running the project's checks in its own container.

Two consequences worth stating plainly, because a reader will otherwise flag them as violations. Appending a round mutates a record in place, which boundary 3 forbids — for Radial's records. A tangled pull is tangled's; no materializer reads it, and every append pins swapRecord so a lost update fails loudly rather than dropping a revision. And the at:// fallback for links.pr is why format: uri validation accepts one: it is an absolute URI by RFC 3986 that WHATWG new URL happens to refuse, and no lexicon changed to allow it.

  • One PR per version chain. A version chain is a branch is a pull request: asking for another version of an implementation — the way a review's findings get resolved — continues the predecessor's branch and updates its PR, and a request that starts a new chain gets a new PR. That mapping is expressed entirely by basedOn; there is no forge concern on the protocol and no way to ask for one outcome from a basis that implies the other. The daemon selects the branch, and reuses the predecessor's only when the request resolves to exactly one same-type predecessor under the same target, that predecessor links a branch inside the daemon's own radial/impl- namespace and a PR in this project's repository, and the PR is still live work. With a ForgeAdapter wired, "still live" is getPullRequestState saying open with head ref, head repository and base ref all matching; without one it is the index plus one git ls-remote — no trusted merge annotation on the predecessor, and the branch still present on the remote — and the daemon logs which of the two answered. Be precise about what the no-forge signal is worth: merge annotations come from the merge poller, which only runs when a forge is configured, so in a forgeless deployment the merge check is structurally dead and the ls-remote is the guard that carries it. What that probe buys is not tidiness but recoverability — a predecessor branch deleted after its PR merged makes the reuse clone fail identically on every retry, so without the probe such a v2 becomes permanently undispatchable rather than merely landing on a fresh branch. What remains invisible without a forge: a merged branch that still exists reads like an open one, and so does a PR closed without merging; the turn recovers from the latter by opening a PR when the branch has none. Anything else is a fresh radial/impl-<hash> branch and a new PR. A continued branch is also the one checkout that must merge — its base is fetched alongside a deepened clone, because --depth 1 grafts the tip as parentless and git then refuses the merge as unrelated histories. Rewriting history to keep one clean commit is explicitly rejected: links.commit is load bearing — reviews pin an exact version and checkruns fetch its sha — so a reviewed commit must stay reachable.
  • Checks run on every implementation artifact. On seeing one land, the daemon runs the project's checks against the linked commit in a clean container and posts a checkrun. Any member can technically post a checkrun, so the UI always surfaces the authoring DID alongside the results. Results annotate rather than gate: a red checkrun is flagged prominently on the card (and included in any reviewer's bundle), and the obvious next click is "request revision" — but nothing bounces automatically.
  • Radial never merges. A human merges the PR on the forge; the daemon observes the merge through the adapter and annotates the artifact as merged. On tangled the merge is a pull.status record the person who clicked wrote into their own repo, so the DID to read it from is not knowable in advance. That fold is what an appview is for, and tangled runs a public one: the adapter asks Bobbin (read-only and unauthenticated, so still no new credential). It scans PDSes itself — the pull's author and the repository's owner, the two repos it can name — when the appview is unreachable, has not indexed the pull yet, or is switched off, and also whenever Bobbin reports open, because an edge index that has not covered a ruling reports exactly that and believing it would leave the merge unobserved forever.
  • Diff-reading is delegated to the forge permanently — this is a deliberate long-term stance, not a v1 shortcut: the web UI links to the PR for reading code, and Radial never grows a native diff/annotation UI. Findings posted via Radial are structured (path, line, severity, body) so the UI can render them alongside the link and revision turns can consume them mechanically. Optionally the adapter mirrors findings as PR comments for reviewers who live in the forge.

Corollary: conversation threads (message) are for humans and ambient discussion; agents receive them as bundle context. Anything the UI or daemon acts on mechanically must be a typed record (artifactRequest, review, checkrun) — otherwise agents end up parsing chat to find out what to do.

That rule is unchanged now that agents can answer, because a message is still never a trigger. What dispatches an answer is a typed answer artifactRequest naming one message version as its subject; its terminal record simply happens to be a message rather than an artifact, the way a review request's is a verdict. A mention remains what it always was — addressing, not dispatch — and nothing anywhere reads a message body to decide what to run. The UI may derive the request from a mention (the composer's @agent button writes both records), but the record a daemon acts on is typed, signed, and written by a human.

The invariant that keeps this from being alternative C (§14) is narrow and testable: the daemon never authors an answer request. Auto-review is the one daemon-authored hop in the system and it stays that way, so an agent's reply commissions nothing — a conversation cannot sustain itself without a person writing a record for each turn of it, and agent↔agent ping-pong stays structurally impossible (§12).

Every record authored through a turn socket — its terminal artifact, review, or answer reply, and any question posted before it stops — preserves optional daemon-observed model provenance. The daemon stamps the latest concrete runtime model reported before that write, with the explicitly configured invocation model as fallback; a silent harness default remains absent. The sandbox cannot claim this value, and a later reroute cannot rewrite an already-signed record.

11. Agent operators #

  • An operator's daemon publishes one com.disnetdev.radial.agent record per profile, keyed by profile name in the agent's repo: harness, models, cost hints, and the artifact types it produces (registry names plus the built-ins review and answer). A single agent DID may publish many, so capability sets are added and retired by writing and deleting records rather than by provisioning accounts (§3). Republishing one under the same rkey — what radiald init <profile> --update does when a profile gains a type — is a sanctioned in-place edit, adopted latest-revision-wins by every materializer (§4).
  • Spaces admit agents via membership like anyone else. Requests + profiles + claims determine who does what; there is no central assignment.
  • Capabilities are per space, through an optional scopes overlay on the same record. An agent record carries no space reference, so membership is what binds it to a space (§6) — which meant one capability set applied everywhere the agent was a member. scopes is a list of {space, artifactTypes} on the record: in space S, a profile's effective types are the entry naming S if one exists, else the top-level artifactTypes. It is an override, not an intersection — an entry is the whole answer for that space, and an entry with an empty list is a valid statement ("this profile does nothing here") rather than a missing one. Entries naming other spaces are inert. One resolver (agentTypesFor in core, mirrored config-side by actorTypesFor) is what every reader goes through: the assignee picker, the daemon's dispatch, claim and auto-review filters, and the coverage advisories. Nothing about the record's identity moves — one record per profile, keyed by profile name, republished under the same rkey — so §4's sanctioned in-place edit and the --update drift guard carry over unchanged, and the digest is untouched (it reads the registry, not capabilities): no protocol version bump.
    • Old-reader degradation is benign and already named. A materializer that does not know about scopes shows the top-level list everywhere. If a scope narrows, an old assignee menu over-offers and a request assigned there sits open — exactly the uncovered-type failure the coverage advisory already exists to explain. If a scope widens, an old menu under-offers, while open requests are still claimed, because the daemon dispatches from its own config. The obligation therefore lives on the writers, as it does for artifact.title (packages/lexicons/README.md).
    • This discloses nothing new. A scopes list names the public space URIs one agent differentiates between; grants and join bookmarks already make membership public. Stated here so it is a decision rather than an accident.
    • The carrier in a private space is an envelope, and the publisher is the running daemon (§18). A private fold reads agent only off the private path, so radiald init — which writes to a PDS — cannot give a private space an assignee list at all. The daemon reconciles each profile's record into the replica instead (create, or republish under the same rkey when the config moved, and nothing at all when it did not), with the effective types for that space resolved into artifactTypes and no scopes field: a replica is per space, so the scoping vocabulary has nothing to say inside one. resolveAgentInits correspondingly drops private spaces from the public record's scopes — not because the URI is secret, but because the public record is a source that fold ignores by construction, so an entry there would be permanently unread and permanently unable to look anything but stale.
  • Harness is per profile, and resolved fail-closed. The daemon holds a registry of the harnesses it can run (Claude Code, pi and the Codex CLI today) and selects one per turn from the acting profile's harness, so a pi reviewer and a Claude implementer coexist in one daemon process. A profile naming a harness the daemon cannot run is refused — at radiald init, before the agent record is published, and again at radiald run startup — never silently downgraded to another one: the published record is what a space's assignee menu offers, and it has to be true.
  • Routing is operator-side configuration. The daemon config maps artifact types to model/provider choices — e.g. plan: {provider: anthropic, model: opus}, review: {provider: openai, model: gpt-5}, implementation: {provider: anthropic, model: sonnet}. An agent only accepts requests for types it has an assignment for. This keeps routing decisions (and their costs) with the party paying for tokens, and gets reviewer/implementer model diversity for free when an operator assigns different models to those types.
  • An agent profile's models list declares routing capability and preference, not proof of what ran. Per-record model provenance is the daemon's write-time observation and remains attached to that signed version.
  • With multiple capable agents in a space, assigned requests go where the human pointed; open requests are arbitrated by the claim fold.

12. Failure modes and bounds #

Failure Handling
Agent crashes mid-turn Claim expires (open requests) or the assignee's daemon retries; partial git work sits on the branch; the request stays visibly open
Two daemons claim an open request Deterministic fold tie-break; loser releases within one ingestion cycle
Agent↔agent ping-pong Structurally impossible in v1: generation is human-initiated and auto-review is a single terminal hop; future trigger rules must carry iteration bounds (§7)
Runaway spend Every generation has a human click behind it, and auto-review is a single bounded hop; per-goal spend budgets are post-v1 (§9)
Malicious/compromised member agent Can only pollute records under its own DID. Removing it revokes its grant and every materializer ignores its entire record corpus identically; the space loses its coordination history (the durable output lives in git), and a later re-add restores it. It cannot forge others' artifacts, verdicts, or requests
Materializer disagreement Impossible by construction if both saw the same records — the view is nearly a pure index; divergence is a sync gap, resolved by ingestion, not consensus
One operator's clock is wrong Bounded, and by design it costs lag rather than correctness (§6): a lease is a duration measured from first sighting, and both lease bounds are pure functions of the record, so two operators refuse the same versions. A forward jump cannot buy more than one lease — the horizon bounds every version against the one timestamp a renewal cannot move — and the writer repairs its own damaged record, or retires it and claims afresh when it is too old to repair — including the record a corrected clock leaves behind, which is in contract and so cannot be shortened by anyone: a writer judges its own records against its own clock (the one comparison that is a machine's word against itself) and retires what it could not have written now. The residue is the tie-break — a slow clock wins races — which is starvation, not divergence. The daemon warns above min(30s, leaseMs / 10) using PDS Date headers (symmetric) and future-dated records (one-sided)

13. Security and privacy #

  • Public coordination. Everything on-protocol is world-readable. Current stance (decided): Radial is for development that can be coordinated in the open (the code itself can still live in a private repo — records leak plans/findings/paths, not file contents, but treat that as public too in practice). Permissioned/private spaces are a long-term goal with no near-term work: when atproto private state matures, membership already gives us the trust boundary to hang it on.

  • Protocol credentials never enter containers. atproto signing stays daemon-side behind the sidecar socket, which is the only harness → protocol write path, and a turn can only emit its requested type. This is the non-negotiable invariant: an agent's atproto identity is unscopeable, and revoking it costs the space its coordination history. Everything else below is scopeable and expendable, and is treated accordingly.

  • Containers are a possession boundary, not a network boundary. A turn container holds exactly two secrets: the operator's forge token (§10) — their own GitHub auth, or a repo-scoped fine-grained PAT if they want scoping — and a spend-capped model API key dedicated to agent turns, for whichever provider the profile's models name (the daemon forwards only credential names its harnesses declare, plus an explicit operator allowlist, and only when set in its own environment; a daemon-side model proxy that would keep the key out entirely — and give per-turn metering — is a possible later upgrade, not v1). Egress is open: agents fetch docs, packages, and arbitrary web resources. Network confinement was never protecting the contents — the repo and bundle are public by stance — and an allowlist taxes every legitimate lookup. What remains is host isolation: containers run on an ordinary bridge network (never host network), the sidecar socket is the only daemon-facing surface, and cloud metadata endpoints are blocked when deployed on cloud infra. Check containers get network too (dependency installs need it) but carry no secrets at all.

  • ChatGPT-managed Codex auth is an explicit stronger-credential mode. run.codexAuth.mode = "chatgpt-session" replaces the spend-capped Codex API key with a refreshable account login for operators whose Codex entitlement lives in ChatGPT. A fresh device login belongs to one Radial instance; the operator's ordinary ~/.codex is never copied or mounted. Each turn receives a scratch auth home, turns sharing it are serialized, and only a validated refreshed auth.json is atomically written back. Everything else in that home is discarded. This preserves cross-turn isolation, but not possession isolation: the running turn can read the account credential, so the mode is for trusted workloads and is never automatic.

  • Prompt injection: untrusted input is unbounded — member records in the bundle, repo contents, and anything the agent reads on the open web. The mitigation is entirely write-side. The worst an injected turn can do is waste its capped spend, leak its forge token, and spam forge writes within that token's forge-enforced scope — all attributable and revocable at the forge. The leaked token is the operator's own (§10), so the damage bound is branch protection plus the human merge, and the remedy is operator-side revocation; the deferred App tier (§10) shrinks the leak to an hour-lived scoped token when it lands. It cannot touch the protocol except through the typed, requested-type-only sidecar path (so it can't commission new work, even though the protocol itself wouldn't reject an agent-authored request, §3), and it cannot merge. Everything an agent writes is attributable to its DID and revocable with its membership. Review and the human merge remain the behavioral mitigation.

    Guest comments (§4) do not widen this, and the reason is structural rather than a matter of framing. An unblessed comment from a non-member is not in any store, any index or any bundle — nothing polls a non-member's repo — so it reaches an agent by no path at all. What can reach one is a member's blessComment, and by then the text is bytes a member signed: the worst case is exactly the worst case for a hostile member message, which this section already bounds, and it is reversible by retractBless from either the blessing's author or an admin. What the UI owes a member is that the choice is informed — the confirm shows the full text being copied — and what bundle.md owes an agent is provenance: the section is separate from the thread, labelled with both DIDs, and never merged into it, so a turn can always tell which lines came from inside the space. The label is not the mitigation. The mitigations are that the copy is deliberate and the bound is unchanged.

    Importing a forge issue as a goal (§4, goal.source) does not widen it either, and by the same structure. Nothing polls a forge's issue records — they are not in any store, index or bundle, and ingestion has no path to them — so an issue reaches an agent only as a goal a member wrote: the text is bytes that member read on screen, edited if they chose to, and signed, which is the case this section already bounds. Two properties keep it there. The goal is written through the ordinary composer rather than by a one-press import, so the words are in front of a human, in an editable field, with the sentence saying what creating it means. And source — the pointer back to the issue — is deliberately not in the turn bundle: it exists so the space can tell which issues it has already taken up, and an agent handed the pointer could fetch the live issue and read comments and edits nobody reviewed. Discovery is view-time and asked for, for the same reason guest-comment discovery is: the service that lists issues is a third party, its answer is not part of the fold, two materializers agree whether or not it answered, and the ask is a reader opening the section's own page rather than any view of the space querying it on sight.

    This holds per forge without a per-forge argument, which is what "forge issue" above is doing. For tangled the listing appview and the issue's author are two parties, so the index is a locator only and every candidate is re-read from the filer's own repo before a reader sees it. For GitHub they are one: the forge's own API is both the index and the only copy of the issue, so the re-read step has nothing to attach to and simply does not exist. Nothing above depends on which it is — the view-time rule, the human-composed goal and the excluded pointer are all write-side — and the differences are confined to what a pointer can promise. A tangled-issue URI names a record, so it can carry the cid of the version that was read; a github-issue URI names a page, so there is no version to pin and source.cid is absent. source was always an assertion by the member who signed the goal rather than a proof, so nothing rests on the difference. source.kind stays an open string (§4): a third forge is additive by construction, and a reader that has never heard of one carries the record unchanged.

14. Alternatives considered #

A. Fixed lifecycle state machine (the previous version of this design): draft → planning → plan_review → implementing → change_review → done, with per-gate quorum policy, agent prepass, per-phase claims, and goal state derived by an authority-checked fold. Rejected in favor of the artifact model. The machinery existed almost entirely to let agents advance goals autonomously between human gates — and that autonomy was the source of every hard problem in the design: claim races and lease heartbeats, ping-pong iteration bounds, budget anxiety, and a materialization fold with state rules and transition-authority checks. Human-initiated requests deliver a stronger human-in-the-loop property (a human decision precedes every step, not just the gated ones) with a fraction of the machinery, and the automation can return incrementally as opt-in trigger rules layered on the artifact substrate rather than as the substrate itself. The extensibility clincher: the fixed graph couldn't grow a "security review" step without lexicon and state-machine changes; in the artifact model it's a registry record and a button.

B. Configurable workflow definitions (states/transitions/guards as records — a workflow engine). Rejected: the flexibility buys authoring complexity, a validation problem, a UI that must render arbitrary graphs, and a much harder materialization story. The artifact-type registry is the deliberate middle ground — types carry prompts and output shapes, never transitions, so extending the system is adding a noun, not authoring a graph.

C. Pure conversation-driven agents (no structure; agents respond when mentioned, like social-media collaborators). Rejected as the control layer: "when do agents run" degenerates into mention-parsing, and outputs have no type or provenance. The artifact model is not this — requests are typed, signed records with explicit briefs and subjects. Conversation is kept as the interaction layer: threads, mentions, and awaiting-input questions ride alongside the request/artifact stream. The answer turn (§9, §10) is the deliberate near miss: an agent does reply in a thread, but only because a human wrote a typed request pinning the message to answer, and only the daemon-never-authors-one invariant keeps that from collapsing into this.

D. Space-scoped capabilities as one record per (profile, space), under a composed record key. Rejected in favor of the scopes overlay (§11): it multiplies §4's sanctioned in-place edit across an unbounded set of records, needs an rkey scheme plus garbage collection when a space is dropped, and breaks the "one agent record per profile, keyed by profile name" line that radiald init's --update guard, the drift advisories and every doc rely on. The sibling rejection is an admin-written, space-side overlay ("this space uses these types from that agent"): wrong author. A capability is a self-description — what the agent offers — and admin curation of somebody else's capabilities is a different feature, not this one.

Generic op-log/CRDT materialization (the readme's original framing). Rejected in favor of primary-record + typed-referencing-records + deterministic fold (§4, §6): same convergence guarantees, dramatically less machinery, and it's the idiomatic atproto shape.

15. Resolved decisions #

  • Every agent turn is commissioned by an artifactRequest. Human initiation is a convention carried by the UI and daemons, not a protocol rule — materializers don't police request authorship, and auto-review requests are agent-authored. The only automation in v1 is the single-hop auto-review trigger, and any future trigger rule that can cause generation must carry an iteration bound. (§3, §7)
  • Reviews annotate, never gate. "Current version" is a UI concept; enforcement like "implement only approved plans" is soft UI policy. (§7)
  • Agents answer questions through a typed, human-authored answer request whose terminal record is a thread reply; the daemon never authors one, and a mention still dispatches nothing. Goal-scoped and assignee-required in v1. (§9, §10)
  • Artifact types are per-space registry data; plan and implementation ship as built-ins. (§4)
  • Provenance (basedOn) is recorded whenever the UI knows it, never required by the protocol. (§4)
  • A goal has one ending: a standalone closeGoal record any active member may write, reversible, carrying a disposition (completed · dropped · superseded · parked) nothing in the fold branches on. The legacy goal.closed edit and archiveGoal are read forever and written never. Merge observation annotates the artifact but never ends a goal. (§7, §10)
  • Goal labels are free strings on a whole-set, latest-wins labelGoal overlay any active member may write; there is no registry, no colours on-protocol, and no label-driven dispatch — the vocabulary is whatever is in use, and writers normalize it. Filtering is UI derivation over the fold; a saved view is that filter named, kept as a personal savedView record latest-wins per (author, name). (§7)
  • Checks run on every implementation artifact in a clean container; results annotate rather than gate. (§10)
  • Radial never merges; turns open PRs themselves, humans merge, the daemon observes through the (observation-only) forge adapter; GitHub is the first implementation. (§10, §13)
  • Turns use the operator's own GitHub auth (gh login reuse or OAuth device flow at init; a repo-scoped expiring PAT for operators who want scoping). Agent attribution is authoritative on-protocol and mirrored into commit trailers and PR-body footers — no forge account per agent, no GitHub App in v1. Forge-side controls (branch protection, no merge rights) plus the human merge are the blast-radius bound. The hardened tier — one manifest-created App per operator minting short-lived per-turn installation tokens — is deferred to post-v1. (§10, §13)
  • Container egress is open; containment is possession-based (what a turn holds) plus host isolation, not network-based. Spend-capped model keys are v1's answer to key exfiltration; a daemon-side model proxy with per-turn metering is a possible later upgrade. (§13)
  • Diff viewing is permanently delegated to the forge; no native review UI. (§10)
  • Routing is operator-side artifact-type→model/provider assignment config. (§11)
  • Coordination is public for now; permissioned spaces are long-term, no near-term work. (§13)
  • Agent identities are ordinary atproto accounts created by the operator elsewhere; radiald init only registers existing identities (app password or OAuth), never provisions DIDs or custodies keys. (§3)
  • Profiles, not accounts, are the unit of agent capability: radiald init registers every profile in the operator's radial.json, which by default share one identity and one app password, each publishing its own agent record keyed by profile name. (§3, §11)
  • Artifact types declare scope: goal | project; project-scoped system artifacts collect in the System section, and capture from goal context is a distilling request with basedOn provenance — never a flag or verbatim pin. (§8)
  • Current system-artifact versions are included in every goal bundle: they are the agents' on-protocol long-term memory. (§8, §9)
  • System artifacts are on-protocol; an in-repo docs/ mirror is a possible later addition, not v1. (§8)
  • Living-document staleness is a UI annotation, never a trigger. (§8)
  • Membership is an append-only event log of exact grants. Removal revokes one grant and retains nothing: all records by a DID with no active grant are ignored, making record trust independent of ingestion timing; re-adding restores trust. Availability of any member's records — active or removed — is best-effort. (§3)
  • Records are immutable by convention; materializers keep the earliest observed repo revision and flag in-place edits (claim lease renewal and an agent profile's republish are the sanctioned exceptions; a legacy goal.closed edit is a third, frozen for data already written). Oversized bodies go to blobs. (§4)
  • Validation and signing are daemon-side; the in-container sidecar is a thin client and protocol credentials never enter containers — the only in-container secrets are a per-turn forge token and a spend-capped model key. (§2, §9, §13)
  • A reviewer needs no distinct DID — the same agent may review its own artifact; review value comes from the fresh per-turn context and operator-routed model diversity. (§7, §11)
  • Messages anchor to a goal or an artifact, and reference the request they respond to (re, with declines for turn-downs); reviews reference the request they fulfill. (§4, §6, §9)
  • Per-goal spend budgets are post-v1; v1's spend control is the human click behind every generation. (§9)
  • Checkruns can be posted by any member; the UI always surfaces the authoring DID. (§10)

16. Open questions #

  1. Open-request arbitration across operators: is the slim claim record + deterministic tie-break enough, or do open requests eventually need negotiation (cost quotes, bids)? Tied to the undesigned economic layer between spaces and operators.
  2. Trigger rules beyond auto-review: representation and bounds for chaining rules (auto-revise-once, auto-implement-on-approval) when they arrive — per-rule iteration caps, per-goal, or both?
  3. Forge adapter surface: is observe-merge polling enough, or do we need webhook support per forge for responsiveness?

HumanLayer (humanlayer.com) is the closest prior art and validates the product shape: an AI-assisted SDLC platform built on the same human-in-the-loop discipline ("do not outsource the thinking"). The surface-level overlap is striking — versioned collaborative artifacts (research, designs, plans, implementations), agent sessions with full tool-call visibility, comment-driven review loops that feed human feedback back to agents, multi-repo worktrees, local daemons on dev machines plus cloud daemons for long-running work, and BYOK against existing Claude/Codex subscriptions. Almost every one of those has a direct analog in this design (artifacts + prev chains, turns, review verdicts with findings folded into the next bundle, operator-side spend-capped keys and model routing). Their existence is evidence the market wants exactly this shape.

Radial diverges on two architectural bets:

  • Decentralized substrate vs. central API. HumanLayer synchronizes state through the HumanLayer API — there is a privileged coordinator, and attribution and trust flow through the platform. Radial's thesis (§2, §6) is that there is no privileged coordinator: every actor is a DID, every action a signed record, and any materializer folds the same record stream into the same view. This is what lets agents from different operators participate in one space with no central server to trust, and makes attribution cryptographic and portable (signed records mirrored into commit trailers, §10) rather than platform-vouched.

  • No workflow engine vs. a fixed lifecycle. HumanLayer's QRSPI flow (Questions → Research → Design → Structure → Plan → Implement) is a fixed six-phase lifecycle baked into the product — precisely the state-machine design rejected in §14-A. Radial ships no state machine at all: "workflow" lives in human heads and in the prompt templates behind UI buttons. Extending the system is adding a registry noun and a button, not authoring a graph or shipping a new lexicon.

The cost of the decentralized bet is privacy: everything on-protocol is world-readable today (§13), a cost HumanLayer doesn't pay because it is the server holding the data. But this cost is transitional, not inherent. Membership already gives Radial the trust boundary to hang private state on (§3); when atproto's private-data mechanisms mature, permissioned spaces close the gap without touching the coordination model — the same signed-record fold runs over encrypted/permissioned records instead of public ones. The centralized architecture, by contrast, cannot become decentralized later; the decentralized one can become private. HumanLayer is the proof the centralized path ships and moves fast — the open question it sharpens is whether the no-central-trust, multi-operator property is worth carrying until permissioned data lands, which §1 answers in the affirmative for the network-as-bus goal.

18. Private mode (records off the PDS, over a private bus) #

§13's stance — everything on-protocol is world-readable — is the cost of the decentralized bet, and it is transitional rather than inherent. Private mode is the interim: a space's records stay off every PDS, travelling as signed envelopes over a private bus, folded by the unchanged materialize(). Envelopes stay lexicon-valid so a private space can be republished when atproto's permissioned data lands; this is the interim, not a replacement for §13's long-term bet.

Status. The protocol below, the fold, the envelope and admission machinery, the write path, the operator commands, the wire two replicas speak, large-body (blob) transfer, and both iroh transport bindings are implemented: packages/core (devices.ts, private/), packages/ingest (PrivateSpaceIngestor), the radial CLI's private commands, the daemon's PrivateSpaceRuntime and radiald device, packages/transport-iroh (native Node), and packages/transport-iroh-wasm (relay-only browser WASM). The browser binding is deliberately only the PeerLink seam: Rust owns authenticated relay connections and TypeScript applies core's bounded frame codec and refusal handling. The endpoint lifecycle above both bindings is in core (private/endpoint.ts) — one endpoint per runtime, frames routed to a space by wire topic, inbound connections authorized before adoption — so a browser tab and the daemon run one lifecycle rather than two; each supplies only its own binding and its own place to keep the endpoint secret. A browser holds a replica of its own in ui/src/lib/private-space.ts, binds that endpoint lazily in ui/src/lib/private-transport.ts, publishes its deviceAddress, and catches up on the same tick that polls the device directory. MemoryPrivateBus and the in-process loopbackLink continue to keep ordinary CI off the network; what no test in this repository covers is a real relayed round trip between two machines.

The two-gate rule #

Today, what a store holds is loosely bounded (ingestion polls member repos) and what counts is decided entirely by the fold. Private mode keeps that split exactly so every replica derives trust from the same record set rather than transport arrival order.

Gate 1 — admission (transport/ingestion, observer-local, cryptographic and DoS only). A receiver admits an envelope after checking, in order: protocol version; envelope field shape; space binding; canonical encodability and size caps; recordCid correctness; validateRecord(); and that the signature verifies against a device key the envelope's own did has published. That last check is the private-mode analogue of "the record was in that repo": it proves DID authorship and nothing more. Admission never consults membership. An envelope whose signing key is not yet known is quarantined, not rejected.

Gate 2 — the fold (pure, convergent). materialize() counts an envelope when its author has an active membership grant and at least one signing device has a DID-authored device binding. Both checks are order-insensitive and permutation-covered.

In a private space, an unsigned record does not count. Gate 2 would otherwise be optional: a record with no signing device has no device to gate, so anything sitting in a member's public repo would fold with no authorship check beyond membership — and membership grants are records too, forgeable the same way. A space's uri+cid are public by design (every member's join bookmark carries them), so the capability required is write access to any one member's repo: a leaked app password, or the PDS operator, which is exactly the party private mode removes from the trust path. The rule is therefore that a private space accepts from the public path only:

  • the device directory — device and deviceAddress — which is unsigned by necessity (below), and
  • join, a bookmark that confers nothing (§3) and is public so a fresh browser can list its spaces; it never creates or hydrates a private replica.

Everything else — the space's work, membership, and governance — must arrive in an envelope or it is ignored with record did not arrive in a signed envelope. Ingestion narrows the same way, polling members' repos for the directory collections alone, but the fold is where the rule lives: a store is an implementation and the fold is the contract.

The space record is the one exemption, because it declares the mode and gating it on that mode would be circular.

Private spaces deliberately have no per-device revocation. A published key speaks for its DID for as long as that DID remains a member, and everything it signed keeps counting on every replica.

What a device can lose is its place on the network. Retirement is the absence of a published address, and retireDeviceAddress produces one by rewriting the device's deviceAddress with retiredAt set. A retired device leaves connectablePeers(), is answered unknown when it dials, and — because the dial roster is re-read every round and never cached — has any link it already holds closed. It stays bound, its records stay counted, and the index and digest are unchanged: this is a connection statement, and address hints are excluded from the device fold by construction. Rotation retires the outgoing address as well as the local key, which is what makes rotating visible to anybody else.

The boundary is worth stating rather than discovering. A key stolen without the owner's repo credentials is finished on this bus — it can be neither dialled nor served. A key stolen with them republishes its own address and undoes the retirement, so the instruction is two steps: end the session at the PDS, then retire the device. And retirement never un-counts a record. For that the response is still member removal, which drops the member's whole corpus; re-adding that member restores every previously signed record, including an attacker's, so keeping the person after a stolen key requires a new space. Revocation can return later as an additive collection and fold rule coordinated by a protocol-version bump — retirement needs neither, which is why it exists at this layer.

The private envelope #

{ version, space, did, collection, rkey, record, rev, recordCid, deviceKeyId, signature }

signature is Ed25519 over a domain-separation prefix (radial-private-envelope/1\n) followed by canonical DAG-CBOR of every preceding field. recordCid is the CID of the lexicon record value under atproto's own DAG-CBOR/CIDv1 rules — the same encoder computes both, and it is pinned against CIDs real PDSes computed for real records (packages/core/test/fixtures/cid-vectors.json) — so strongrefs, version chains and reviews-pin-exact-versions keep their meaning across both buses. rev is a writer-generated TID: strictly better than the public path's rev, which is the repo head at this observer's scan time, because every observer gets the same per-author version order. Duplicate (did, collection, rkey, recordCid, deviceKeyId) envelopes are idempotent.

The write path #

RecordWriter (@radial/sidecar) is the single write seam behind runCli() for the CLI, the web app and the turn socket, and private mode substitutes an implementation of it rather than adding a path beside it: every command, and every turn, is unchanged. What a PDS did for a write, EnvelopeWriter (core/src/private/writer.ts) now does itself, and each half is a decision:

  • the rkey is a locally minted TID unless the caller pins one (space join's deterministic rkey, a claim's request-derived rkey). A create at a name this replica already holds is refused, because a PDS's RecordAlreadyExists is what space join leans on;
  • the CID is computed from the record's own canonical bytes, so a strongref written here means exactly what one written against a PDS means;
  • the commit is gate 1: a record this replica wrote runs through the same admit() a stranger's envelope does, and lands only if it passes. "Written" means "in my store" — there is no server to be the authority;
  • the publish is the bus, after the local commit and never before. A publish that fails is a delivery failure, not a write failure: the peer that missed the gossip re-requests it at catch-up.

The three sanctioned in-place rewrites keep their meaning on this path (put): a claim's renewal, an agent's republish, a deviceAddress moving networks. swapRecord pins the version the new value came from, compared against this replica's own store rather than a PDS commit — weaker across peers, because nothing serializes two writers here, and stronger for the case it is used for, which is one writer renewing its own claim.

Re-enveloping is a command rather than a doc footnote: a surviving device re-signs its own DID's stored record values under fresh envelopes, preserving each envelope's original rev so a recovery cannot reorder an author's versions. It is idempotent — envelope identity includes the signing device — and it is what makes revocation survivable.

The creation ticket #

A private space's at:// URI is a name and not a location: an invitee holding it can read nothing, and cannot verify the first envelope they are offered either. A ticket is the out-of-band bootstrap that closes exactly that gap:

radial-ticket-1.<base64url of {version, space{uri,cid}, founder, device{deviceKeyId,publicKey,algorithm}, endpointId?, relays?, peerHints, issuedAt}>

It is not a credential: possession grants no membership, which is an admin's addMember grant and only that (§3). A ticket creating a fresh replica must carry non-empty, unique peerHints for active addressed members: this is the explicit current route to poll and dial. It grants the ability to start — which space, whose repo to poll for the directory, and which key verifies the space's oldest envelopes — and the ability to notice when the answer differs from what was promised: ticketDirectoryMismatch() refuses to join when the founder's repo publishes a different key than the ticket names, since a PDS operator can serve whatever they like. That check is advisory and never enters the fold: a trust input only one observer holds would break convergence on arrival, which is the whole point of the two-gate rule.

The cid half of space is a pin, and it is enforced. The URI alone is a name anyone can claim to answer; the cid names the exact genesis record, so a replica can check that the space record it is eventually handed is the space it was invited to. acceptTicket writes the pin into the replica's space.json (spaceCid) — written down rather than checked once, because the record it pins usually arrives days later — and spacePinMismatch() compares the two every time the fold is used: catch-up stops, a local write is refused before it is sealed, and radial private status reports it rather than failing, since that is the command an operator runs after one of those refusals. Without it, a correctly signed second genesis at the same URI — founder equivocation, or an accidental second space record — would bootstrap an invitee into a different space definition than the one they were handed, with every gate passing.

Like ticketDirectoryMismatch(), this is a local bootstrap refusal and not a fold rule: it reads a ticket only this observer holds, it reports to a human, and it changes nothing about what materialize() counts. Two replicas that accepted the same ticket refuse identically; an existing founder replica has no ticket to check, while a daemon with no existing replica must be provisioned with a fresh ticket and cannot start from the URI alone.

A ticket reveals that the space exists and who founded it, so it is handled as a secret — never written to a PDS, never printed to a log. ticketFingerprint() exists so a log can name one without carrying it.

The device directory (public, additive lexicons) #

The directory is deliberately not private, and is read from the public path only: a key published inside an envelope would be a key that vouches for itself, and a fresh browser with no local state has nothing else to read.

  • device (new, immutable) — DID-authored: deviceKeyId, the envelope-signing public key (unpadded base64url of the raw 32 bytes), algorithm, kind (node/browser), optional protocol versions. Never republished: a new key is a new record under a new rkey, so history stays verifiable after rotation.

  • deviceAddress (new, mutable) — the third sanctioned in-place rewrite, named here alongside claim renewal and the agent republish (§4). Keyed to a deviceKeyId, carrying an iroh endpoint id, relay hints, and an optional retiredAt; republish-under-the-same-rkey with latest-rev-wins, safe because address hints are excluded from the device-authorization fold by construction. A stale hint can make a peer dial the wrong endpoint; it can never change which envelopes an observer counts. retiredAt withdraws the device from every replica's dial list and from what they will serve, and is reversible by publishing an address again — the owner's repo is the authority on where their own devices are, in both directions.

  • join (extended, no new collection) — the bookmark already defined in §3 gains optional founder, protocol, label and peerHints. A new collection would be a permanent duplicate lexicon for the same concept. §3's "a daemon never writes one" holds: humans write their own under their own session, and agent DIDs need none because radial.json already names the daemon's spaces.

  • space.private (additive, optional) — set only at creation. A space never changes mode; migrating an existing public space is out of scope. The flag is permanent from the commit that added it while the envelope writer is not built, so runCli() — the single write path for the CLI, the web app and the turn socket — refuses outright to publish into a space whose record carries it. Fail-closed, because the failure it guards is a PDS write nobody can take back.

Guest comments and Constellation: forced off, twice #

packages/ui/src/lib/guests.ts queries a public backlink index with goal URIs whenever a space has guest comments enabled. In a private space that would hand a public index the space's URIs and rkeys on every view. Two independent stops, both tested:

  1. Fold — in a private space setGuestComments records are ignored with a diagnostic and guestCommentsEnabled is always false.
  2. UI — the discovery effect is hard-gated on index.private, with a test asserting no request leaves even when the component is told to discover.

blessComment/retractBless are meaningless in a private space and its compose surfaces never render there.

What the public directory leaks #

A DID's public repo reveals that it uses Radial, how many devices it has, opaque space identifiers in its bookmarks, endpoint ids, relay choices and update times. peerHints additionally discloses the DIDs a member expects to be reachable, which is why it is opt-in and defaults to the founder alone. There is no anonymity claim. This must be shown to a member before they enable private mode.

The wire: what two replicas say to each other #

PrivateBus states what a transport has to do in Radial's concepts — join, publish, subscribe, catchUp, close. The wire is what it has to carry, and it is defined in core (private/wire.ts, private/sync.ts) rather than inside a transport, because none of the questions it answers is an iroh question: a transport that moves frames between two authenticated endpoints and hands each inbound one, with the remote endpoint identity it authenticated, to WirePrivateBus.accept() has implemented the protocol. accept() itself performs the required directory authorization before serving or delivering a topic-bound frame, so the transport cannot accidentally turn that check into optional glue or make the protocol mean something different in a browser tab than in the daemon.

  • One versioned ALPN (radial/private/1) and one opaque topic per space, sha256("radial-private-topic/1\n" + spaceUri), domain-separated like the envelope's signing prefix. A space URI names its founder's DID and rkey; a topic is what travels to relays and to anyone watching a mesh, so joining discloses 32 opaque bytes rather than whose space this is. It is not a secret and confers nothing — anyone holding the space URI can compute it, and topic knowledge grants no access.
  • Seven frames — hello, catch-up, envelopes, gossip, blob-request, blob, error — UTF-8 JSON, optionally length-prefixed for a byte stream (FrameReader bounds a declared length before the body is buffered, which is the one place a stranger chooses how much memory this process holds). JSON, like the archive, because a record's authenticity does not rest on how it travelled: the receiver recomputes canonical DAG-CBOR and the recordCid and checks the signature over those bytes. Decoding is deliberately shallow — version, kind, topic, counts, sizes — and every record-level check stays in admit(), so there is one place an envelope is judged rather than two, one of which would drift into being the lenient one.
  • Catch-up is bounded and resumable. A responder serves its corpus in envelopeKey order, minus what the asker's summaries cover, plus what the asker's wants name explicitly, capped by count and bytes, with a cursor when it stopped at a cap. Envelope identity is stable and a corpus only grows, so "everything after this key" names the same suffix on every page and no snapshot is held per asker; an envelope landing behind the cursor mid-round is picked up by the next round's fresh summaries. An envelope larger than the byte cap is still handed over — a responder that skipped it would leave a peer asking for it forever. The asker bounds its side too: a fixed number of pages per peer, because the cursor is the peer's claim that there is more.
  • A publish that fails is recorded, not thrown. One unreachable peer must not fail a goal create; the peer that missed the gossip re-requests it at catch-up. Per-peer delivery, receipt and error counters are diagnostics and never trust.
  • Fresh replicas use an explicit transfer ticket. It carries non-empty, unique peer hints for active addressed members. Wire frames do not claim identities and unsolicited connections never trigger public repo reads (ADR §31).

Connection authorization (private/peers.ts) is about connections, not the index. Before serving catch-up, a receiver requires the authenticated endpoint to be named by a published device's deviceAddress that its owner has not retired. Otherwise anyone who computed the topic could enumerate the corpus. This is neither membership nor a fold gate: every envelope still passes admission and materialization. A retired endpoint is refused as unknown rather than with a status of its own. An inbound connection no attached space names closes immediately.

Which connections exist is protocol rather than transport (private/connections.ts). A transport dials an address and returns an authenticated link; core decides everything around it. The dial list is re-read every round so new devices and changed addresses are observed. Hints order but never filter it, local endpoints are skipped, duplicate addresses share one link, unreachable endpoints back off, and failed links are redialled. None of this is a gate.

The endpoint: where a machine actually is #

A frame needs somewhere to arrive. radiald run binds one endpoint for the whole daemon (daemon/src/private-transport.ts, over the native binding in @radial/transport-iroh) and serves every configured private space over it, routed by the wire topic: a deviceAddress is a fact about a device and names one endpoint id, so an endpoint per space would give one record several answers and make two spaces rewrite each other's forever.

  • The identity persists. The endpoint's secret is kept in <dataDir>/private/endpoint.key, 0600, beside the device signing key, because possession of it is that endpoint. A daemon that minted a fresh one per start would publish a new address per start, cost every peer a re-read, and strand every ticket that carried the old id.
  • The address is published, and only when it changed. Startup writes each identity's deviceAddress (the third sanctioned in-place rewrite, under the device key's own rkey) naming the live endpoint id and relay hints. An unchanged hint writes nothing at all, so an ordinary restart is silent on every PDS. A device the directory does not name is one nobody dials — an unpublished daemon can accept connections it will never be offered.
  • Startup order is the argument: publish the signing key before binding an endpoint (a replica refuses its own envelope if it cannot verify it), bind before publishing an address (there is nothing to publish otherwise), publish before opening a replica, and poll the directory before checking the ticket against it.
  • An accepted connection is authorized before it is adopted, against the same directory and once per attached space, because a peer in one space is a stranger in the next. Serving a frame is authorized separately and always (accept()); adoption is what lets a replica that can only dial out be gossiped to.

A machine holds one replica of a space, and radiald and the radial CLI both hold that one: the same <dataDir>/private/<space>/ directory and the same space.json, whose layout lives in @radial/core/node so the two cannot disagree. The CLI deliberately binds no endpoint of its own — a second one would be a second replica on one machine — so it writes into the replica and the daemon carries those envelopes to peers. 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.

Large bodies: the bytes a record names #

Every other record in a private space fits inside its own envelope. A picture does not: image.blob is capped at ten megabytes by the lexicon and an envelope at half a megabyte by admission, because an envelope is the thing a stranger can make a peer hold. So bytes travel separately (private/blobs.ts), and the question that matters is what makes them trustworthy when they arrive carrying no signature of their own.

Content addressing is the authentication, and it is end to end. A blob's name is the CID of its bytes — CIDv1, raw codec, sha2-256, which is exactly what com.atproto.repo.uploadBlob returns for the same bytes. The record naming it is signed inside an envelope, and the envelope's recordCid covers that record. A receiver recomputes the CID of whatever it is handed and stores it only under that computed name, so substituting one blob for another lands under a name nothing references. There is nothing to trust a peer about, which is why blob transfer carries no second signature and must not grow one.

That the CID rule is atproto's is what keeps the migration obligation true for pictures as well as prose: a private image record is byte-identical to the record a public upload would have produced, so republishing a space means re-uploading bytes rather than rewriting records.

  • Blobs are pulled, never pushed. A replica asks only for the CIDs the records it has already admitted name, so no peer can deliver bytes nothing references. An unsolicited blob frame is refused exactly as an unsolicited envelopes frame is.
  • Transfer is chunked and bounded. blob-request carries a CID and an offset; blob answers with one chunk, its offset, and the blob's full length. Every bound in the fetch loop comes from something already signed or already known: the total is checked against the size the record declared, the offset must be the one asked for, and the assembled bytes are hashed against the name before they count. A peer can waste time and nothing else.
  • There is no blob want list, deliberately. An evicted envelope leaves no trace, which is why the want list exists; a missing blob is always named by a signed record this replica holds, so what is outstanding is derived from the store on every cycle. It cannot go stale, cannot be lost by a restart, and cannot disagree with the corpus it is computed from. A blob nobody answers for stays visible (radial private blob list, radial private status) and is asked for again next cycle.
  • The write path is the ordinary one. radial private blob add is the private counterpart of uploadBlob — it stores bytes in this replica and prints the reference — and radial image create writes the same record it writes on the public path, refusing a blob whose bytes this replica does not hold. A record naming bytes nobody has is a broken picture no sync can repair, and there is no PDS to fall back on.

Bounds, and the want list #

Quarantine is bounded by count and bytes, and eviction is never silent loss: no PDS holds a re-fetchable copy, 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 version summaries, and outstanding wants are surfaced as diagnostics. A burst costs a re-fetch, never a silent permanent fork.

The held set is memory-only, and that is deliberate rather than missing. A quarantined envelope was never admitted, so it is absent from this replica's summaries and the next catch-up asks for it again as a matter of course; a restart loses nothing a peer will not re-offer. The want list exists for the case that argument does not cover — an envelope this replica evicted while its peers believe it landed, which no summary diff would ever surface.

Both bounds measure the envelope, not the record inside it, and admission caps the envelope's own fields (did, deviceKeyId, signature, recordCid) in its cheap structural pass. Quarantine is the retaining path, so an unbounded field around a small record would otherwise be a quota that counts the wrong bytes.

Keys, replicas, and the operator commands #

A device's signing key lives beside the instance's sessions, in the data directory, 0600 and written through a temporary file (FileDeviceKeyStore, @radial/core/node). A browser will generate a non-extractable key instead and keep the handle in IndexedDB, so the material never exists in the page; a daemon has to survive a restart with no human present, so its key is extractable and the file is the thing to protect. A device key id is derived from the key (<kind>-<16 chars of base64url(sha256(publicKey))>), which makes publishing idempotent and makes a renamed binding impossible.

A replica of a private space is a directory named after the space (<dataDir>/private/<founder-did>~<rkey>/) holding records.db (with the envelopes, in the same transaction), wants.db, sync.db, and a space.json naming the founder and the peer hints. It is greppable rather than hashed on purpose: this directory is the space, and the backup advice below is unfollowable if nobody can tell which directory is which.

A browser holds the same replica in the space's IndexedDB database (packages/ui/src/lib/private-store.ts, assembled by private-space.ts, whose meta row is that database's space.json), with one rule the public path does not have: a private space is not a cache. Every other row a tab stores is a copy of a record some PDS still serves, so a refused write costs a cold scan; an envelope is the only copy there is, so a private replica refuses to open at all in a browser that will not persist (private browsing, blocked storage), reports a write it could not make durable instead of swallowing it, and writes an envelope and its projection in one IndexedDB transaction — the browser's version of the single SQLite connection above, for the same reason.

Every operation in the table below that is about a space rather than about a machine is also in the web app, running the same sequence over that IndexedDB replica: founding a private space, minting an invitation to one, accepting one, rotating this browser's key, and reading what the replica holds. The disclosure above gates the first and the third, since both publish records into the member's own repo. What stays on the command line is what is about a machine — private export/import, private list, and the daemon's own identities.

The commands, and which side they belong to:

radial space create --private mints the founder's device if needed, opens a replica, seals the space and the seeded artifact types into it, and writes the founder's public join bookmark; it prints the space URI, and a ticket is minted later once a live address exists
radial ticket mint / radial ticket accept the invitation, and joining from it: publish this device's key, write your own join, open a replica. On a daemon-owned replica, use the same data directory: start radiald long enough to publish and catch up, stop it completely before the CLI mint, then restart it before the recipient syncs; the CLI and daemon must not overlap on the replica
radial private list / status / sync what this machine holds and what it cannot reach
radial private export / import the backup, and the restore — see below
radial device list / rotate / retire this identity's keys; rotation is fold-neutral hygiene, and retiring withdraws a device's published address so no replica connects to it
radiald device list / publish / rotate / retire the same, for the daemon's agent identities
run.privateSpaces in radial.json which spaces the daemon serves privately, and the ticket to bootstrap each from

Every ordinary write command (goal create, request create, artifact post, …) is unchanged: the CLI matches a command's at:// arguments against the replicas this machine holds and routes the whole command through the envelope writer when one matches. Ambiguity is an error rather than a guess.

Backups #

There is no central durable copy. A replica's SQLite (or a browser's IndexedDB) is the backup. Important spaces should be replicated across more than one always-on node, and a restore should be drilled before a space is trusted with work.

radial private export writes that replica out as an archive: a header line, one signed envelope per line, then one blob per line (JSON Lines), whole-corpus, deduplicated and ordered by identity, so re-exporting an unchanged replica produces the same file. radial private import reads one back. It is also how a replica moves to another machine without a peer in between.

The blobs are in it, and have to be, for the same reason the envelopes are: no PDS holds the bytes either. An archive that carried the record for a picture and not the picture would be a backup that restores a broken image and says nothing. They are no more a trust input than the envelopes: a blob line is bytes, stored under the name they hash to, so an edited one lands under a name no record mentions — or fails outright, and is named in the import report.

The archive is a bag of envelopes, and never a trust input. Importing offers every envelope to the same admit() a peer's would pass — space binding, recomputed recordCid, signature under a key the author's public repo published — so a file somebody edited cannot land a record that a peer could not have landed, and the failure is a named rejection in the import report rather than a corpus one record different from everybody else's. There is deliberately no archive-level signature: it would be a second, weaker authorship proof beside the per-envelope one, and the weaker one always becomes the one that matters.

Three things it therefore does not carry, each for its own reason:

  • The device directory. It is public, read from members' repos and nowhere else, and a key that travelled inside a backup would be a key vouching for itself — in the one artefact an attacker would most like to hand you. A restore polls the directory like any replica; until it can, the corpus quarantines with wants recorded and the report says so, and re-importing the same file once the directory is readable completes it.
  • The signing key. A different secret in a different file (FileDeviceKeyStore), with different handling. A restored replica reads a space and cannot write into it until it mints and publishes a device.
  • The bootstrap. Which genesis record is the space is a ticket's job, and a ticket arrives out of band: restoring onto a machine that holds no replica takes --ticket, and the header's spaceCid is compared and reported, never adopted. Importing does not write a public join either — restoring a replica is not joining a space.

What it does carry beyond the envelopes and the blobs is the exporting replica's want list, because dropping it would put silent loss back where the want list removed it from: a restore that forgot what the replica knew it had lost would look complete and never ask again. A want the archive's own envelopes satisfy is cleared as they land.

An archive is the corpus in the clear, and more portable than the store it came from. --out writes 0600 through a temporary file; where it goes next is the operator's problem, and the docs say so rather than implying an archive is safe to leave anywhere.