A collaborative coding-agent orchestrator for atproto radl.app
radial docs plan.md
26 kB

Radial — Phased Implementation Plan #

Companion to design.md. Each phase ends with something runnable and an explicit exit criterion; later phases assume earlier ones are done. Phases are ordered to retire the riskiest bets first. In the artifact model the materializer is nearly a pure index (design §6), so it is no longer the scariest component — the riskiest bets are now the atproto record plumbing itself and the request → container → sidecar → artifact loop. Claims exist only for open (unassigned) requests, which lets all lease/arbitration machinery move to the final multi-operator phase; every earlier phase uses assigned requests and needs none of it.

Stack assumption: TypeScript throughout (monorepo). atproto's lexicon tooling, @atproto/api, and Jetstream clients are TS-native, and it lets the daemon, sidecar, UI, and shared core reuse one implementation of the materializer. Revisit only if container/daemon performance demands it (it won't at tens-of-DIDs scale). The web UI is SvelteKit — it stays inside the TS monorepo (so core/ingest import cleanly), and its server routes give the UI's materializer a natural home without standing up a separate view server.

packages/
  lexicons/     # com.disnetdev.radial.* lexicon JSON + generated types
  core/         # records, validation, index/materializer, trigger rules — pure, no I/O
  ingest/       # repo polling + Jetstream → record store
  daemon/       # radiald: request fulfillment, container launch, check runner, forge adapter
  sidecar/      # radial CLI (runs inside agent containers)
  ui/           # web UI (SvelteKit)

Phase 0 — Skeleton and lexicons #

Decisions on paper become schemas. No behavior yet.

  • Monorepo scaffolding (pnpm workspaces or similar), CI, lint/test harness.
  • Author lexicon JSON for all com.disnetdev.radial.* records in design §4, including exact membership grants with grant-targeted, retain-nothing removal (§3), the type-level scope: goal|project, goal-or-project references, review/request backrefs, message anchors, and blob overflow for large artifact bodies.
  • Codegen typed record interfaces + runtime validators into core.
  • Author the built-in plan and implementation artifact-type registry entries (brief templates + output specs) as fixtures — the registry is data (§4), so the built-ins are seed records, not code.

Exit: pnpm test validates fixture records for every lexicon; invalid fixtures fail.

Phase 1 — Core: the materializer index #

The shared view logic (design §6), built as a pure library before any network exists. It's an index, not a state machine — but it's still the thing two independent parties must compute identically, so it still gets the strongest tests in the repo.

  • Record store abstraction: (did, collection, rkey) → record, in-memory + SQLite.
  • Trust computation: space root → exact admin/member grants → addMember/removeMember log folded in creation order → records trusted iff their author holds an active grant. Removal revokes one exact grant and retains nothing; re-adding restores trust in the member's records.
  • Immutability handling: keep the earliest repo revision observed for every record, independent of arrival order; a CID change on the same record is flagged as an edit annotation and never silently adopted (goal closure and claim lease renewal are the sanctioned owner-edit exceptions) (design §4).
  • The index itself: group artifacts under goals — project-scoped requests and artifacts group under the project instead (§8) — attach reviews/checkruns to subjects by strongref, match fulfillment by the request backref on artifacts and reviews, chain versions by prev, compute the open-request set (no fulfilling artifact/review, no declining message — re + declines — from the assignee) and the current system-artifact set (newest version of each project-scoped chain).
  • Claim tie-break for open requests (earliest createdAt, then lowest record URI — stable across lease renewals) — implemented and tested here, unused until Phase 7.
  • Test strategy is the deliverable: golden scenarios cover pinned versions, edits, removal ignoring the removed member's records, re-add restoring them, competing claims, and cross-goal system artifacts, plus a property test that every arrival permutation yields the same index.

Exit: property test passes across shuffled ingestion orders for every golden scenario; a radial-debug index <goal> CLI prints the materialized view (artifact timeline, version chains, open requests) from a directory of record fixtures.

Phase 2 — On the wire: identities, ingestion, and a human-only loop #

