A collaborative coding-agent orchestrator for atproto radl.app
radial docs design.md
74 kB · 353 lines

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
                                                                                 LEGACY: read forever, never written
                                                                                 (superseded by closeGoal, §7)
com.disnetdev.radial.closeGoal      {goal, closed,                             — a goal's one ending; author = any
                                     disposition?, createdAt}                    active member, reversible (§7)
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"
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 that review request automatically the moment a matching artifact lands, authored by one of its agent identities (§3). The reviewer needs no distinct identity — the same agent DID may 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.

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), 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) 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.
  • 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.

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.

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)
  • 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.