A collaborative coding-agent orchestrator for atproto radl.app
README.md

Radial lexicons #

Goal endings #

New clients end and reopen goals with com.disnetdev.radial.closeGoal. Its disposition is a free-form string (maximum 32 characters) with the currently documented vocabulary completed, dropped, superseded, and parked. It is intentionally not a lexicon enum: older materializers must still recognize an ending when a newer writer introduces another disposition. The disposition is annotation only; closed is the state.

com.disnetdev.radial.archiveGoal and the legacy goal.closed field remain readable for compatibility, but new writers must not write them. How a second implementation must read them, since they are two independent legacy dimensions rather than one:

  • The latest trusted record across closeGoal and archiveGoal (by createdAt, then URI) wins, and a goal is matched by URI rather than uri#cid — a legacy closed edit minted a second CID, and an ending has to survive that.
  • A winning closeGoal decides the state outright, in either direction: it is the one axis, so it reopens a legacy-closed goal without any rewrite of the goal record.
  • A winning archiveGoal can only ADD its park. archived: false does not cancel a goal.closed: true underneath it: the writer of either record knew nothing about the other, so a member who archived and then changed their mind has said nothing about a closure. A goal carrying both reads as completed — the ending that actually happened.
  • A closeGoal with closed: true reports disposition as written (unknown words included) and the record's createdAt as the ending instant. A legacy closure reports completed and no instant; a legacy park reports parked and the archive record's instant.

Radial records use the permanent com.disnetdev.radial.* namespace. The JSON files in lexicons/ are the source of truth. pnpm codegen generates typed record definitions and embedded runtime schemas in packages/core.

Each addMember record is an exact membership grant. removeMember revokes one grant by strongref (membership) and retains nothing: materializers ignore every record authored by a DID with no active grant. Re-adding a member is a new grant that restores trust in their records. Repository revisions are not used as record provenance.

Artifact records always carry a short body. When the full content would overflow an atproto record, body is the summary and bodyBlob points to the complete body.

An artifact's title is optional on the wire and required of a writer #

com.disnetdev.radial.artifact carries an optional title: a short phrase (at most 200 characters) naming the artifact wherever it is listed rather than read — a row, a chip, a bundle's "System artifacts" section. It is not in required, and it must not become required. Records signed before the field existed cannot be rewritten, so a required title would fail validation for all of them, and a conforming materializer must ignore a record that fails validation — which would read the requests they answered as still open and keep dispatching them.

The obligation therefore lives in the writers, and both of Radial's have it: the sidecar's radial artifact post and the daemon's turn socket each require a --title, trim it, and refuse a blank one or one over 200 characters rather than writing a record every materializer would drop. So:

  • a record without title is valid, and stays valid forever;
  • a record with title carries a non-blank, trimmed value within the ceiling;
  • a reader shows the title where there is one and may fall back to something derived from the body where there is not. That fallback is display-only: it is not protocol data, and nothing may persist it back into a record.

Nothing infers a title from the body's Markdown at write time. An explicit value chosen by whoever wrote the artifact is what keeps the signed field deliberate, and keeps two implementations from disagreeing about what a record is called.

An agent's scopes overrides artifactTypes per space, and a writer owes both #

com.disnetdev.radial.agent carries an optional scopes: a list of {space, artifactTypes} saying what this profile accepts in one space (design §11). It is not in required and must not become required, for the title reason above — every agent record already in a repo was signed without it, and a materializer that dropped them would show a space with no agents at all.

The rule is override, not intersection, and the empty list is meaningful:

  • in space S, the effective types are the entry naming S if one exists, else the top-level artifactTypes;
  • an entry with artifactTypes: [] says "this profile does nothing here" — the one thing artifactTypes alone cannot say, since it must be non-empty;
  • entries naming other spaces are inert, and a space with no entry gets the top-level list, which is exactly how every record written before scoping existed behaves.

Readers must go through one resolver rather than re-spelling that rule: agentTypesFor(record, spaceUri) in @radial/core, beside the record helpers.