Connect the index to real atproto. Prove the coordination layer end-to-end with humans doing every role — no agents, no containers — because that isolates protocol bugs from harness bugs.

  • Agent identity registration: operators create agent accounts elsewhere (any PDS); radiald init never mints DIDs. It registers existing identities with the daemon — authenticating via app password or OAuth — stores the session credentials, and publishes each agent's com.disnetdev.radial.agent profile.
  • Record writing: signed record creation into an actor's repo via PDS.
  • Ingestion v1: poll member repos (listRecords) on an interval into the record store; per-author ordering as designed (§5). Jetstream deferred to Phase 7.
  • A thin CLI (radial) usable by a human: create space, add members, create project/goal, post an artifact request, fulfill it with an artifact, post a review, request and post a v2. This CLI later becomes the sidecar's foundation.
  • Two materializer instances pointed at the same space converge to identical views.

Exit: two humans (two DIDs, two PDSes) run a full artifact cycle — request → plan artifact → request_changes review → v2 request → v2 artifact → approving review — entirely through CLI-written records, with the daemon's index and a second independent materializer agreeing at every step.

Phase 3 — First agent turn: request fulfillment + container + sidecar #

The turn model (design §9), for one built-in type only: plan, with the request naming its assignee. Narrowest possible slice that exercises request → container → sidecar → artifact → fulfilled-in-the-fold. No claims, no leases — assigned requests don't race.

  • Daemon fulfillment loop: index shows an open request assigned to one of its agents → launch container.
  • Container runtime (Docker first): mount the goal bundle (goal, basedOn artifacts + ancestry, open findings, current system-artifact versions (§8) — an empty set until Phase 5 — thread history as JSON/markdown), git checkout, scoped credentials; egress limited to git remote, model APIs, sidecar. (Superseded in Phase 4.5: open egress, possession-based containment — §13.)
  • Brief composition: the type's registry template + the request's brief.
  • Sidecar radial artifact submit: a thin client over the daemon's local socket — the daemon validates that the emitted record matches the requested type, signs, and writes to the agent's repo; atproto credentials never enter the container (§13). The sidecar socket is the only write path from harness to protocol.
  • One harness adapter (Claude Code).
  • Can't-complete path (§9): the turn posts a question message and exits without an artifact; the request stays open and is flagged awaiting input.
  • Crash handling for assigned requests: container dies → daemon retries; partial work is visible on the branch.

Exit: human writes a goal and a plan request via CLI; daemon dispatches a turn that reads the repo and posts a real plan artifact; the request shows fulfilled in both materializers; a deliberately killed container leaves the request open and retryable.

Phase 4 — Implementation artifacts: forge adapter and checks #

Close the loop through code (design §10).

  • ForgeAdapter interface + GitHub implementation (openPullRequest, updatePullRequest, getPullRequestState, compareUrl). Daemon-side credentials only. (Superseded in Phase 4.5: turns act on the forge directly under the operator's forge auth; the adapter narrows to observation.)
  • Implementation turns: branch, commit, push, open/update PR, post the artifact with links: {branch, commit, pr}; v2 turns consume structured review findings from the bundle. A v2 continues its predecessor's branch and PR when the daemon can establish the PR is still live work — confirmed against the forge where an adapter is wired, and otherwise from the index plus a git ls-remote (an in-namespace branch, a PR in this project's repository, no merge annotation on the predecessor, and the branch still on the remote), with the daemon logging which signal decided it. The index fallback exists because run.forge is easy to omit and its absence silently produced a second PR for every revision; the ls-remote is there because the merge check cannot fire without a forge (nothing writes the annotations), and a deleted predecessor branch would otherwise make a v2 crash-loop instead of falling back to a fresh one. A continued branch is cloned deep with its base fetched, since a --depth 1 checkout cannot merge at all.
  • Check runner: on an implementation artifact landing, run project checks against the linked commit in a clean container, post checkrun. Results annotate the artifact — nothing bounces automatically (§10).
  • Merge observation: poll PR state; on merge, the daemon annotates the artifact as merged. Radial never merges; goal closure stays a human edit.
  • Thin UI spike (deliberately minimal, replaced wholesale in Phase 6): a single page over the daemon's index — goal timeline plus registry-generated request buttons. The buttons-as-workflow interaction is the product's core bet, so it should meet real use before the remaining infrastructure phases finish.

Exit: goal → plan request → plan artifact → human approval review → implementation request (basedOn the plan) → PR + green checkrun → human merges on GitHub → artifact shows merged — with every generation behind an explicit human request.

Phase 4.5 — Operator forge auth and the possession boundary #

