# 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](https://tangled.org) 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=` | `{uri, cid, value}` — the record, fetched live through Slingshot, and **no state** | | `sh.tangled.repo.listPullsBy?subject=` | `{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:///sh.tangled.repo/`'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's `list*` 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///pulls/` 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`. ### 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.commit` is 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.pr` a turn writes. Bobbin does not offer a direct rkey-to-number lookup, but its documented creation-order rule and `listPulls` are 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 `open` is not a ruling, and the fold is what catches a merge the index has not covered (D2). - **Trust an appview `open` and 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 only `open` pays it. - **Scan the repo DID for status records because that is what `target.repo` names.** It is a knot; it answers `404` to every `com.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 read `open`. 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. 1. **Does the *web* appview show a `sh.tangled.repo.pull` record 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 through `getPull`. What is still unexercised is whether `tangled.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. 2. **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. 3. **Repo identity beyond `repoDid`.** Settled since the first cut: `target.repo` is 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 whose `sh.tangled.repo` record carries no `repoDid` (read as owner-identified) and one whose owner holds more than 500 repo records (the scan bound). The parser also reads an `at://…/sh.tangled.repo/` value and a `target.repoDid` spelling for the same DID, so a lexicon that moves repo identity keeps folding. 4. **Anonymous HTTPS clone from a knot**, and at what URL. The project's `gitUrl` is whatever the operator records; only the *push* remote is derived (`git@:/`). 5. **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 carries `source.repo` — a bigger change to `dispatch.ts`, since the branch namespace moves repos. 6. **`compareUrl`** points at `…/commit/`; tangled has no compare route this adapter can rely on across versions. It is used only for operator-facing links. 7. **The image change** (`openssh-client`). Docker is never exercised by this repo's CI. 8. **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 reports `merged` without a status record behind it. What *is* now verified is that the index can report `open` long after a ruling for pulls outside its backfill window, which is why an `open` is corroborated (D2) rather than believed. 9. **Who writes the `pull.status` record 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: 1. `pnpm images`; `radiald init` generates the key and publishes `sh.tangled.publicKey`; add the agent as a collaborator; put `ssh-keyscan` output in `run.forges[].knownHosts`. 2. Create a project with the tangled `gitUrl`; state a goal; request an `implementation`. 3. **Expect**: a `radial/impl-` branch on the knot; one pull record; an `artifact` whose `links` carry branch, commit and the direct `…/pulls/` page (or the record's at-uri while Bobbin is not ready); a `checkrun` against the linked commit; the unit reading `landed` then `judged`. Then check the record round-trips through the appview: `curl "https://api.tangled.org/xrpc/sh.tangled.repo.listPullsBy?subject="` lists it with `"state": "open"`, and `https://tangled.org///pulls` shows it (§5.1). 4. 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. 5. Merge the pull by hand. **Expect**: `listPullsBy` flips that pull to `"state": "merged"` with a `stateUpdatedAt`, and within one `mergePollIntervalMs` after that, a `merge` record and the unit reading `merged`. Note the gap between the two — that is §5.8. Also note whose repo the resulting `sh.tangled.repo.pull.status` record landed in (§5.9): the merger's, the repo owner's, or neither. 6. Request a v3 after the merge. **Expect**: a *fresh* branch and a new pull. 7. 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](https://docs.tangled.org/single-page) · [Bobbin](https://docs.tangled.org/bobbin) · [Introducing Bobbin](https://blog.tangled.org/bobbin/) · [`api.tangled.org`](https://api.tangled.org) · [lifecycle of a pull request](https://blog.tangled.org/pulls/) · [`lexicons/pulls/pull.json`](https://tangled.org/tangled.org/core/blob/master/lexicons/pulls/pull.json) · [`lexicons/pulls/state.json`](https://tangled.org/tangled.org/core/blob/master/lexicons/pulls/state.json) · [`lexicons/publicKey.json`](https://tangled.org/tangled.org/core/blob/master/lexicons/publicKey.json)