ADR — Radial on tangled: the forge seam #
Status: accepted, partially unverified. Decided while implementing the "add support for tangled as
a forge" plan; D2 revised after review, when tangled's public Bobbin instance turned out to exist;
D0 added and D2 revised again after a second review, when a repository's own DID turned out not
to be its owner's. Tested against tangled.org/core lexicons as published in July 2026
(v1.16-alpha) and against live responses from https://api.tangled.org, plc.directory and the
PDSes behind them. Every claim below is marked verified (read from tangled's own published
lexicons or docs, or observed live) or assumed (could not be exercised without a live knot and a
tangled account — see §5).
1. Context #
Radial's forge integration was deliberately narrow (design §10): a turn pushes a branch and opens a
pull request itself from inside the container with git and gh, and the daemon only observes —
ForgeAdapter was getPullRequestState + compareUrl, the merge poller writes a merge
annotation, and a human merges. GitHub was the only implementation, and run.forge was one global
switch that also gated whether implementation turns could run at all, for every project.
Tangled is an atproto forge. Adding it is not "another REST client": it moves two things across the daemon/container boundary, and it is the first forge to prove the adapter seam was real.
2. What tangled is, in Radial's terms #
Verified, from tangled.org/core's published lexicons:
| Record | Key | Shape Radial reads |
|---|---|---|
sh.tangled.repo.pull |
tid |
{title, body?, target:{repo: did, branch}, source?:{branch, repo?: did}, rounds:[{patchBlob: blob(application/gzip), createdAt}], createdAt} |
sh.tangled.repo.pull.status |
tid |
{pull: at-uri, status: sh.tangled.repo.pull.status.{open,closed,merged}, createdAt} |
sh.tangled.repo |
any |
{knot, name?, repoDid, description?, spindle?, …} — lives in the OWNER's repo |
sh.tangled.publicKey |
tid |
{key, name, createdAt} — an ssh public key registered to a DID |
The rounds description says it plainly: "revisions of this pull request, newer rounds are appended
to this array … the blob format is gzipped text-based git-format-patches." A revision is a new
round on the same record, not a new record — which is a better fit for Radial's one-PR-per-chain
rule than GitHub's model, not a worse one.
Verified, from the tangled docs: pushes go to knots over ssh, authorised against the DID that owns the registered key.
Verified live against https://api.tangled.org (July 2026): tangled runs a public Bobbin —
its read-only, unauthenticated XRPC appview over sh.tangled.*. The first cut of this work said
there was none, reasoning from the docs' note that tangled hosts no public Hydrant or Slingshot; that
is true of Bobbin's upstreams and false of Bobbin itself. The endpoints this adapter uses, with
responses observed on that instance:
| Call | Answers |
|---|---|
sh.tangled.repo.getPull?pull=<at-uri> |
{uri, cid, value} — the record, fetched live through Slingshot, and no state |
sh.tangled.repo.listPullsBy?subject=<author did> |
{items: [{uri, cid, value, state, stateUpdatedAt?, commentCount}], cursor} — the edge index, newest first |
sh.tangled.bobbin.getCoverage |
{ready, eventsProcessed, lastCursor} — how far the edge index has backfilled |
The split between the first two is load-bearing and is not what the endpoint names suggest: only the
index endpoints carry state. Reading state off getPull would report every pull open and no
merge would ever be observed, so getPullRequestState goes through listPullsBy, keyed by the pull's
author — which is exactly what its at-uri names. Questions that need only the record
(pullBelongsToProject) use getPull, which has no index to lag behind.
Verified live, and the fact everything else hangs off: a repository has its OWN DID, which is not
its owner's. https://tangled.org/@tangled.org/core is three identities, not two:
| DID | alsoKnownAs |
#atproto_pds |
Holds | |
|---|---|---|---|---|
| the owner | did:plc:wshs7t2adsemcrrd4snkeqli |
at://tangled.org |
oysterling.us-west.host.bsky.network |
the sh.tangled.repo record, and pull.status records for pulls it rules on |
| the repository | did:plc:j5hmlfdrwkvtxm7cjmu7j2is |
(empty) | knot1.tangled.sh |
git, and no atproto records at all |
| a pull's author | any person | their own PDS | the sh.tangled.repo.pull record |
A pull's target.repo (and the target.repoDid live records repeat it in) names the repository
DID; at://<ownerDid>/sh.tangled.repo/<name>'s repoDid field is what maps one to the other. The
first cut of this work compared a pull's target.repo against the OWNER's DID and so compared two
namespaces — always false, silently (see D0). Two rkey conventions are live: core's record is keyed
by the repo name, records created through the current appview are keyed by a tid with the name in
value.name.
A repository DID resolves to a knot, which serves git and answers 404 to every
com.atproto.repo.* call. So a status fold must scan a PERSON's repo, never the repo DID — there is
nothing in one to read. (The first cut of this document mistook the repo DID for tangled.org's own
identity and concluded that "for such a repo there is no PDS to scan at all"; the correct statement
is that repo DIDs are always knot-hosted and never hold pull.status records, while the owner is an
ordinary account whose PDS answers normally.)
Verified live: a sh.tangled.repo.pull.status record is written by whoever RULED on the pull,
into their OWN repo. did:plc:dfl62fgb7wtjj3fcbb72naae's repo carries merges and closes it made,
timestamped at the ruling (2026-07-26T11:53:14.643Z on the day this was checked), on pulls authored
by other people in repos it does not own. That is the argument for an appview: Radial can guess two
repos, and a third-party collaborator's merge is in neither. Historic rulings on core also sit in
tangled.org's own repo (1723 of them, ending 2026-07-02, when merges evidently moved to individual
maintainers' DIDs), which is why the direct fold scans the owner as well as the author.
Verified, from the same docs: Bobbin serves rkeys, not sequential numbers — "No sequential
issue or PR numbers. bobbin returns rkeys, not #42 style ids like the web appview … rkeys are the
IDs." The same note supplies the mapping rule: a client that wants the web appview's display number
derives it from creation order. Bobbin's repository-scoped listPulls exposes that order (D3).
Verified: tangled.org/core ships cmd/knot, cmd/appview, cmd/spindle, cmd/bobbin and no
user-facing CLI. There is nothing an agent in a container could run to open a pull request, and
Bobbin serves no write endpoint — tangled's own position is that writes go direct to the PDS and the
knot, since that is where the auth is.
3. Decisions #
D0 — A project is identified by its repository's DID, resolved through the owner's repo record #
Added after review. The first cut used the OWNER's DID, which is a different DID, so every identity
comparison in the adapter compared two namespaces and was always false: pullBelongsToProject said
"not this project" about a project's own pull, so every v2 opened a fresh branch and a second pull
record, and openPullRequest wrote a target.repo naming a repository the knot and the appview have
never heard of. Nothing failed loudly; it just never matched.
projectIdentity(gitUrl) resolves the owner handle to a DID, finds sh.tangled.repo in that
owner's repo (matching value.name or the rkey, since both conventions are live), and returns its
repoDid. PullRequestState.headRepoFullName is in the same namespace, so the dispatcher's reuse
guard compares like with like, and openPullRequest writes that DID into target.repo and
target.repoDid — byte-shaped like the branch pulls tangled's own client writes. source stays a
bare branch: of 268 live pulls sampled, source.repo appears only on FORK pulls (0 set it to the
target repo), and Radial pushes to the target repo.
A repo record without repoDid is read as owner-identified; the field post-dates the first repo
records, and the owner DID is the only other thing such a repo's pulls could have named.
The lookup is cached per project URL (a repo's DID does not change) and its failure is loud: a URL naming a repository the owner does not hold throws rather than falling back to some other DID.
D1 — The forge is a registry, selected per project by gitUrl host #
Not one global adapter. A space may hold a GitHub project and a tangled project at once, and the old
shape had a real bug in it: one missing GitHub token disabled implementation turns for every
project. implementationEnabled is now a per-project question the matching adapter answers
(implementationBlockedReason()), evaluated in dispatch.ts where the project is known.
run.forges: [{kind:'github'}, {kind:'tangled', hosts?, knownHosts?}], with the legacy
run.forge: {kind:'github'} still parsing as a one-element list.
D2 — Observation reads Bobbin for a ruling; the direct PDS fold corroborates an "open" #
Revised twice. The first cut read PDSes only, on the mistaken belief that no public Bobbin existed.
The second read Bobbin and treated its answer as final; live checking showed its open is not a
ruling, and that the fold it fell back to was scanning a knot.
TangledForge.getPullRequestState reads the pull out of sh.tangled.repo.listPullsBy for its
author, which carries the record and the folded state together. Bobbin is the right first source,
and not for convenience: a pull's state is not in the pull record. It is the newest
sh.tangled.repo.pull.status record naming it, written into the repo of whoever ruled on it —
verified live (§2), and a DID Radial cannot know in advance. Scanning PDSes means guessing which
repos to look in, and the guess is wrong precisely when a third-party collaborator does the merge.
Bobbin folds that from the firehose, so the answer is network-wide instead of two-DIDs-wide. It also
needs no credential: Bobbin is read-only and unauthenticated by design.
But an appview open is not a ruling. Bobbin's state comes from an in-memory edge index
backfilled off a Hydrant stream, so open means "no ruling in my index", which is not "nobody
ruled". Verified live in July 2026: pulls on core whose merged/closed status records are
plainly readable on tangled.org's PDS come back open from listPullsBy. Taking that at face
value leaves such a merge unobserved for as long as the gap lasts — which, for the merge poller, is
forever. So an open is corroborated against the direct fold and a status record beats it; a
merged or a closed is a ruling and stands as-is, costing no PDS read at all. Where both sources
carry a ruling they agree.
The direct fold — also the whole answer when Bobbin is unreachable, not configured, or has not
indexed a pull this new — scans the two repos a ruling plausibly lands in: the pull's author (who
can close their own pull) and the repository's owner (who merges it). Both are people, so both
have ordinary PDSes. The repo's own DID, which is what target.repo names and therefore the
obvious-looking third candidate, is deliberately not scanned: it is a knot (§2), and no status record
is ever written into one. A candidate that cannot be read is skipped rather than fatal — an
unreachable PDS for one repo must not discard a ruling the other already yielded — and only a fold
that could read nothing at all throws.
The owner can only come from the project's gitUrl, which a pull request URL does not carry. So
getPullRequestState takes an optional PullStateContext, which the merge poller and the
dispatcher's reuse guard both pass. It is a hint, never a requirement: without it the fold still
scans the author, and Bobbin — the normal path — needs no context at all.
Verified end to end against api.tangled.org and live PDSes while implementing this: real core
pulls read back open, closed and merged; pullBelongsToProject true for the owning project and
false for another; with the appview pointed at a dead URL the fold engaged, logged once, and reported
the same merged with its mergedAt; and a pull Bobbin reports open was corrected to merged by
corroboration.
What Bobbin does not replace:
- Writes. Bobbin serves no write endpoint. The pull record is still written direct to the acting agent's PDS (D4).
- Reading the agent's own pull records (
#findOwnPull, the create-vs-append decision). Bobbin'slist*endpoints are served from an in-memory edge index backfilled off a Hydrant stream, so a record written minutes ago may not be in them yet. A miss there does not degrade gracefully — it opens a second pull request for a chain that already has one. The agent's own PDS is where the record was written and cannot lag behind itself, so that read stays direct.
Not a hard dependency. Three things make the direct fold the whole answer rather than a
corroborator, each logged: Bobbin unreachable (its docs are explicit that a dead Slingshot means
502 on single lookups), Bobbin's index not yet carrying this pull, and api: false. Keeping it is
what makes an appview-less deployment work at all, since self-hosting Bobbin means self-hosting
Hydrant and Slingshot too, which tangled does not run publicly. Its limit is real and stated above:
two repos, so a merge by a third-party collaborator is outside it.
A per-repo scan walks at most five pages of listRecords. Status records sort newest-ruling-first
(their rkey is a fresh tid at ruling time — verified live), so a recent ruling is on page one; a repo
with well over 500 historic rulings can hide an old one past the bound, which costs nothing for a
pull Radial opened itself and is why corroboration is best-effort rather than authoritative.
The index-lag case matters more than it looks. Bobbin's listPullsBy answering "not in my index" is
a perfectly successful HTTP call, so treating a miss as open would report a merged pull as open
indefinitely, with nothing in the logs. It is treated as "the appview cannot say" instead.
Either way a read failure throws — MergePoller treats a throw as "back off and retry", never as
"not merged".
Deliberately not done for state polling: gating on sh.tangled.bobbin.getCoverage. A stale index
reports an older state, which for merge polling costs latency and nothing else — the next poll picks
it up — and a second request per poll to find that out is not worth it.
D3 — revised again: a pull number must be proved, never counted #
The human-readable https://tangled.org/<owner>/<repo>/pulls/<n> carries a number the web
appview assigns from database state; it is in no record and is not recoverable from record creation
order. Live evidence disproved the earlier rule: Bobbin reported ready: true at
136338/160175 events while returning an empty repository list, and large repositories showed page
numbers diverging sharply from list rank (including records absent from the index). Deletions also
leave gaps in appview numbering. Counting can therefore produce a valid page for somebody else's
pull.
Radial now publishes a numbered URL only when the web page itself names the exact
sh.tangled.repo.pull at-uri. The reverse direction applies the same proof. The newest pull-list
entries are searched under a small, logged budget; failures, markup changes, and HTTP-200 404 pages
all fail closed to the stable at-uri. pageLinks: false disables the HTML dependency. Merge polling
also compares the reported head branch and, when available, repository identity before writing an
annotation, making legacy wrong links harmless.
Consequence: format: uri validation in core had to accept an at-uri. It is an absolute URI by
RFC 3986; WHATWG new URL refuses it only because the DID's colons parse as a port. No lexicon
changed — isUri in validation.ts gained an explicit at-uri branch, which only widens what
validates. The UI renders a fallback record URI as a chip with no number rather than reading the
record key as one.
D4 — The daemon opens the pull request; the container only pushes #
There is no CLI to do it with (§2), and record construction is daemon-side anyway (§9, boundary 6:
the daemon constructs records, the harness supplies prose). So openPullRequest is an optional
adapter capability: where an adapter implements it, radial artifact submit needs no --pr and the
daemon stamps links.pr itself; where it does not — GitHub — --pr stays required and nothing
about that path changed.
The pull is authored by the same DID that signs the artifact. No forge account per agent, no
operator token in the middle — a stronger version of what the Co-Authored-By: trailer achieves on
GitHub.
Create-vs-append: sh.tangled.repo.pull is keyed by tid, so Radial's usual deterministic-rkey
idempotency is unavailable. Instead the acting DID's own pull records are listed and matched on
(target.repo, source.branch) — the same pair the one-PR-per-chain rule is defined by, and now the
repository's own DID on both sides (D0) — and a match is a putForeign with the new round appended
and swapRecord pinned to the CID just read. The resulting page URL (or at-uri fallback) is also
persisted on the turn ledger (pull_uri), so a crash between the record write and the artifact
write does not need a re-list to recover.
D5 — The push credential is an ssh key, and boundary 5 holds #
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 revoked by deleting one record. radiald init generates one per agent DID (idempotently —
regenerating would silently invalidate every collaborator grant already given), stores it 0600 beside
sessions.json, and publishes the public half.
The key is written in OpenSSH's own openssh-key-v1 container rather than PKCS#8: Node can only
export PKCS#8, and whether a given OpenSSH build reads a PKCS#8 ed25519 key depends on how it was
compiled — a dependency that would surface as an opaque push failure inside somebody else's
container.
Host keys are pinned from operator config. Radial will not fall back to
StrictHostKeyChecking=no; an unpinned push is a MITM-able push, and that would quietly weaken the
possession-based containment story. A tangled turn is given no GH_TOKEN.
The push uses an explicit force-with-lease expectation read from git ls-remote immediately
before the write. RADIAL_PUSH_REMOTE is an ssh URL, not a configured tracking remote, so bare
--force-with-lease has no remote-tracking ref to compare and rejects an otherwise safe push as
stale info. Naming refs/heads/<branch>:<observed-sha> preserves the actual rule: a rebase may
replace the predecessor's history, but a concurrent move after the observation is refused. An empty
observed SHA means the branch must still not exist.
D6 — Spindles are out of scope #
Tangled has its own CI. Radial's check runner keeps running the project's checks in its own
container against the linked commit. Answered once, here.
4. Rejected alternatives #
- Put a tangled app password in the container so the agent writes its own pull record. Violates boundary 5 (an atproto credential in a container) for no gain — the daemon already has the session.
- Patch-only pulls, with no push at all. Tempting: it needs no ssh key. But
links.commitis load bearing — checkruns fetch the exact sha and review turns pin their checkout to it — so with no commit on a remote, checks and review turns break. - Wait for a tangled CLI. None exists; blocking on one blocks the goal.
- Resolve the appview number by scraping the appview's HTML. Brittle against a pre-1.0 UI, and
it would put a screen-scraper on the path of every
links.pra turn writes. Bobbin does not offer a direct rkey-to-number lookup, but its documented creation-order rule andlistPullsare enough. - Drop the direct PDS fold now that Bobbin exists. It is the only thing that keeps a
no-appview deployment working, and Bobbin's own docs describe two upstream failures that take its
reads down. It earns its keep a second way now: an appview
openis not a ruling, and the fold is what catches a merge the index has not covered (D2). - Trust an appview
openand skip the corroborating read. One fewer request per poll of an open pull, for a class of merges that is then never observed at all — and the poller would go on asking forever with nothing in the logs. The extra read is bounded by the poller's own exponential backoff, and onlyopenpays it. - Scan the repo DID for status records because that is what
target.reponames. It is a knot; it answers404to everycom.atproto.repo.*call and holds no records. The first cut did this, and because a failed candidate was fatal it took the owner's answer down with it. - Read pull state from
getPull, the obvious-looking endpoint. It does not serve state; every pull would readopen. Caught only by exercising the live instance, which is the argument for §6 being a real exit criterion rather than a formality. - Read the agent's own pull records from Bobbin too, for symmetry. Symmetry is not the goal; correctness is. See D2 — an index miss there opens a duplicate pull request.
5. What is NOT verified #
CI cannot run Docker or reach a knot, so the following were reasoned from published lexicons and docs but never exercised. Each is written to fail loudly rather than silently.
- Does the web appview show a
sh.tangled.repo.pullrecord written directly to a PDS? Narrowed since the first cut. Radial's own observation no longer depends on the answer: Bobbin indexes from a Hydrant stream of the atproto firehose, which carries every PDS commit whatever wrote it, so a record Radial writes is a record Radial can then read back throughgetPull. What is still unexercised is whethertangled.org's human-facing appview renders it — which matters only because a human merges (design §10). If it does not, the write path must go through an appview endpoint with an OAuth session and D4 changes shape; D1, D2, D3 and D5 still stand. - Round semantics. The lexicon warns that "appviews may reject records; do not treat this field
as append-only". Radial only ever appends, and pins
swapRecord, which is the strictest thing it can do from outside. - Repo identity beyond
repoDid. Settled since the first cut:target.repois the repository's own DID (§2, D0), so it distinguishes repos exactly — the ambiguity the first cut worried about does not exist. What is still unexercised is a repo whosesh.tangled.reporecord carries norepoDid(read as owner-identified) and one whose owner holds more than 500 repo records (the scan bound). The parser also reads anat://…/sh.tangled.repo/<rkey>value and atarget.repoDidspelling for the same DID, so a lexicon that moves repo identity keeps folding. - Anonymous HTTPS clone from a knot, and at what URL. The project's
gitUrlis whatever the operator records; only the push remote is derived (git@<host>:<owner>/<repo>). - Whether the agent DID can be added as a collaborator and push
radial/impl-*. If pushing to the target repo turns out to be impossible, the agent must push to its own fork and the pull carriessource.repo— a bigger change todispatch.ts, since the branch namespace moves repos. compareUrlpoints at…/commit/<sha>; tangled has no compare route this adapter can rely on across versions. It is used only for operator-facing links.- The image change (
openssh-client). Docker is never exercised by this repo's CI. - How fast Bobbin's index picks up a new pull, and a merge of one. It is firehose-backed, so the
lag ought to be seconds, but it was not measured against a write. Neither direction can produce a
wrong
merged: an unindexed pull falls through to the direct fold, and nothing reportsmergedwithout a status record behind it. What is now verified is that the index can reportopenlong after a ruling for pulls outside its backfill window, which is why anopenis corroborated (D2) rather than believed. - Who writes the
pull.statusrecord when a human merges a Radial pull. Observed on other people's merges — the person who clicked, into their own repo — but not on one of Radial's. If a tangled version instead has the knot or the appview identity write it, Bobbin still folds it and only the (best-effort) corroboration narrows. §6 step 5 is what settles it.
6. Operator acceptance (the real exit criterion) #
Run once, against a scratch tangled repo:
pnpm images;radiald initgenerates the key and publishessh.tangled.publicKey; add the agent as a collaborator; putssh-keyscanoutput inrun.forges[].knownHosts.- Create a project with the tangled
gitUrl; state a goal; request animplementation. - Expect: a
radial/impl-<hash>branch on the knot; one pull record; anartifactwhoselinkscarry branch, commit and the direct…/pulls/<n>page (or the record's at-uri while Bobbin is not ready); acheckrunagainst the linked commit; the unit readinglandedthenjudged. Then check the record round-trips through the appview:curl "https://api.tangled.org/xrpc/sh.tangled.repo.listPullsBy?subject=<agent did>"lists it with"state": "open", andhttps://tangled.org/<owner>/<repo>/pullsshows it (§5.1). - Request a v2. Expect: the same branch, the same pull record with a second round, no
second pull, and
continuing radial/impl-…in the log. - Merge the pull by hand. Expect:
listPullsByflips that pull to"state": "merged"with astateUpdatedAt, and within onemergePollIntervalMsafter that, amergerecord and the unit readingmerged. Note the gap between the two — that is §5.8. Also note whose repo the resultingsh.tangled.repo.pull.statusrecord landed in (§5.9): the merger's, the repo owner's, or neither. - Request a v3 after the merge. Expect: a fresh branch and a new pull.
- Re-run 2–3 against a GitHub project in the same space and the same daemon. Expect: byte-identical behaviour to before this change.
Record the outcome of each numbered expectation here.
Sources. Tangled docs ·
Bobbin · Introducing Bobbin ·
api.tangled.org · lifecycle of a pull request ·
lexicons/pulls/pull.json ·
lexicons/pulls/state.json ·
lexicons/publicKey.json