Rework of the Phase 3/4 credential and network stance (design §10 and §13, revised): turns act on the forge directly under the operator's own forge auth, and the egress allowlist is removed in favor of possession-based containment. The atproto invariant — protocol credentials never enter containers, the sidecar is the only protocol write path — is untouched.

  • Operator's own GitHub auth (§10): radiald init reuses an existing gh CLI login or runs GitHub's OAuth device flow once; the daemon injects the operator's token as GH_TOKEN and turns push/open PRs as the operator's account. No App, no key management. Operators who want scoping can hand radiald init a repo-scoped fine-grained PAT instead. The hardened GitHub App tier (per-turn ~1 h installation tokens via the manifest flow) is deferred to post-v1.
  • Attribution in content: the commit author / Co-Authored-By: trailer names the agent and its DID, and the PR-body footer links the artifact record. On-protocol records remain the authoritative attribution.
  • Turns write to the forge directly: implementation turns push and open/update PRs themselves (git + gh baked into the agent container image); the daemon-side push path in finish.ts and the openPullRequest/updatePullRequest adapter ops are removed. The artifact still carries links: {branch, commit, pr} via sidecar submit — the turn reports the URLs it created.
  • ForgeAdapter narrows to observation: getPullRequestState + compareUrl; merge polling and checkrun annotation are unchanged. Phase 5's optional finding-mirroring returns as a daemon-side write op when it lands.
  • Open egress: delete the proxy/internal-network machinery (egress.ts and its wiring); turn containers run on an ordinary bridge network. Keep host isolation: never host network, the sidecar socket is the only daemon-facing surface, metadata endpoints blocked on cloud deployments.
  • Check containers get network (dependency installs need it) but keep their deliberately empty env — no secrets of any kind.
  • Spend-capped model key: turn containers get a dedicated, spend-capped model API key (workspace-scoped), documented as operator config. A daemon-side model proxy (per-turn metering, key never in-container) stays deferred.
  • Forge recon works with zero daemon support: gh in the container covers issues, PR discussions, CI logs, and code search.

Exit: the Phase 4 walkthrough repeated with the PR opened from inside the container under the operator's auth, agent attribution visible in the commit trailer and PR footer; a turn demonstrates forge recon (gh pr view/gh api) and an arbitrary web fetch; no references to the egress proxy machinery remain.

Phase 5 — Review turns, auto-review, custom and system types #

The review machinery and the registry earning its keep (design §7, §4, §8).

  • Review turns: an artifactRequest with type: "review" and a subject dispatches a reviewer turn whose terminal record is a review verdict with structured findings, referencing the request it fulfills. The reviewer may be the same agent DID as the author — review value comes from the fresh per-turn context and operator-routed model diversity (design §7, §11).
  • The auto-review trigger: per-type per-project config (overridable per request); on a matching artifact landing, the daemon writes the review request under one of its agent identities (design §3, §7). One hop, terminal — verify a verdict never spawns further work.
  • Soft UI/CLI policy: warn when requesting an implementation whose basis plan lacks an approving verdict (annotate-not-gate, §7).- Custom artifact type end-to-end: a space admin adds a security-review registry record via CLI; a request for it dispatches a turn with that brief — zero code changes. This proves the registry-as-data design claim.
  • System artifact types end-to-end (§8): the admin adds project-scoped adr and architecture registry records the same way. Direct project-scoped requests work, and so do capture requests — a project-scoped request with basedOn pointing at a goal artifact, whose bundle the daemon assembles from the source artifact's goal, and whose turn distills rather than copies. From here on, current system-artifact versions actually populate every goal bundle (the slot Phase 3 built).
  • Optional: mirror findings as PR comments through the forge adapter.

Exit: with auto-review on for plan, a submitted plan gets an unprompted agent verdict; a deliberately flawed plan collects request_changes findings that flow into the human-requested v2 turn; a custom security-review type works from registry record to fulfilled artifact with no deploy; a capture request distills a goal's plan into an ADR whose current version then shows up in the next turn's bundle.

Phase 6 — Web UI #

Deferred until here deliberately: every capability already exists via CLI, so the UI is a pure materializer + record writer with no novel protocol logic (design §2). But in the artifact model the UI carries more of the product than before — the request buttons are the workflow — so this phase is where Radial starts feeling like itself.