The obligations on a writer are three, and they exist because an old reader degrades visibly rather than safely (a narrowed scope makes an old assignee menu over-offer, and the request it assigns then sits open):

  • artifactTypes stays truthful on its own. It is what every reader that does not know about scopes will show, so it is the profile's global answer and not a placeholder.
  • scopes is sorted — by space, and by type within each entry — and so is artifactTypes when a writer republishes both. The record is compared to decide whether to republish at all (radiald init --update, and the daemon's per-tick reconcile in a private space); an unstable order there is a record rewritten forever, kept in every revision by every peer.
  • the field is omitted, never written empty. A profile that scopes nothing must go on producing byte-for-byte the record it always produced.

Private spaces take no entry here at all. Their fold reads agent only out of a signed envelope, so a scopes entry naming one would be written where nothing reads it; the daemon publishes into the replica instead, with that space's effective list resolved into artifactTypes and no scopes field (design §11, §18).

Labels are free strings, and normalizing them is the writer's job #

com.disnetdev.radial.labelGoal carries the goal's whole label set: at most 20 labels, each 1–100 characters. The lexicon bounds them and can do nothing else. It cannot say that Needs design and Needs design are the same label, or that a member typing Infra after somebody typed infra meant the one that already exists, because a lexicon describes bytes and those are different strings.

So the obligation lives in the writers, exactly as it does for an artifact's title, and Radial's single write path (runCli, used by both the CLI and the web app) has it. Before writing a labelGoal or a savedView, a writer must:

  • trim each label and collapse inner whitespace runs to single spaces;
  • drop labels that are empty after that;
  • deduplicate case-insensitively, keeping the first spelling as written;
  • refuse — not truncate — a set over 20 labels or a label over 100 characters. A record over either bound is one every materializer drops, which from a shell looks exactly like a write that worked.

A conforming reader must not apply that normalization itself. Case-folding means choosing which spelling survives, and a materializer that chose one would be reinterpreting what a member signed; Radial's fold removes only exact duplicates and otherwise preserves the winning record's order, which is the author's. Two spellings of one word are therefore two labels on the wire, and a reader that groups them (a filter, a chip count) does so in its own presentation and never in the index.

There is deliberately no registry: a label exists because somebody used it. A future admin-governed registry with colours would be a new collection and is additive-safe; labelGoal does not anticipate it.

com.disnetdev.radial.savedView follows the same rules for labels and for name, and adds one of its own: a view is keyed by (author DID, name), so a writer editing a view writes a new record with the same name rather than rewriting the old one, and deletes by writing one with deleted: true. state defaults to live when absent, and an absent labels or project does not constrain — which is what keeps a future additive field compatible with views already signed.

Model provenance is optional and daemon-attested #

Artifacts, reviews, and messages may carry an optional model string (at most 500 characters). It is the exact opaque model identifier the daemon observed for an agent turn: the latest concrete model reported by the harness before submission, falling back to the model explicitly selected for the invocation. The field is absent for human writes and when a harness silently chooses its own default.

The container and sidecar do not supply this field. It is display provenance only: it does not affect trust, routing, fulfillment, materialization, or the digest, and readers must not infer it from an agent profile's current capability declaration.

An image is its own record, and the blob reference is why #

com.disnetdev.radial.image carries one blob plus optional alt and name. Prose refers to it as ![alt](radial-image:<the record's at:// URI>) rather than by URL, and both halves of that are deliberate.

The record is what makes the blob durable. atproto reclaims a blob that no record references, so an upload on its own is not storage. A writer therefore uploads, writes this record, and only then inserts the markdown; a failure between the second and third steps has to be reported as "nothing was inserted" rather than as an image somebody now has.

The reference is a record URI, not an address. A …/xrpc/com.atproto.sync.getBlob?did=…&cid=… URL pins whichever PDS the author was on when they pasted it. The record URI names the author, and a reader resolves that DID to its current PDS at read time, so an author moving PDS does not require anybody's prose to be rewritten. It also keeps resolution closed: a reader fetches the blob CID the record carries, never a DID/CID pair lifted out of somebody's markdown.

The accept list is four raster containers — image/png, image/jpeg, image/webp, image/gif — with a 10 MB maxSize matching the existing artifact blob ceiling. SVG is deliberately absent. Through an <img> it is inert in most respects and is still a document: it can carry script, reference external resources, and expand arbitrarily on decode. Adding a format here is additive and safe; removing one would invalidate records already signed, so the list starts narrow on purpose. Both values are read out of this lexicon at runtime (IMAGE_LIMITS in @radial/core), so the upload form, the writer and the validator cannot check three different numbers.

Nothing in the fold reads an image record. It carries no space, goal or project reference — membership is what binds it to a space, exactly as for agent — and it contributes nothing to goal state, timelines, claims or the convergent digest. Adding a field to it can therefore never change what a materializer decides.

Claim leases are durations, and they are bounded #

A claim record is the one place where one implementation's clock changes another's behaviour, so two numbers are part of the wire contract and not of any one implementation. Both are evaluated identically by every conforming materializer; an implementation that picks different ones will disagree with this one about which claims exist.

  • MAX_CLAIM_LEASE_MS = 3 600 000 (1 hour). A claim version carrying renewedAt declares a lease of expiresAt − renewedAt. Longer than this, or negative, or unparseable, and the version is refused.
  • MAX_CLAIM_HORIZON_MS = 86 400 000 (24 hours). Every version, with or without renewedAt, is also bounded by expiresAt − createdAt. It has to be far more generous because createdAt is frozen across renewals, so the span it bounds is age + lease for a healthy claim.

Both bounds apply, and the second is not redundant: renewedAt is self-reported, so a writer whose clock jumps forward and is then corrected can declare a lease of exactly one hour sitting a week in everybody's future. createdAt is the only field on the record that corroborates it. The consequence 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 rather than held by one that keeps stretching. In this implementation the claim rkey is claim-<24 base32 chars of sha256(requestUri#requestCid)> for the first such record and of …#<generation> for each one after it, so re-claiming needs no coordination and no deletes.

renewedAt is optional and additive: it is the moment the writer issued this version of the lease, and absent means "the same as createdAt". A renewal moves expiresAt and renewedAt together, from one clock reading, and touches nothing else; that is what makes the declared lease independent of how far the writer's clock has drifted since the claim was created.

Which version of a claim record exists is equally part of the contract, and it is a selection over the version set rather than a scan in arrival order. A version is eligible when every field but expiresAt and renewedAt matches the original's and it is within both bounds above. The record is the eligible version with the greatest expiresAt as an instant, ties broken by lowest cid by code point; if none is eligible the original stands. Nothing may depend on repository revisions or arrival order here — those are observer-local, and an implementation that ordered by them would disagree with this one about which version exists. Two consequences worth stating: a superseded renewal is not an edit (only ineligible versions are), and a shortening rewrite is adoptable exactly when the version it replaces is out of contract, which is how a writer repairs a lease its own clock damaged.

Liveness is then a duration measured from when the observer first saw the version, not a comparison of the writer's expiresAt against the reader's clock. Two implementations that ingested the same records therefore disagree about who holds a request for no longer than the gap between their syncs.

The claim tie-break is unchanged and remains self-reported: earliest createdAt as an instant, then lowest record URI by code point.

Guest comments are three new record types, not a change to message #

com.disnetdev.radial.setThreadSource attaches a Roomy room to a goal without copying its live contents into Radial. com.disnetdev.radial.importMessage is the member-signed snapshot crossing into agent context; com.disnetdev.radial.retractImport withdraws that snapshot. Roomy source metadata is descriptive because the service provides neither signed message records nor CIDs.

com.disnetdev.radial.blessComment, com.disnetdev.radial.retractBless and com.disnetdev.radial.setGuestComments are additive in the strongest sense — they are new collections, so no record already signed is affected by them, and a materializer that has never heard of them folds exactly what it folded before.

The comment itself is an ordinary com.disnetdev.radial.message with a goal strongref, written by whoever wrote it into their own repo. That was already possible and always will be: an atproto repo is self-sovereign, there is no permission anywhere to grant, and nothing here tries to invent one. What decides whether such a record counts is the same rule as for every other record — its author's grant — so a non-member's comment is dropped by trust, and a guest later added as a member has their history fold in with no migration at all.

How a second implementation must read the three:

  • blessComment carries {goal, comment, body}. comment pins the guest's record by uri#cid; body is a snapshot of that exact version, copied by the writer at the moment they blessed it. Render and bundle the snapshot, never a re-fetch: the fold does no IO, the guest's repo is not polled by anybody, and a guest may rewrite or delete at will. goal is matched by URI, not uri#cid, for the same reason an ending is (a legacy closed edit minted a second CID).
  • A blessing is trusted from any active member, the same rule as a message. A retractBless is trusted from the blessing's own author or an active admin — retractRequest's rule, and load-bearing here: a blessing is the one path by which text from outside the space reaches an agent, so a mistaken one has to be revocable by somebody other than whoever made it. A retracted blessing stays visible and stays out of every bundle.
  • setGuestComments is {space, enabled}, admin-authored, folded latest-wins over a default of false, and matched to the space by exact uri#cid. It governs solicitation and display and nothing else. It cannot stop anybody writing a comment, and turning it off does not un-bless anything: a blessing is withdrawn by a retractBless and by nothing else.

The private-mode device directory #

Private mode adds space.private and space.nonce, four optional join fields (founder, protocol, label, peerHints), and the device and deviceAddress collections. These additions leave already-signed public-space records valid.

How a second implementation must read them:

  • device is {deviceKeyId, publicKey, algorithm, kind, protocols?, label?}, DID-authored and immutable. It binds one envelope-signing key to its author. Read it from the public path only: a key published inside an envelope would vouch for itself. Rotation creates a new record; the old binding remains valid.
  • deviceAddress is {deviceKeyId, endpointId, relays?, retiredAt?} and is sanctioned mutable discovery metadata. It never enters the trust fold. retiredAt withdraws the device from every replica's dial list and from what they will serve, and is reversible by publishing an address again; because the whole record is excluded from the device fold by construction, adding it changed no observer's index and needed no protocol version bump (ADR §29).
  • join's new fields are all optional and all inert to trust, exactly as the record already was. peerHints names DIDs a fresh client may try to reach for catch-up; publishing one discloses that DID as a likely participant, so it is opt-in.
  • space.private is set at creation and never changes. In a private space the fold ignores setGuestComments and pins guestCommentsEnabled to false, and every write path refuses to publish into it over a PDS (runCli) — the envelope writer that would honour the flag is not built, and a PDS write cannot be taken back.
  • space.nonce is 128 random bits, written only into a private space's genesis and read by nothing. It exists because the record's hash is public even though the record is not: a member's join bookmark publishes the genesis cid as a pin, and without the nonce that cid is a commitment to {name, description, private, createdAt} — four low-entropy fields whose timestamp the space's own TID rkey already discloses, so a guessed name confirms in milliseconds. A second implementation must preserve it verbatim (it is part of the bytes every pin is checked against) and must not require it: spaces created before it exists have none (ADR §28).

A record that arrived in an envelope is folded when at least one signing device is publicly bound to its author. Device bindings are append-only; member removal is the compromise response.

The rule with teeth for a second implementation is the other direction: in a private space a record that did NOT arrive in an envelope does not count, unless it is one of the two directory collections or a join. Without that, gate 2 is optional — a record with no signing device has no device to gate — and anything in a member's public repo folds on membership alone, which is itself just another record in somebody's repo. The space record is the single exemption, because it is what declares the mode.

goal.source says where imported text came from, and nothing follows it #

com.disnetdev.radial.goal carries an optional source — {kind, uri, cid?} — for a goal somebody wrote by importing an issue from the project's forge (design §4, §13; the UI path is packages/ui/src/lib/issues.ts). Additive and optional, on the same terms as artifact.title: a goal signed before the field existed cannot be rewritten, so it must never become required.

kind is free-form, like closeGoal.disposition and for the same reason — a newer writer must be able to name a forge this build has never heard of without older readers rejecting the record. One value is currently minted, tangled-issue, and that one is checked: its uri must name an sh.tangled.repo.issue record, and cid pins the version whose text was read.

Three things a second implementation should know about it:

  • It is an assertion, not a proof. The title and body are what a member read, edited if they chose to, and signed; the writer does not re-fetch the issue and copy its bytes, which is exactly what comment bless does do, because a blessing quotes somebody verbatim and a goal is the member's own words.
  • Nothing mechanical follows it. It has one consumer: a list of a repository's open issues hides the ones a goal already names, so two members do not import the same issue twice. No fold rule reads it.
  • It is not in the turn bundle. buildTurnBundle carries a goal's uri, cid, title and body and no other field, and core/test/bundle.test.mjs asserts that a goal carrying a source produces a bundle with no trace of the issue in it. An agent handed the at-uri could fetch the live issue and read comments and edits nobody reviewed, which is the thing the import gate exists to prevent.