Detailed breakdown in phase6-ui-plan.md; the approved comp is mock/phase6-goals.html.

  • A pure SPA, no view server (revised): a static SvelteKit build in packages/ui that runs core's fold in the browser over an IndexedDB-backed record store, reusing core/ingest unchanged. §2 lists the UI as browser-plus-view-server or local-first; local-first is the one that keeps the "no privileged coordinator" claim (§12) honest, since a view server would be the only place the fold actually runs.
  • Auth with the human's own atproto account via browser OAuth; writes go straight to their PDS through the same runCli command layer the CLI uses. No server session, no CSRF surface, no daemon dependency.
  • Space/project/goal creation and editing; goal detail as an artifact timeline: cards with version chains, verdicts, checkruns, provenance links.
  • Request buttons generated from the registry: each artifact type renders a button; pressing it on a card writes the request with basedOn filled from context (§7). Review button on every card. Auto-review toggles in project settings.
  • Soft-policy warnings surfaced at the button (unapproved basis plan, red checkrun).
  • System section (§8): current versions of the project's system artifacts, with project-level request buttons ("update architecture doc", "new ADR"), capture buttons on goal artifact cards and threads ("capture as ADR", "fold into architecture doc"), and the staleness badge for living documents ("N implementations merged since v3") whose obvious click is request-update.
  • Review queue: open review requests assigned to me; approve / request-changes with structured findings; PR links for diff reading (never a native diff view, §10).
  • Conversation threads with mentions; awaiting-input requests surfaced prominently.
  • Realtime-ish freshness via the same ingestion package (polling is fine).
  • Pre-work the comp exposed (all before any UI code): the daemon stamps prev for every artifact type, not just implementations — without it a plan v2 or an architecture v2 lands as a new chain and System lists it twice, which is a live Phase 5 defect; agent records reach the index (the assignee picker needs them); goals can be closed (GoalRecord.closed has no command); findings can be posted without a file; and the request-plus-artifact-plus-version-chain "unit" the UI is built around lands in core as a tested pure function.

Exit: the Phase 5 walkthrough repeated with the human never leaving the browser except to review/merge the PR on GitHub — including adding a custom artifact type and watching its button appear, and capturing a goal artifact into the System section from its card.

Phase 6.5 — Replies: the thread goes both ways #

The thread was one-directional. A human wrote into it, every turn read it, and the only thing an agent could write back was a can't-complete question — which ends its turn and parks its request. Two changes, and the first is a defect fix rather than a feature.

  • A reply is a message with parent — the field both fold implementations have read since Phase 1 to decide a question has been answered, and which nothing in the product wrote. Until now the Awaiting-input list promised "answer in the thread and it can be picked up again" and answering in the thread did nothing at all: every request blocked on an agent's question was blocked forever. message post gains --parent (with root derived, never passed) and --mention; the question block and every thread row get a Reply action.
  • An answer turn (design §9, §10): a typed, human-authored artifactRequest naming one message version as its subject, whose terminal record is a reply in that thread rather than an artifact. Built into the daemon beside review — not registry data, never a unit, never a fraction of the goal's pie — with a read-only checkout, no forge token, and a deterministic rkey so a retried turn cannot double-post into a thread a human reads.
  • radial message list, because both of those writes take a message's pinned uri#cid and the only place to read one was the browser. A read command like type list: it materializes the target's space and prints the thread — locator, author, time, flags, first line — one row per message, with --unanswered narrowing to the questions a request is parked on. The exchange itself is core's threadRows, the same fold the UI draws, so the terminal and the browser cannot disagree about what answers what.
  • The invariant that keeps it out of §14's alternative C: the daemon authors no answer request. A reply commissions nothing, so a conversation cannot sustain itself without a person writing a record for each turn of it.
  • Enabling it costs nothing on a live space and changes one thing that will surprise an operator: every request parked in awaiting_input since the product shipped becomes dispatchable the moment somebody replies to it. radiald turn list --state awaiting_input shows what is parked before you turn it on.
  • Owed after it lands: an architecture (and glossary) request. The system artifacts are on-protocol records, not files in this repo, so this phase cannot amend them — but it contradicts two of them. Architecture §10's boundary 7 still reads "The registry is data; only review is code" and names two documented exceptions to type-name branching; there are three now (review, implementation, answer). Its §8 table of what a container holds says nothing about the answer turn's no-token, read-only row. Glossary §6 (Turns) has no entry for the third turn type. Those artifacts ride in every bundle as the agents' long-term memory, so until the request lands the next turn reads a rule this phase broke.

Exit: a human asks an agent about a landed plan from the thread, the agent's reply appears nested under the question with the "asked to reply" row gone, replying again dispatches nothing — and, separately, a plan request whose turn asked a question is answered from the browser and redispatches on the next tick.

Phase 7 — Multi-operator hardening #

What makes it a network rather than a single-operator tool (design §11, §12). Claims and leases finally enter the picture, because open requests only matter when more than one daemon might answer them.

  • Open requests + claims: requests without an assignee; daemon writes a claim (lease, heartbeat-renewed), confirms it won the tie-break after an ingestion cycle, releases on loss. Verify wasted work stays bounded to one ingestion cycle under real latency; tune lease/heartbeat/poll intervals.
  • Jetstream subscription with polling as fallback/backfill.
  • Operator routing config: artifact type → provider/model assignment (§11); an agent only accepts requests for types it has an assignment for. (The second harness adapter that was to prove pluggability — pi, alongside Claude Code — landed early, per profile and fail-closed, along with the generalized provider credentials it needed; profiles already express per-type provider/model routing by splitting capability sets, so what remains here is the config-level table.)
  • Failure drills from §12 as integration tests: crash mid-turn, competing claims, non-member and removed-member record injection, stale-grant removals, and materializer divergence detection.
  • Operator onboarding docs; cut a versioned release of lexicons + daemon + sidecar.

Exit: two independent operators' daemons, different model providers, collaborate on one space's goals — open review requests fulfilled cross-operator by whichever daemon claims first — with no shared infrastructure beyond atproto.


Cross-cutting rules (all phases) #

  • The index is sacred. Any new record type or index rule lands in core with permutation-property coverage before the daemon or UI consumes it.
  • Humans commission all work — by convention. The only path to a turn is an artifactRequest, and requests are only written on a human's click (plus the daemon's single-hop auto-review); this is UI/daemon behavior, not an index rule — the protocol doesn't police request authorship (design §3).
  • Secrets never touch the protocol (§13) — enforced from Phase 3's first container, not retrofitted.
  • Turn restrictions are structural, not advisory: validation and signing live daemon-side behind the sidecar socket, protocol credentials never enter the container (the only in-container secrets are the operator's forge token and a spend-capped model key, §13), and a turn physically cannot emit anything but its requested type.
  • Lexicon changes after Phase 2 are additive-only; records already live in user repos.

Forge adapters beyond GitHub #

Landed: the forge is a registry selected per project by gitUrl host, and tangled is the second adapter — merge observation through Bobbin, tangled's public read-only appview (with a direct sh.tangled.repo.pull.status fold off PDSes as the fallback), ssh push credentials per turn, and daemon-brokered pull creation because tangled ships no CLI a container could use. Design §10 has the mapping table; docs/adr-tangled-forge.md records the decisions and what remains unverified without a live knot.

Deferred / consciously out of scope #

  • Trigger rules beyond auto-review — auto-revise-once, auto-implement-on-approval, architecture-update-on-merge — and their iteration bounds (§7, §8, §16.2). The substrate is built for them; v1 ships only the terminal hop. Living-document staleness stays a UI badge, never a trigger.
  • In-repo docs/ mirror of system artifacts (§8) — on-protocol records are authoritative for v1; a mirror commit is a later nicety for non-Radial agents and repo-browsing humans.
  • Hardened forge auth tier (§10, §13): one manifest-created GitHub App per operator (radiald init --github-app) minting short-lived per-turn installation tokens. v1 runs on the operator's own GitHub auth; the App is the upgrade for shared or long-running deployments.
  • Private/permissioned spaces (§13 — waits on atproto private state).
  • Economic settlement and open-request negotiation between spaces and operators (§16.1).
  • Forge webhooks (§16.3) — start with polling; add per-forge webhooks only if merge-observation latency hurts in practice.
  • Tangled spindles (its own CI). Radial's check runner keeps running the project's checks in its own container; the two are not merged (see adr-tangled-forge.md).
  • Per-goal spend budgets and on-protocol cost records — post-v1; v1's spend control is the human click behind every generation (design §9).