From 8c677faa590626713b58c19181f8a494fe882914 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 26 Aug 2026 10:50:10 -0400 Subject: [PATCH] feat(plan)!: replace TODO.md with one file per epic TODO.md had become 3,400 lines in fifteen sections that named command families rather than lines of work, with a decision record, a bug report and a one-line wish all wearing the same checkbox. Every entry now lives in the epic that covers it, verbatim: thirty-two epics, each carrying an exit criterion somebody could check, its open work above a trailing `## Done`, and frontmatter that plan/README.md's five tables and plan/milestones.md's narrative are read against. Two prek hooks keep the register from drifting from the files it points at, and two entries did not survive the move, both superseded by work that landed under another entry and both recorded as such. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: Ia8da43fbb65b5e1277a25eeb8cb8df05f3d08cfa --- CLAUDE.md | 2 +- Cargo.toml | 3 +- TODO.md | 3401 ------------------------------------ deny.toml | 10 +- docs/architecture.md | 2 +- plan/README.md | 219 +++ plan/api.md | 104 ++ plan/artifacts.md | 35 + plan/branch-pulls.md | 184 ++ plan/brand.md | 70 + plan/complete/agent.md | 60 + plan/configuration.md | 102 ++ plan/credential-store.md | 274 +++ plan/declined.md | 53 + plan/dependencies.md | 153 ++ plan/docs.md | 45 + plan/doctor.md | 111 ++ plan/http-bounds.md | 111 ++ plan/identity.md | 128 ++ plan/indexes.md | 126 ++ plan/issues.md | 206 +++ plan/keys.md | 48 + plan/lexicons.md | 194 ++ plan/logs.md | 177 ++ plan/milestones.md | 53 + plan/module-layout.md | 178 ++ plan/order.txt | 57 + plan/output.md | 304 ++++ plan/pipelines.md | 48 + plan/publishing.md | 83 + plan/pull-numbers.md | 114 ++ plan/pull-requests.md | 252 +++ plan/report.md | 97 + plan/repos.md | 149 ++ plan/review.md | 89 + plan/search.md | 63 + plan/secrets.md | 26 + plan/sign-in.md | 252 +++ plan/stacks.md | 372 ++++ plan/testing.md | 97 + plan/toolchain.md | 72 + prek.toml | 30 + scripts/check-plan.py | 385 ++++ scripts/gen-plan-readme.py | 349 ++++ src/config/lock.rs | 14 +- 45 files changed, 5486 insertions(+), 3416 deletions(-) delete mode 100644 TODO.md create mode 100644 plan/README.md create mode 100644 plan/api.md create mode 100644 plan/artifacts.md create mode 100644 plan/branch-pulls.md create mode 100644 plan/brand.md create mode 100644 plan/complete/agent.md create mode 100644 plan/configuration.md create mode 100644 plan/credential-store.md create mode 100644 plan/declined.md create mode 100644 plan/dependencies.md create mode 100644 plan/docs.md create mode 100644 plan/doctor.md create mode 100644 plan/http-bounds.md create mode 100644 plan/identity.md create mode 100644 plan/indexes.md create mode 100644 plan/issues.md create mode 100644 plan/keys.md create mode 100644 plan/lexicons.md create mode 100644 plan/logs.md create mode 100644 plan/milestones.md create mode 100644 plan/module-layout.md create mode 100644 plan/order.txt create mode 100644 plan/output.md create mode 100644 plan/pipelines.md create mode 100644 plan/publishing.md create mode 100644 plan/pull-numbers.md create mode 100644 plan/pull-requests.md create mode 100644 plan/report.md create mode 100644 plan/repos.md create mode 100644 plan/review.md create mode 100644 plan/search.md create mode 100644 plan/secrets.md create mode 100644 plan/sign-in.md create mode 100644 plan/stacks.md create mode 100644 plan/testing.md create mode 100644 plan/toolchain.md create mode 100755 scripts/check-plan.py create mode 100755 scripts/gen-plan-readme.py diff --git a/CLAUDE.md b/CLAUDE.md index 8402b21..ccf4f61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ Make sure your work includes a "maintenance budget" for code directly related to - Reducing duplicated or dead code - Adding new debug log lines and similar observability - Updating documentation pages or test suites -- Checking off completed issues in TODO.md +- Ticking off finished work in the epic under `plan/` that covers it Structure your commits for human review: diff --git a/Cargo.toml b/Cargo.toml index 3a9c9cb..4fdfbe4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,7 +93,8 @@ clap_complete = "4.6.9" # `clock` because `Utc::now` and `Local` are used (`logs`, `search`, the log # writer); it pulls `now`, `std` and `alloc` behind it, which is the set # jacquard-common already asks for, so the feature union does not move. See -# TODO.md on why the earlier decision to leave chrono alone was reopened. +# plan/dependencies.md on why the earlier decision to leave chrono alone +# was reopened. chrono = { version = "0.4", default-features = false, features = ["clock"] } flate2 = "1.1.9" # `buffered` and `try_collect`, for uploading a body's image blobs a few at diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 458469d..0000000 --- a/TODO.md +++ /dev/null @@ -1,3401 +0,0 @@ -# TODO - -## Auth -- [x] Loopback OAuth login with granular scopes (jacquard) -- [x] File session store at ~/.config/atgc/sessions.json, mode 0600 -- [x] Identity page on the OAuth callback; @-prefixed handles; real handle - in login and `auth status` output -- [x] That page now lands on `about`'s field of DNA, prerendered into it by - the process that serves it, with the profile's display name, handle and - avatar escaped on the way in rather than trusted. The art is one `
`
-      and the animation is an opacity breath and a drifting highlight, both
-      off under `prefers-reduced-motion`
-- [x] Prune stale sessions on login — now scoped to the DID logging in, so
-      repeat logins of one account collapse to a single grant and other
-      accounts are untouched (the any-hint resume it originally fixed is
-      gone; every resume names its DID)
-- [x] Preemptive scopes for the non-destructive roadmap (single consent).
-      Broken once, by `pr comment`: the list asked for the deprecated
-      `sh.tangled.repo.pull.comment` and not the `sh.tangled.feed.comment`
-      that replaced it, so a session holding every other scope still got a
-      403 from the PDS on the first real comment. Adding it changed the
-      client_id, which is a re-login for everyone; the legacy entry stays
-      beside it, since deleting a legacy comment still needs it
-- [ ] **The confidential client was researched and rejected**, written down
-      here so nobody re-proposes it. It would have bought real things: a
-      2-year session with 3-month refresh tokens instead of the public
-      client's two weeks (`oauth-constants.ts`, `SESSION_LIFETIME_EXTENDED`
-      / `REFRESH_LIFETIME_EXTENDED` — so the claim this entry used to make
-      was correct), and the word "atgc" on the PDS consent page instead of
-      "an application on your device", which a localhost client cannot have
-      at any price.
-
-      It is rejected on a rule, not on effort. The reference authorization
-      server refuses `application_type: "native"` together with
-      `private_key_jwt` (`oauth-provider/src/client/client-manager.ts`,
-      citing RFC 8252 §8.4), and loopback and private-use-scheme redirect
-      URIs are permitted *only* to native clients. Chain the two and a
-      confidential client is a `"web"` client, every one of whose redirect
-      URIs must be `https://`. **A confidential atgc cannot have a loopback
-      callback.**
-
-      So the plan this entry used to describe — static client metadata and a
-      jwks on lance.blue — is necessary and nowhere near sufficient. It also
-      needs a *live HTTPS callback service* to receive the authorization
-      code and hand it back to a CLI that may be headless, over SSH or
-      behind NAT: either paste-back, or a stateful rendezvous that holds
-      authorization codes and is therefore a credential-handling endpoint to
-      secure, rate-limit and keep up. **atgc is not taking a hosted runtime
-      dependency to log in.** Everything else here reads a PDS directly and
-      degrades to "the index is out" rather than "the service is down", and
-      a login that requires lance.blue to be answering trades that away for
-      a longer token.
-
-      The second reason stands even if the first were lifted. The private
-      key must sit wherever the CLI runs, because the CLI mints a client
-      assertion on every refresh — so it is either shipped with the binary,
-      which makes it not a secret and hands everyone a key that impersonates
-      "atgc" to every PDS, or generated per user, in which case the consent
-      page shows their name and not ours, which was the point. Proposal 0010
-      (client-assertion-backend) is the named escape hatch upstream and
-      targets browser SPAs; it does not answer this for a CLI.
-
-      Two findings worth keeping out of the wreckage. jacquard already
-      supports confidential clients end to end and would need no patching —
-      `keyset.rs` (`generate_es256`, `public_jwks`), `atproto.rs`
-      (`AuthMethod::PrivateKeyJwt`, inline `jwks`) and `request.rs`
-      (`build_auth` mints an RFC 7523 assertion). And the cheaper goal is
-      still open: if the complaint is re-authorizing every fortnight, make a
-      lapsed session pleasant — `auth refresh` exists, and a clear prompt at
-      the moment one lapses costs hours and adds no service. Revisit only if
-      the spec permits a confidential client a loopback redirect
-- [x] Several accounts at once, keyed by DID: an accounts.json registry
-      beside jacquard's session store, `--account` / `ATGC_ACCOUNT` and a
-      checkout's own `user.email` DID feeding one precedence chain. Login
-      moved to `atgc auth login`; bare `atgc login` stays as a hidden alias
-- [x] `auth login` is additive and stops there — it no longer moves the
-      active pointer, so authorizing a second account cannot silently change
-      who atgc acts as. It still sets the pointer when there is none, so a
-      first login survives a second one. Instead of switching it prints what
-      would actually make that account active, worked out from the state at
-      the time: a switch, or the env var / checkout identity that outranks
-      one. Nothing is printed when the account is already the active one
-- [x] `auth logout` — log out one account (the active one, or a named
-      handle/DID), `--all` for every account. Local only: the tokens are
-      deleted, not revoked, and expire on their own
-- [x] `auth refresh` — exchange the active account's access token when it
-      has run out or is close to it; a healthy session is left alone
-- [x] `auth status` — every account, the active one marked with what
-      selected it, plus PDS, scopes and session expiry for that one
-      (replaced `whoami`, which no longer exists). An account whose token
-      lapsed still lists — expired is not logged out
-- [x] `auth switch` — move the persisted active-account pointer; says so
-      when something more specific outranks it anyway. That check used to
-      cover the checkout's own .git/config and nothing else, so an exported
-      ATGC_ACCOUNT went unmentioned; it now shares one routine with
-      `auth login` and covers both
-- [x] `auth token` — print the active account's access token for scripting
-      (refreshed first; DPoP-bound, so a bare Bearer header won't do)
-- [x] `auth login` says what it is waiting for. Discovery — handle to DID to
-      PDS to authorization server — is several requests to hosts atgc has
-      never contacted, and it used to run in complete silence, so a slow one
-      was a dead terminal that reads as a hang. It now prints the handle it is
-      resolving, times the step under `--debug`, and writes an
-      `authorize_ready` with the elapsed ms. That last one is what makes the
-      log readable after the fact: an `authorize_start` alone now means
-      discovery is still running, where before it could equally mean the
-      browser was never used
-- [x] `auth login` stopped printing the authorization URL at people. It is a
-      few hundred characters of PAR request, wrapped over several lines,
-      burying every other line of the login; now a terminal that renders
-      OSC 8 gets the site's name and a "click here" link instead, and only a
-      pipe, a redirect or an older terminal sees the URL — there it is the
-      only way through. The line also says whether a browser actually opened,
-      and offers the link either way, since an opener exiting zero is not
-      proof anyone is looking at a consent page. A `browser_open` event
-      records the same thing for the log
-- [x] One HTTP client constructor (src/clients/http.rs) with a 5s connect timeout and
-      a 30s read timeout, behind every request atgc makes. `reqwest`'s
-      defaults have no deadline at any layer, so a host that drops packets
-      rather than refusing them was bounded only by the local kernel's SYN
-      retry schedule. Read timeout rather than a total one on purpose: the
-      same client carries `pr create`'s patch blob, and capping a transfer by
-      total duration is a hidden ceiling on payload size
-- [x] Append-only OAuth event log at ~/.config/atgc/oauth.jsonl, mode 0600 —
-      typed events for every token request (with the client_id actually
-      sent), refusal (with the OAuth error code and a scrubbed body), store
-      read/write, session get/upsert/delete, prune and logout, stamped with a
-      per-invocation id. Written as one bounded write(2) under O_APPEND so
-      concurrent invocations cannot tear a line. Secrets appear only as
-      SHA-256 prefixes
-- [x] `auth log` — read oauth.jsonl back. Grouped by invocation by default,
-      `--interleave` for strict time order across processes, `-f` to follow
-      (through a rotation, not into a deleted inode). Filters by invocation,
-      event kind (`token_*`), account DID and time; `--failures` for the
-      short version; `--json` passes the raw lines through so the `jq`
-      workflow still composes. `--incident` does the comparison the next item
-      asks for. Deserializes into the writer's own event enum, so a new event
-      variant is a compile error here rather than a silently unrendered line
-- [x] Settle the session-destruction incident from the log. **Hypothesis 1,
-      confirmed by the authorization server in its own words.** A session was
-      destroyed a third time, during an `atgc pr create`, and this time the
-      log was running. bsky.social refused the refresh with
-      `invalid_grant` / "Token was not issued to this client", presented
-      under `client_id` fingerprint `cddefa6e` with no redirect URI, and
-      jacquard deleted the session on the spot. No two invocations were alive
-      at the same time and the refresh token had been read only sequentially,
-      minutes apart, so hypothesis 2 is ruled out for this failure.
-      `atgc auth log --incident` on `~/.config/atgc/oauth.jsonl` reproduces
-      all of it
-- [x] Fix what that proved: `auth login` records the `client_id` its grant
-      was issued to in `accounts.json`, and every later resume presents that
-      exact string instead of building a fresh one with no redirect URI.
-      Verbatim rather than rebuilt from the port, so a future `SCOPES` edit
-      cannot invalidate a live grant either. Sessions granted before this
-      cannot be repaired — the port is gone — and need one more login, which
-      the refresh error now says by name. Proven as far as it can be without
-      waiting: a resume given the recorded value reproduces `ddd93b67`, the
-      client the incident's grant was issued to, where building it fresh
-      still gives the refused `cddefa6e`. **A successful refresh an hour
-      after login has still not been observed**, because that is the only
-      way to observe it
-- [ ] Report upstream to jacquard: `client_id` on a token request comes from
-      the caller's `ClientData` rather than from the session, and
-      `ClientSessionData` has no field to hold it, so no loopback client can
-      refresh a grant across process boundaries. `oauth/loopback.rs`'s own
-      helper looks to have the same bug. Not patched in `vendor/` on
-      purpose — see the note beside the patch in Cargo.toml
-- [x] `auth login` resolves handles DNS-first, as the handle spec orders it.
-      jacquard's resolver has both steps in the right order but
-      `JacquardResolver::new` — which `OAuthClient::new` calls — leaves its
-      DNS field `None`, and `dns_txt` then returns an empty vector rather
-      than an error, so the step was a silent no-op and every handle went to
-      `https:///.well-known/atproto-did`. Found on a handle with a
-      good TXT record whose A record pointed at a parked IP that drops
-      packets: one DNS query away from resolving, 30s of stall on every login
-- [ ] Report upstream to jacquard, second item: `JacquardResolver` takes no
-      DNS resolver. `new_dns` and `with_system_dns` both build hickory with
-      `ResolverConfig::default()`, which is Google's `8.8.8.8` — the latter's
-      name says otherwise, and hickory's own `from_system_conf` is what it
-      sounds like. The field is private, so a caller cannot supply one, and
-      atgc now sends handle lookups to Google rather than to the resolver the
-      machine is configured with. An `Option>`
-      argument would settle it. Wanted before anyone points atgc at a PDS on
-      a network with split-horizon DNS
-- [x] `auth login` survives a granted scope it cannot parse. bsky.social
-      answered a login with `repo:` — a repo scope with no collection, so an
-      empty NSID — and the vendored crate's `Scopes::new().expect()` took the
-      process down inside the callback: exit 101, nothing on the wire, and a
-      browser holding a connection error because the loopback server had died
-      with it. A grant is the server's answer and its vocabulary is the
-      server's to extend, so `Scopes::parse_lossy` now keeps every token it
-      can read and hands back the ones it cannot; strict parsing still
-      applies to the scope string atgc composes. The refresh path had the
-      same `expect`, one command deeper
-- [x] The browser gets the failure, not a shrug. The callback page said
-      "return to the terminal for details", which is a second lookup for
-      something atgc already knows and was wrong outright when the process
-      never reached the terminal. It now carries the error itself, escaped
-      and scrubbed, on the same card as the success page — and the token
-      exchange runs on a task of its own, so a panic in it is a failed login
-      with the panic's own file and line on both screens and status 1,
-      rather than a dead port and two screens of nothing
-- [x] `token_granted` records the scope the grant came back with. The
-      request half was already logged as `authorize_start`'s `scopes` and the
-      answer was not, so the word that aborted a login existed only in a
-      response body this log deliberately never writes down. The one field of
-      a success body worth the exception — it is not a credential, it is the
-      list of what the credentials may do. `auth login` also names any
-      granted scope word it cannot read, once, at the moment it arrives
-- [ ] Report upstream to jacquard, third item: the two places a
-      *server-returned* scope string is parsed — `callback_core` and
-      `ClientSessionData::update_with_tokens` — did it with
-      `Scopes::new().expect()`, so an authorization server extending the
-      scope vocabulary aborts every client built on the crate. Patched in
-      `vendor/` (unlike the second item) because the failure is not
-      hypothetical and lands mid-callback. The lossy parser is the shape to
-      offer upstream; whether it should also *report* what it dropped, rather
-      than leaving each caller to re-derive it from the raw string as atgc
-      does, is the part worth asking about
-- [x] A denied consent and a callback with no code printed one sentence
-      between them. The redirect carries `error` and `error_description` and
-      `callback_query` dropped both, so "authorization was denied or the
-      callback had no code" was a guess covering states the server had named
-      precisely. `callback_query` now returns a `Callback` struct carrying
-      both, and `refusal` splits three ways: `access_denied` is "you declined
-      the authorization request" and not a fault, a server error leads with
-      the server's own `error_description` and keeps the code beside it for a
-      bug report, and a redirect with neither a code nor an error says it
-      should not happen — which it should not, and which the old sentence hid
-      among the ordinary refusals. The OAuth log keeps the machine-readable
-      token instead of the prose. Driven end to end against a real
-      bsky.social authorization with a hand-made callback
-- [x] One identifier classifier (src/lexicon/identity.rs) behind every place an
-      account is named — `--account`, ATGC_ACCOUNT, `auth login/switch/
-      logout`, `repo list `, `pr status --author`, and a checkout's
-      `user.email`. DID syntax per the atproto spec rather than a `did:`
-      prefix test, `did:plc`'s 24 base32 characters, hostname-only
-      `did:web`, and handles by the handle spec. An ordinary git email is
-      named as one and refused locally instead of being sent to a public
-      handle resolver with the address in the query string
-- [x] DID methods parsed as a variable, not hardcoded: an unrecognized
-      method is still a valid DID and can still select an account (refusing
-      it would fall through to somebody else's), and only fetching its
-      document refuses, naming the method. `did:web` gained its first test
-      coverage and a real document fixture
-- [x] The disallowed-TLD list from the handle spec (.local, .arpa,
-      .invalid, .internal, .localhost, .example, .alt, .onion) is enforced,
-      and was already: `jacquard::types::string::Handle` refuses one, which
-      is why `explain_handle` carries a `DISALLOWED_TLDS` branch whose only
-      job is to *name* the reserved TLD — it exists precisely because that
-      is the one refusal `domain` knows nothing about. Verified:
-      `ATGC_ACCOUNT=a.localhost` is refused with ".localhost is reserved and
-      cannot be a handle".
-
-      The entry's own worry is the live behaviour, not a hypothetical:
-      "enforcing it would break anyone pointed at a local test PDS" is what
-      happens today. If that ever costs somebody something, the fix is an
-      escape hatch, not the enforcement — but nobody has asked
-- [x] `account::names_account` prefix-tested for `did:` and answered a
-      boolean, so a value the classifier rejects and a value naming somebody
-      else both came back `false` and both produced `EnvWins` — "something
-      else outranks you" — when only one of them is true. As the entry said,
-      routing it through `classify` alone changes no outcome; the fix is the
-      third answer. `classify_env` returns `Names` / `NamesAnother` /
-      `NamesNothing`, and `Advice::EnvUnusable` is the new variant:
-      "ATGC_ACCOUNT=me@example.com names no account, so nothing that needs
-      one will work until it is changed", with "or unset it" as a remedy that
-      is real here and nowhere else in this function. Two lines, for
-      `RepoWins`' reason. The classification is `lexicon::identity::classify`
-      so that what counts as an identifier here is what `resolve_spec` will
-      decide a moment later
-- [ ] The commands that then fail do not all fail the same way. `key list`
-      and `auth token` name the variable and quote the classifier; `pr list`
-      falls back to reading no PDS and reports "no pull requests found", and
-      `issue list` says "not logged in, and no --author given". Both of those
-      are true and neither mentions `ATGC_ACCOUNT`, which is the thing to
-      change. The advice above is a warning at `auth switch`/`auth login`
-      time, not a fix for the commands themselves
-- [x] `auth switch` and `auth logout` named their positional field
-      `account`, which clap resolves to the same argument id as the global
-      `--account` selector — so `--account` silently landed in the
-      positional instead of choosing who to act as, and
-      `atgc auth logout --account ` failed to parse at all. Same bug
-      class already fixed once, for `repo list` and `key list`: renamed both
-      fields to `owner`, carrying the same explanatory comment
-- [x] `sessions.json` and `accounts.json` are now written atomically: stage
-      the bytes in a same-directory temp file created at the destination's
-      mode (0600, before any byte lands — never `chmod`'d afterward), fsync
-      it, then rename it over the target. Closes the crash window a bare
-      `fs::write` left open — a kill -9, OOM or full disk mid-write used to
-      be able to truncate either file, and a truncated sessions.json is not
-      the self-healing case: `read_store` hard-fails on it, and that sits on
-      the path of nearly every account-aware command. Temp names fold in the
-      pid, wall-clock nanoseconds and a per-process counter, since there is
-      no locking anywhere in this codebase and a fixed name would hand two
-      concurrent invocations a new collision to replace the old one. Does
-      **not** touch the separate unlocked read-modify-write race `read_store`
-      and `write_store` describe — that one needs locking, not atomicity, and
-      is the item below
-- [x] `read_store`'s corrupt-JSON error now says so and names the fix — move
-      the file aside and log in again — distinct from the read-failure
-      message a missing file or a permissions error still gets. A truncated
-      sessions.json used to hard-fail with `serde_json`'s bare parse error
-      and nothing pointing at `rm ~/.config/atgc/sessions.json` as the way out
-- [x] Hypothesis 2 is closed: one advisory lock (`~/.config/atgc/.lock`,
-      `flock(2)`, `crate::lock`) now covers every read-modify-write of both
-      files, held across *both* halves rather than around the write.
-      `write_store` and `account::save` take a guard argument they never
-      read, so a write not computed under the lock is a compile error, and
-      the registry's four mutators go through one `update` closure instead of
-      four load/edit/save pairs — losing `client_id` there costs the session,
-      not a cached handle. Reads stay unlocked: `write_atomic` renames, so a
-      reader sees one whole file or the other
-- [x] The refresh race needed the lock held across a *network* round trip,
-      which nothing wrapped around the store can do — the section spans three
-      store calls with a token request between them. Vendored jacquard-oauth
-      gains a defaulted `ClientAuthStore::lock_for_refresh` hook (no-op for
-      every other implementor) that `get_refreshed` takes before the read and
-      holds past the write; a process that queued then re-reads and finds a
-      token it does not have to spend. Report upstream — see
-      the note beside the patch in Cargo.toml for the shape and the alternative weighed
-      against it
-- [x] `prune_stale_sessions` no longer deletes every `oauth-state:` key it
-      sees, and a single-account `auth logout` no longer does either (only
-      `--all` still does). Those entries hold *another* invocation's PKCE
-      verifier: dropping one makes a concurrent login fail at its callback
-      with nothing to match the returned `state` against. `login` now cleans
-      up after its own abandoned attempts instead — the timeout and
-      token-exchange-failure paths, since jacquard already deletes the state
-      on success. Residual leak, deliberately: a login killed outright leaves
-      its entry until `auth logout --all`. Now measured rather than
-      predicted, and it is bigger than "a few hundred bytes" suggested: this
-      machine's `sessions.json` reached 18,825 bytes against the 8,869 this
-      file records for two accounts, and 3 of its 6 top-level keys are
-      abandoned `oauth-state:` entries. Each carries a PKCE verifier and a
-      DPoP private key, so this is dead key material sitting beside live
-      credentials, not just dead weight — which is a better argument for
-      expiring them by age than the disk cost ever was.
-
-      Postscript, and the reason the measurement kept getting worse: the
-      cleanup this entry describes had never once run. `discard_auth_state`
-      takes the state from `state_param`, which reads the authorization URL,
-      and under PAR there is no `state` in the URL to read — so every call
-      returned at its first line. Six abandoned entries against three live
-      sessions by the time it was noticed, and not one `auth_state_deleted`
-      in four hundred logged events. Fixed under the `auth login` entries
-      above; expiring by age is still worth doing for the entries a killed
-      login leaves, which no amount of cleanup-after-yourself reaches
-- [ ] Failing to take the lock fails the command — it never proceeds
-      unlocked. Weighed deliberately: an agent waiting a few seconds notices
-      nothing, while an unlocked read-modify-write can delete a live session,
-      which costs a person a browser login and is invisible at the moment it
-      is caused. The cost is that the lock is now a hard dependency, so a
-      `$HOME` on a filesystem where `flock` returns `ENOLCK` rather than
-      merely blocking (some remote mounts) fails these commands outright
-      instead of degrading. Nobody is known to run that way; if someone does,
-      the answer is a real fix for that filesystem, not a flag that turns the
-      exclusion off
-- [x] **Most writes to `sessions.json` were neither atomic nor locked.**
-      `write_atomic` had two callers; everything else reached disk through
-      jacquard's `FileTokenStore::set_value`, which is `read_to_string` then
-      `std::fs::write` — a truncate in place of the live credential file.
-      Counted against this machine's own `oauth.jsonl`, over 4,536
-      invocations: 1,090 `session_upsert` against 12 `store_write`, and 1,010
-      of those upserts carried an unchanged access token. Shipped as
-      `auth::store::SessionStore`, a `ClientAuthStore` over `read_store` and
-      `write_atomic`, so there is now one pathway and every write lands by
-      rename. No format change and no migration, as predicted:
-      `jacquard::client::token` is a `pub mod` and `StoredSession` is
-      externally tagged, so the records are byte-for-byte the ones jacquard
-      wrote and an `atp:` key survives a write it is not part of. The file is
-      compact now rather than alternating with jacquard's pretty form; both
-      parse, and a pretty fixture is pinned in a test so no existing store
-      breaks.
-
-      The diagnosis of those 1,010 was wrong in a way worth recording. They
-      are not one-per-round-trip nonce bookkeeping: `OAuthClient::restore`
-      ends with `create_session` handing `SessionRegistry::set` the record it
-      has just read, so they are **one per authenticated invocation, writing
-      back bytes identical to the ones on disk**. So the answer is not a
-      cheaper write but no write — `SessionStore::put` compares the
-      serialized record against what is already at that key and returns
-      without touching the file. Confirmed against the real store: `auth
-      status` and `doctor` now leave `sessions.json` at the same inode and
-      the same checksum, where every such command used to rewrite it
-- [x] **The one pathway was atomic but still unlocked.** `SessionStore`
-      reached `write_store_at` directly rather than through the function that
-      requires a `config::lock::Guard` as a proof obligation, so jacquard's
-      writes — which is most of them — were atomic and took no lock, exactly
-      as they had before. A whole-file rewrite can drop another process's
-      session whatever prompted it, which is the thing the lock exists for.
-
-      The obvious fix self-deadlocks, as this entry said: `get_refreshed`
-      calls `upsert_session` and `delete_session` *inside* the guard
-      `lock_for_refresh` hands it, and `flock` is per process, so a lock
-      taken unconditionally in the write path waits out `WAIT` against this
-      process's own hold and then fails — on the one write that carries
-      freshly rotated tokens. So it is a re-entrancy story after all:
-      `take_reentrant` publishes the section's context in a `HOLDER`, and
-      `take_or_inherit` returns `Hold::Inherited` to a write already inside
-      it and `Hold::Owned` to everything else.
-
-      The hole this entry named — "the lock is already held" being a fact
-      about the *process* rather than the call chain — is closed by what
-      `HOLDER` stores: `tokio::task::try_id()`, so a second task writing
-      while a refresh is in flight does not match and takes the lock
-      properly. The `Option>` is deliberate twice over: the inner
-      `None` is a future polled by `block_on` rather than a spawned task,
-      which is where atgc's commands actually run, and matching `None`
-      against `None` is sound because there is one `block_on` context per
-      runtime and `put`'s read and write have no `.await` between them.
-      Both halves are pinned by tests, the inheriting one timed so that
-      "inherited" cannot pass as "waited thirty seconds and then inherited"
-- [x] Whether a nonce-only update takes the lock, settled: **yes, when there
-      is one — but the case that mattered no longer writes at all.** The
-      exclusion was never about the nonce; it is about the whole-file
-      rewrite, which can drop another process's session whatever prompted it,
-      so a write that changes a nonce is exactly as dangerous as one that
-      changes a token and gets the same treatment. What made the question
-      urgent was the measured 1,010, and those turn out to be no-op
-      write-backs rather than nonce updates, so they are elided instead of
-      locked. The cost argument the ledger worried about — a `flock` on every
-      PDS round trip — does not arise: what is left is one lock per write
-      that changes something, which is roughly one per login and one per
-      refresh
-- [x] `~/.config/atgc` is created at the umask default. `config_dir` calls
-      `create_dir_all`, so the directory is 0775 here while every file in it
-      is 0600; nothing is exposed only because `~/.config` happens to be 0700
-      and the primary group is private. The code relies on the parent's luck
-      rather than on its own choice. `DirBuilder::mode(0o700)` settles new
-      installs, and every existing user is already in the wide state, so it
-      wants a repair as well as a fix — both shipped, and the repair says so
-      on stderr rather than happening quietly under somebody's home directory
-- [x] A first-login window where the store was world-readable.
-      `FileTokenStore::try_new` wrote `{}` at the umask default and
-      `auth_store` chmodded to 0600 afterwards. The file is empty in that
-      window — but every later write truncated the same inode, so a local
-      process that opened a descriptor during it kept reading credentials
-      indefinitely. Closed as the predicted side effect of the entry above:
-      nothing creates the file eagerly any more, and each write lands on a
-      new inode, so a descriptor from that window no longer follows the file.
-      Pinned by a test asserting the inode changes between two writes and
-      that both are 0600 — the inode is the assertion that matters, since a
-      mode check alone would pass against an in-place write
-- [x] `XDG_CONFIG_HOME` is not read. `config/dir.rs` spells
-      `$HOME/.config/atgc` and nothing in the tree looks the variable up.
-      Three lines, and deliberately without `dirs`: its `config_dir()`
-      returns `~/Library/Application Support` on macOS, which would move
-      every existing user's state — a worse bug than the one being fixed. The
-      spec says a relative value is ignored, which is worth honouring rather
-      than joining. Shipped; a relative value is ignored out loud, and setting
-      the variable over an existing login is answered with where the accounts
-      still are rather than only with "no account is logged in"
-- [x] Nothing ever says the credential store has gone readable. `doctor`'s
-      session row checks liveness only, and `auth_store`'s chmod is
-      incidental repair on a path that `auth status` and `doctor` never take.
-      Shipped as `doctor`'s `config dir` row: the directory's mode and every
-      file's, whatever another local user can reach, and one `chmod go-rwx`
-      that covers all of it. Warn and never error, because a readable store
-      is a confidentiality problem and not a broken install
-- [x] `OpenOptions::create(true)` with `.mode(0o600)` silently ignores the
-      mode when the file already exists, so a file inherited at 0644 kept it
-      for good — and `ATGC_OAUTH_LOG` pointed at an existing file is a
-      supported way to arrange exactly that, for a log naming DIDs, working
-      directories and the local user. `logging/file.rs`'s `open_sink_at` now
-      calls `narrow_if_wide` on the descriptor it just opened: read the mode,
-      and `fchmod` away group and other only when they are set. Through the
-      fd rather than the path, so nothing swapped in between is what gets
-      chmodded; owner bits preserved, so an append-only 0200 log stays 0200;
-      and a file that could not be opened is left alone, there being no
-      descriptor to act on. Verified against a real 0644 file under
-      `ATGC_OAUTH_LOG`.
-      `config/lock.rs:219` is deliberately left alone — the lock file holds
-      nothing, atgc is the only thing that creates it, and the `config dir`
-      row now names it with a chmod if it is ever found wide. Doing it
-      properly is `create_new` and then a plain `open` on `AlreadyExists`,
-      which is two syscalls and a new race on a path taken for every lock
-- [x] `auth_store` chmodded `sessions.json` to 0600 as incidental repair, on
-      a path `auth status` and `doctor` never take — `auth_store` is the
-      OAuth client's entry point, and every command that only *reads* the
-      store names the file without going near it. The repair is now
-      `config::dir::narrow_file_if_wide`, beside the directory's own and in
-      the module every caller reaches, and it is called from `store_path`,
-      which nothing can skip: `auth status` on a 0644 store now says so and
-      narrows it. Three changes of shape came with the move: only when the
-      file is actually wide, rather than a chmod on every call; the owner's
-      own bits kept, so a deliberately read-only store does not gain a write
-      bit; and a refused chmod is a warning rather than a failed command,
-      since a store on a read-only mount should still be readable
-- [ ] The DPoP private key sits in the clear beside the token it protects.
-      Each session in `sessions.json` carries the access token, the
-      single-use rotating refresh token and the P-256 key as a JWK, so
-      proof-of-possession buys nothing against anyone who can read the file.
-      Measured: an access token lives 60 minutes and the session two weeks
-      (public loopback client), and the scopes include
-      `rpc:sh.tangled.repo.delete?aud=*`
-- [ ] **The OS keyring was researched and rejected**, written down here so
-      nobody does it twice. `keyring` 3.x and 4.x both reach macOS through
-      the *legacy* `SecKeychain*` API, whose ACL is keyed to the binary's
-      designated requirement — an unsigned or ad-hoc-signed binary gets a new
-      code hash on every build, so every rebuild re-prompts and "Always
-      Allow" does not survive one. The data-protection keychain that fixes
-      that needs a provisioning profile, and apple-native-keyring-store's own
-      README says there is little point in a command-line tool using it.
-      Headless is worse than a prompt on both platforms: macOS over SSH
-      returns `errSecInteractionNotAllowed` (-25308), which `keyring` does not
-      map and so surfaces as an opaque `PlatformFailure`; Windows over SSH
-      gets a network-class logon with no credential set and fails with
-      `ERROR_NO_SUCH_LOGON_SESSION`, the same limitation Git Credential
-      Manager documents. Windows also caps a credential blob at 2,560 bytes
-      and `sessions.json` is 8,869 for two accounts, while the kernel
-      keyutils backend has a 20,000-byte per-user quota that those two
-      accounts would already fill to 44%. atgc goes non-interactive off a
-      terminal by design, so a store that prompts or blocks is a regression
-      in the case that matters. The work belongs in hardening the file —
-      atomic, locked, 0700 — not in a keyring.
-
-      Re-checked when the atomic-write work landed, because it is the
-      question anyone arriving at this file asks first. It still holds, and
-      the size argument has got stronger rather than weaker: this machine's
-      store is now 18,825 bytes for three accounts, which is **7.3x the
-      entire Windows credential-blob cap** and **94% of the whole keyutils
-      per-user quota** — for one user, before a fourth account. A store that
-      cannot hold the credentials is not a store, and no amount of prompting
-      behaviour fixes that. Nothing in the rest of the entry has changed
-      either, so the conclusion is unchanged: harden the file
-- [ ] The `auth` subsystem still has no flow test. The store now has unit
-      coverage — the elided write, the new inode and mode per write, the key
-      spellings `logout` filters on, and a pretty-printed fixture standing in
-      for every store written by a previous version — but nothing exercises
-      `login`, `switch`, `logout`, `refresh` or `token` end to end, which is
-      a large part of why these defects lasted. That wants `tests/support/`
-      rather than unit tests: `Command::env` gives a child its own `HOME`,
-      and jacquard restores a fixture session with no network. Whoever takes
-      it should read `tests/support/mod.rs`, `tests/support/account.rs` and
-      `docs/testing.md` first — this was left undone for want of those, not
-      because the coverage is unwanted
-- [ ] `auth logout` still does not revoke, recorded above as deliberate and
-      worth revisiting now the session cap is measured at two weeks: the
-      revocation endpoint is stored and the vendored crate's
-      `OAuthSession::logout` calls it, so this is a request rather than a
-      design problem. A logout that leaves a two-week credential live on a
-      machine somebody has finished with is the case against
-- [x] A token endpoint's error body reaches the terminal unscrubbed. The rule
-      is stated in `logging/oauth.rs` and applied to `oauth.jsonl` alone:
-      `auth.rs` dumps `{e:#?}` to stderr under `--debug` on the same value
-      the next line passes through `scrub_text` on its way to the log, and
-      two `bail!` sites interpolate `{e}` into text a person sees with no
-      `--debug` at all. jacquard's `RequestError::HttpStatusWithBody` embeds
-      the response body in `Display` and `OAuthError::Request` is
-      `#[error(transparent)]`, so it arrives whole. Making `dump_err` itself
-      scrub makes the guarantee structural rather than per-caller, which is
-      what `Fp` already does for the log
-- [x] One `oauth.jsonl` event is written unscrubbed: `AuthorizeFailed.error`
-      is clipped but not passed through `scrub_text`, where both its siblings
-      are. Narrow — a public client's PAR body holds no long-lived credential
-      — but it is a hole in the one invariant that file exists to keep, and
-      the `Fp` type cannot cover a plain `String` field
-- [x] `scrub_text` under-matches, and its doc claims the opposite. It redacts
-      quoted values only: traced against `access_token=abc123`, the `=` and
-      the alphabetic run are skipped, the scan halts on a digit that is not a
-      quote, and the value survives. Form-encoded shapes reach `Debug`
-      through `RequestError`'s `url` field. Blank an unquoted value to the
-      next `&`, whitespace, `,` or `}` — and fix the sentence that says it
-      over-matches rather than under-matches
-- [x] `--debug` logs one raw `oauth-state` nonce, where every other site
-      fingerprints it through `redact_key`
-- [ ] The XRPC error paths quote their errors to the user the way the OAuth
-      ones did — `knot.rs` and `record.rs` interpolate `{e}` into `anyhow!`
-      text with no scrubber. A PDS or knot error body is not known to echo
-      credentials the way a token endpoint's does, and `--debug` covers the
-      dumps either way now, so this was left rather than widened into a
-      change nobody could review
-- [x] Any local process could end a login. The loopback listener bailed on
-      the first callback without a `code`, so anything able to reach an
-      ephemeral port on 127.0.0.1 could make `auth login` unusable by
-      scanning. A nuisance rather than a hijack — `state`, PKCE and `iss` are
-      all checked in the vendored client and the listener is loopback-only.
-      It now answers a request that does not carry this login's `state`, logs
-      it as `ignored`, and goes back to accepting until the five-minute
-      timeout. A *denial* carrying the right state still ends the login at
-      once: the person pressed Deny and should not sit through the wait.
-      Verified against a real bsky.social authorization, three scans and then
-      the genuine article
-- [x] **The login had no way to know its own `state`.** `state_param` reads it
-      out of the authorization URL, and under PAR — which every server atgc
-      has met supports — the parameters go in the POST body and the URL is
-      `client_id` plus a `request_uri` and nothing else. So it answered `None`
-      on every real login, which is why the entry above had nothing to
-      discriminate on and why `discard_auth_state` returned immediately every
-      single time. `state_written_by` diffs the `oauth-state:` keys either
-      side of `start_auth`: jacquard files the PKCE verifier and the DPoP key
-      under `oauth-state:`, so this login's state is the key that was
-      not there a moment ago. Two appearing at once is not attributed to
-      either — leaking an entry costs a few hundred bytes, deleting another
-      process's is a login that fails at its callback. `state_param` stays as
-      the fallback for a server with no PAR endpoint
-- [x] The third exit from the login wait had no cleanup. A timeout and a
-      failed token exchange both called `discard_auth_state`; a callback that
-      arrived and was unusable — a denied consent — returned straight through
-      the `?`. Now all three do
-
-## pr
-- [x] `pr create` — patch-based PRs with --dry-run; DID resolved from the
-      remote's git redirects
-- [x] `resolve` returns the *repo's* DID, not the owner's. It used to take
-      the first `did:` segment in the path, so a remote spelled
-      `tangled.org//` handed back the owner's account DID and
-      every `pr` command silently asked the appview about the wrong subject.
-      The rule now is that a DID naming a repo by itself is the repo's and a
-      DID with a repo name after it is an account's — verified against the
-      live appview and knot1 for all four path shapes. `repo clone`'s
-      "discard a DID equal to the owner" workaround is gone with it
-- [ ] `resolve` refuses `//` rather than resolving it.
-      The knot serves that path, but answers it directly instead of
-      redirecting, so there is no repo DID to read out of a `Location`
-      header and nothing left to follow. It is not a remote Tangled hands
-      out and `repo clone` won't parse one either, so this is a loud error
-      where it used to be a silently wrong DID. Fixing it properly means
-      asking the knot or the appview what repo that path names
-- [x] `pr create` — records `source: {branch}`. The reasoning that stood
-      here was wrong on a checkable fact, so it is corrected rather than
-      quietly dropped: this is **not** "the shape every pull request written
-      by anything other than atgc carries". Tangled's own web UI passes
-      `pullSource = nil` for a patch-based pull
-      (`appview/pulls/create.go`, `handlePatchBasedPull`), and only its two
-      branch shapes write a `source` at all. What the field buys atgc is
-      real — it is the only thing matching a pull back to the branch it came
-      off, which `pr view` needs — but the appview reads it as a claim and
-      not as a hint. See the entry below
-- [x] `pr create` pushes the branch by default, with `--patch-only` for the
-      shape it has today. Read off Tangled core at master `1adde466` rather
-      than guessed, so the ground was settled before the work started.
-
-      **There is no patchless pull**, and there still isn't:
-      `lexicons/pulls/pull.json` requires `rounds` and every round requires
-      `patchBlob`, so the patch is gzipped, uploaded and put in the round
-      exactly as before. What changed is who computes it and what the record
-      is then entitled to claim.
-
-      The default pushes the branch to the target's knot over SSH, `GET`s
-      `sh.tangled.repo.compare?repo=&rev1=&rev2=` —
-      a public query, no scope and no session — refuses an empty comparison
-      as the appview does, and writes `source: {branch}` over a branch that
-      is now really there. `--patch-only` keeps the old local
-      `git format-patch` and writes **no** `source`, because a `source` is
-      exactly the claim that a branch is on the knot and the appview never
-      checks it.
-
-      Two things learned in the doing, both cheap to get wrong. The knot
-      serializes `types.RepoFormatPatchResponse` with Go's names, not the
-      lexicon's: the mailbox arrives as `patch` and the per-commit list as
-      `format_patch`, and every field is `omitempty`. And a scenario can take
-      a real push without a git transport on the mock knot — the checkout
-      carries `url..pushInsteadOf`, which rewrites the push target and
-      leaves `git remote get-url` alone, so the repo DID is still resolvable
-      out of the remote path. Plain `insteadOf` would not have worked;
-      `get-url` applies it
-- [x] `stack create` pushes the branch and keeps `source: {branch}` on every
-      member, still formatting each member's patch locally. Read off Tangled
-      core at master `e4eb96e9`, and the read made the choice rather than
-      narrowing it.
-
-      The claim was never the wrong *shape*: `newStack` in
-      `appview/pulls/create.go` gives every member of its own stacks the same
-      `pullSource` while storing one commit's `fp.Raw` as that member's patch.
-      A member's `source` says where the stack came from; its patch says which
-      commit of it. So there was nothing to fix in the record — only the push
-      that makes it true, exactly as for the flat pull.
-
-      Dropping `source` was never open, which the read settled in one pass.
-      `resubmitCheck` in `appview/pulls/single.go` compares the *top* member's
-      sha (the stack is stored top-first) against the branch's live head;
-      `resubmitBranch` routes the web's resubmit through `repo.compare` and
-      re-splits the range by change-id — it dispatches on `IsBranchBased`
-      before it ever looks at the stack, so a sourceless member degrades to a
-      paste-a-patch box for one commit; `compose.go` will not compose a
-      stacked pull that is patch-based at all
-      (`isStacked := mode == "stack" && !isPatchBased`). And closer to home,
-      `read::for_branch` matches on `source.branch`, which is how `stack
-      view`, `stack resubmit` and `stack merge` find a chain — a sourceless
-      stack is one atgc could never pick up again. Hence no `--patch-only`
-      here: a push that fails refuses and points at `pr create --patch-only`.
-
-      The compare is still asked for, demoted from patch source to proof: an
-      empty answer refuses as `handleBranchBasedPull` does, a count that
-      disagrees with the plan is a note, and `sourceRev` needs nothing written
-      at all — `PullSubmissionFromRecord` derives it from the blob's last
-      patch, so the top member's sha becomes the branch head for free.
-
-      One thing learned that nothing above predicted:
-      `knotserver/git/diff.go` adds the `Change-Id:` header to a knot's
-      format-patch only from a commit object's jj `change-id`, never from a
-      `Change-Id:` trailer. So a `--add-change-ids` branch makes a stack the
-      *website* cannot resubmit — the appview finds no ids in what the knot
-      hands it. `stack create` says so when the compare shows it, and
-      `stack resubmit` is unaffected because it injects the headers itself
-- [ ] `stack resubmit` should re-push the branch, and today does not — the
-      sibling of the `pr resubmit` entry below and left out for the same
-      reason: one PR, one thing. Now that `stack create` publishes, a
-      reconcile after an amend leaves the knot holding the pre-amend branch,
-      so `resubmitCheck` says "should resubmit" forever and the tree link
-      describes commits no member's patch matches. The push itself is one
-      line; what the entry has to settle is force, since every reconcile this
-      command exists for rewrites history and a plain push will be refused
-- [ ] `pr resubmit` on a branch-based pull should re-push and re-compare,
-      and today does neither. Deliberately left out of the `pr create` change
-      so that one PR did one thing, and it is a real gap rather than a tidy
-      one: after a resubmit the record's newest round is the local patch
-      while the branch on the knot is whatever was last pushed, so the tree
-      link and the diff describe different code. The shape is already there —
-      `push_and_compare` in `cmd/pr/write.rs` is the whole of it — and the
-      question the entry has to answer first is how a resubmit knows which
-      shape it is appending to, since the pull record says (`source` present
-      and no `source.repo`) but a `--patch-only` pull must not suddenly grow
-      one
-- [ ] `stack create` writes `source: {branch}` on every member, which is the
-      same untrue claim `pr create` just stopped making, and it cannot be
-      fixed the same way. A stack member's patch is one commit's
-      `format-patch` carrying the `Change-Id:` header the appview correlates
-      rewrites by, and `repo.compare` answers about a *range* — there is no
-      per-commit compare on a knot to take those bytes from. So the choice is
-      narrower than it was for `pr create`: push the branch and keep writing
-      `source` while still formatting each member's patch locally, or drop
-      `source` from stack members and lose the branch match. Neither is
-      obvious, and picking one wants the same read of `appview/pulls/` that
-      settled the entry above
-- [x] `pr resubmit` on a branch-based pull re-pushes and re-compares, so
-      the newest round and the branch on the knot describe the same code
-      again. Which shape a round follows is read off the record and never
-      off a flag — `source` present and no `source.repo` is branch-based —
-      which is why there is no `--patch-only` on `pr resubmit` to match the
-      one on `pr create`.
-
-      The three awkward cases, decided. A `--patch-only` pull stays
-      patch-only forever: a round that grew a `source` would start claiming
-      a branch the pull's earlier rounds never published, and the appview
-      believes `source` without checking it, so the pull would look
-      branch-based back to its first round. Reopening with `pr create` is
-      the way to change shape, and it is honest about it. A fork-based pull
-      (`source.repo` set, which only the web UI opens) is refused: the
-      compare has to run on the fork's knot, which is the same thing that
-      keeps `pr create` from opening one. And a round raised from a branch
-      other than the one the record names is refused rather than resolved
-      either way round — pushing the checked-out branch under the recorded
-      name moves a branch nobody asked about, pushing the recorded name from
-      here sends commits the round never read — which is also the answer for
-      a source branch that no longer exists locally: fetch it from the knot
-      and check it out
-- [x] a round republishes its branch with a lease, which is what settled the
-      force question `stack resubmit` was waiting on. Both resubmits push
-      `--force-with-lease=:`, and the sha is read off the last
-      round's own patch — the `From ` line git and the knot both write
-      (`knotserver/git/diff.go` runs plain `format-patch`, no
-      `--zero-commit`) — never off the remote-tracking ref, whose argument-
-      less lease a `git fetch` quietly disarms by advancing it onto the
-      commits worth protecting. So a rewritten branch lands without a manual
-      force-push, and a branch somebody else moved refuses with both shas
-      named, before anything is sent. `stack resubmit` pushes at all now,
-      including on the no-op path, since records that already match an
-      unpushed branch are the state it has to repair. With no lease to be
-      had — a create, or a patch that names no head — the push is unleased
-      and fast-forward-only, which is what it always was
-- [ ] the branch a `--patch-only` pull came off is recorded nowhere, so
-      `pr view`, `browse --pr`, `pr diff` and `pr checkout` cannot find it
-      from the checkout it was opened in. `pr create --patch-only` says so
-      and prints the at-uri, which is the honest stopgap and not an answer.
-      The obvious candidate is still a trailer in the patch beside
-      `Change-Id:`, and the reason it was not just done is that
-      `read::for_branch` matches over *listing items*, which hold a blob CID
-      and not the patch — so matching on a trailer means fetching a blob per
-      candidate. Worth measuring before building: the candidate set is one
-      account's pulls on one repo, which is usually small
-- [ ] fork-based pulls, the third shape and the one `pr create` deliberately
-      does not implement. Harder than the branch shape in three separate
-      ways: `sh.tangled.repo.hiddenRef` on the *fork's* knot under service
-      auth, push access on the fork rather than on the target, and the
-      compare run there rather than here — and the appview only recognises a
-      fork it already knows (`db.GetForkByRepoDid`), so a fork atgc created
-      by other means does not qualify. `--patch-only` is the answer until
-      this exists
-- [ ] Attach commit bodies to the PR body, which used to ride on the end of
-      the entry above and is unrelated to any of it
-- [x] `pr list` — your own pulls straight from your PDS merged with Bobbin's
-      sh.tangled.repo.listPulls (api.tangled.org, ATGC_BOBBIN to override);
-      --state/--limit/--remote and --source auto|pds|bobbin; tolerates old
-      record shapes. Works with Bobbin down, and says specifically what
-      Bobbin is missing when the two disagree
-- [x] **both indexes are opt-in and the default is the PDS alone.**
-      `--source` is now a *set* — pds (default), bobbin, web, auto, or names
-      joined by commas — with ATGC_USE_BOBBIN=1 / ATGC_USE_WEB=1 adding one
-      to the default. pds/bobbin/auto keep their old meanings, so no spelling
-      that worked before means something different now; what moved is what
-      you get by saying nothing. Forced by the failure mode rather than by
-      the stall rate: a stalled index answers about a world hours old in the
-      shape and with the confidence of a fresh answer, which produced a
-      merged pull reported open, 16 pulls reported as 6, and 3 open pulls
-      reported as branches with no pull. The cost is real — a repo listing is
-      author-scoped by default and says so — and is the smaller of the two
-- [x] the branch → pull resolution behind `pr diff`/`pr checkout` was pure
-      Bobbin with no PDS side at all, which is where "no pull request found
-      for branch X" came from for pulls that were open and one listRecords
-      away. All four branch-inferring commands now share
-      `read::branch_pull_record`
-- [x] the index-only reads honour the same opt-in: `search` refuses (it is
-      the one question with no second source even in principle), `repo view`
-      skips the star count onto the zero it already treats as unknown, and
-      two `doctor` rows read n/a naming the opt-in
-- [x] the exception, deliberate: `stack resubmit`, `stack merge` and
-      `pr merge` read a listing to find a reason to *refuse*, and there a
-      source that sees less cannot refuse more. They keep every source, as
-      `Source::EVERY`. A stale row costs an unnecessary refusal, never a
-      merge that should not have happened
-- [x] `pr list` — other contributors' pulls still come only from Bobbin, and
-      nothing in the stack offers a second *API*: every pull query in the
-      monorepo is a Bobbin route, and the knot mirror serves only git
-      objects. Worked around, not closed: when the merge proves Bobbin's
-      listing stale (or Bobbin is unreachable), `clients/tangled/web/backfill.rs` scrapes the
-      repo's tangled.org pull listings — a separate index that has stayed
-      current through every Bobbin stall observed — and fetches the missing
-      records from their authors' PDSes. Repo scope only, budgeted, and
-      announced in the staleness warning when it fires. Now reachable as
-      `--source web` in its own right rather than only inside `auto`; beside
-      Bobbin it still runs on evidence, and without Bobbin it is the only
-      cross-account source there is, so it runs unconditionally
-- [ ] **Delete `clients/tangled/web/backfill.rs`** — it is a scrape of HTML that core#707 says
-      will be replaced wholesale by the Svelte frontend, papering over
-      Bobbin ingest stalls (3 service-wide in the 5 days to 2026-08-09,
-      one ~29h; no upstream issue filed yet) that Tangled is actively
-      working on. When Bobbin's listings stop stalling — or the moment the
-      new frontend breaks the scrape — remove the module, the `gather`
-      block marked HACK in pr/read.rs, and the pub(crate) escapes on
-      review.rs's listing_url/numbered_titles/pull_uri_from_appview
-- [x] state `?` is now settled from the web index when it can be: the
-      backfill walk hands back the states it proves in passing (free), and
-      `pr view` runs a targeted title-narrowed, uri-confirmed lookup for
-      its one pull (bounded at 4 candidate pages, auto source only). Both
-      probe runs showed agents hedging open-vs-closed on `?` or fetching
-      the pull page by hand; now atgc does the honest version itself
-- [x] the "no commits on " refusal from `pr create`/`resubmit`
-      now says the submitted branch is the checked-out one and to check
-      the right branch out — the situation behind nearly every hit,
-      confirmed by four fresh-context agents reaching it from `main`
-- [x] a target branch that stops existing, at both ends. `pr create` warns
-      when the target is the source branch of a pull that is not merged or
-      closed — the shape that cannot survive its own base, since Tangled
-      merges by rebasing and the branch goes with it — off the listing the
-      stack warning already reads. `pr resubmit` asks `git ls-remote`
-      whether the target is still there and refuses when it is definitively
-      not; `refs/remotes//` outlives the branch it mirrors,
-      so the round used to be built on a stale tip and quietly re-contain
-      commits that had already landed. Only an empty answer from the remote
-      refuses, never an unreachable one. Reported by an agent that hit it
-      for real: a pull opened against an open stack's branch, stranded when
-      the stack merged, recoverable only by closing and reopening
-- [ ] `pr resubmit --target` / `pr edit --target` — retarget a pull in
-      place. The target is a field of a record in your own PDS, so this is
-      a plain `putRecord` and is what the stranded pull above actually
-      wanted; the reason it is not here yet is downstream, not local.
-      Tangled's own UI has no retarget, so how the appview renders a pull
-      whose earlier rounds are patches against a base they never applied to
-      is unproven. Wants a live test first, and probably has to write the
-      retarget and a fresh round as one operation, since a retarget without
-      one leaves the record naming a base its patches do not fit
-- [x] `pr resubmit ` — the last verb that would not take its pull
-      positionally, and so the last place the family's shape broke. Twelve
-      `pr` verbs and seven `issue` verbs against one `--pr `, printed by
-      `atgc agent` two lines above `pr edit 23`, which is where the two
-      spellings were most likely to be read as a distinction that means
-      something. `--pr` stays and is not deprecated: it was this verb's only
-      spelling for its whole life, so it is in scripts and in older notes,
-      and adding a spelling now costs nothing where removing one after 1.0
-      would be a breaking change. Naming the pull twice is refused, and
-      naming it neither way is now this command's own message rather than
-      clap's report of a missing flag
-- [x] `pr view ` — accept the same four pull spellings as its
-      siblings; it was the one command in the family that refused a
-      positional, and both fresh-context test agents typed `pr view 2`
-      at it. An unlisted-but-named pull renders from its record
-- [x] `pr view` — print the current branch's PR: state, title, author, body
-      and one line per round. Matches source.branch and stops there.
-      --web opens the repo's pulls page
-- [x] It used to fall back to your newest PR on the repo, which since atgc's
-      own PRs carried no source was the path they always took. From a second
-      worktree that reported another branch's merged PR in the format of a
-      right answer, and the `uri:` it printed was what got pasted into `pr
-      close`. `pr diff`, `pr comment` and `pr checkout` guessed the same way
-      and no longer do
-- [x] `pr view` — link to the PR itself instead of the repo's pulls page;
-      the pull number isn't in the record, so it comes from the appview, by
-      reading its pull listings and confirming the candidate's `data-aturi`
-      against the record. `pr create` and `pr resubmit` print the same link
-      for the pull they just wrote. Bobbin is not consulted and has no
-      numbers to be behind on, so this works while its index is stalled —
-      which is when someone is most likely to be looking for it
-- [x] `pr list` — a `#` column, from one sweep of the same listings for
-      the whole page rather than a confirmation per row: thirty pull pages at
-      up to 2.5 MB each is not a listing's budget. The rows are joined to the
-      listings on their titles, and a row is numbered where the title is
-      unique on both sides
-- [x] The rows that join leaves blank — pulls sharing a name, like this
-      repo's two "Flesh out .gitignore" — are then confirmed a page at a
-      time, capped at six and fetched together. A confirmation overrides the
-      join where they disagree, since it matched an at-URI on the pull's own
-      page against a guess that matched a title on a listing
-- [x] **that join was unsound and printed wrong numbers.** Two pulls sharing
-      a title swapped numbers between consecutive `pr list` runs (#194 and
-      #197, 2026-08-14). The walk stopped as soon as every row had a guess,
-      so "this title is unique" was asserted over a subset chosen for ending
-      early and a twin past that edge could not be seen; and the confirm pass
-      shortlisted only rows the join left *blank*, so a wrongly placed row was
-      the one row never checked. `walk_listings` now reports `Coverage`, a
-      join may be printed only under `Complete` — every listing read to the
-      end — and anything else is a hypothesis that is confirmed or left blank.
-      `number_for_record`, the one-pull path, always worked this way
-- [x] reading the listings out is the cheap half, which is why the fix is
-      affordable: a listing page is ~53 KB and states thirty numbers, a pull's
-      page is up to 2.5 MB and states one. Exhausting all three listings for
-      this repo's 165 pulls costs about 1.1 MB — less than confirming a single
-      row — so the walk budget went to 16 pages while the confirm budget
-      stayed at six. Measured end to end it came out *faster* than the four-
-      page walk it replaced, which had been spending its budget on 2.5 MB
-      pages to patch up a guess
-- [x] The listings are paged, so the newest thirty per state was never the
-      limit: `?offset=30` is the second page and an offset past the end
-      renders empty. Walked until the rows are placed, four pages per state
-      at most. Before this, every pull of this repo's under #24 was
-      unnumberable and `pr view` on one linked to `/pulls`
-- [x] The `/pulls` fallback says it is the fallback. A link to the listing
-      works as well as a link to the pull, so nothing about the URL told you
-      it had widened to the whole repo
-- [x] `pr create` and `pr resubmit` wait and ask again — 0.8s, then 2s —
-      because the number of a pull written a second ago is missing for the
-      one reason that fixes itself: the appview has not ingested it yet. Both
-      pulls that introduced this printed a bare `/pulls` link for exactly
-      that reason. `pr view` does not wait, having nothing to wait for
-- [x] `--json`'s `url` is the pull's own page or `null`, never the listing.
-      The note the text views print underneath has nowhere to go in an
-      object, and a link a caller cannot tell has widened is the same trap
-      one column further in. `pr view`, `stack view`, `pr create`,
-      `pr resubmit`
-- [x] `pr status` — a `#` column too. It spans repos and a number is only
-      unique inside one, so it is `pr list`'s sweep once per repo on the page,
-      with the repos run concurrently: one round trip rather than one each. No
-      flag, and no lookup that was not already being made — the repo label it
-      prints, `@owner/name`, is the URL its listings hang off. A repo whose
-      label could not be resolved has no listings to read and keeps blank rows
-- [ ] Pull state read from one PDS can only ever be the account's own half of
-      it. Bobbin accepts a status record from the pull's author or from the
-      target repo's DID; the repo's records are not public (its DID document
-      lists a knot service and no PDS, and the knot serves no record
-      endpoints), so a merge performed by somebody else is invisible until
-      Bobbin catches up. Such pulls print state `?` rather than a guess
-- [x] On your own repo it is not half of it — you are both authorities Bobbin
-      honours, so your records are the whole answer and an index that
-      disagrees is behind, not better informed. `pr list` printed `open` over
-      two pulls this account had closed; it now keeps its own status records
-      there, and reads them even when Bobbin answered for every pull
-- [x] The status walk is bounded by recency, not by a page count. A status
-      record cannot predate the pull it names and the listing is newest
-      first, so it stops once a page ends below the oldest pull on screen —
-      a page or two at any collection size. It read a fixed five pages
-      before, and past 500 status records dropped the oldest and warned, on
-      every `pr` command, that some states might read `?`
-- [x] The pull walk splits the same way. A screenful keeps the early stop and
-      the five-page cap; a caller concluding something from absence gets
-      neither. Past 500 pull records `stack create` refused outright on any
-      repo holding fewer than 100 of the newest 500, and — worse, because it
-      was silent — a repo with 100+ satisfied the early stop partway down the
-      collection and reported a *complete* listing missing everything older
-- [x] `pr status` — your PRs across repos, read from the subject's own PDS
-      rather than Bobbin: every record it wants lives in one repository, so
-      no index can be behind on it. Bobbin is still asked for states written
-      by other people and for comment counts, but is no longer required.
-      Target repo shown as owner/name, falling back to the appview's redirect
-      when Bobbin has not indexed the repo; --state/--limit, --author and
-      --source
-- [ ] `status pr` — the other half: PRs targeting your repos, split from the
-      ones you authored (was `pr status`; see the `status` section)
-- [x] `pr diff` — print a round's patch, straight out of the author's PDS.
-      `--round N` picks one, the latest by default. Needs no session: the
-      record and the blob are both public reads, verified against an empty
-      `HOME`. Takes a pull number, a record key, `/`, an
-      at-URI or a Tangled URL, and falls back to the current branch's PR.
-      Bounded: declared blob size checked before the fetch, decompression
-      capped by `--max-bytes`. Paged through `git var GIT_PAGER`, and bare
-      on a pipe so `atgc pr diff 23 | git am` works
-- [x] `pr diff --interdiff` — `git range-diff` between two rounds, built by
-      applying both to one base in a scratch `git worktree`. When the target
-      branch has moved it searches back along first-parent history for a
-      commit that takes both, confirming by applying rather than by probe;
-      when no such commit exists it prints nothing and says so, since a
-      partly applied series would produce a confidently wrong answer.
-      `--from N` picks the left-hand round, `--base` overrides the search
-- [ ] `pr diff --interdiff` cannot compare rounds of a PR that has already
-      been merged into the branch it targeted, because neither round applies
-      to anything on that branch any more. Pull request 23 is the worked
-      example. The rounds' real bases are commits the branch never stood at,
-      so nothing short of the author's original refs would find them
-- [x] `pr checkout` — `git am --3way` onto `pr//-r`, or
-      a fetch of the source branch when the PR is branch-based and the
-      branch is in the target repo. Refuses a dirty tree and an existing
-      branch; `--branch` overrides the name and it is checked with `git
-      check-ref-format`. A stopped `git am` is left stopped, with the four
-      ways out printed
-- [x] `pr checkout --worktree ` puts all of that in a `git worktree`
-      created at that path instead of in the checkout it was run from, which
-      is the shape this project actually works in — contributors and agents
-      live in `.claude/worktrees/`, and a reviewer with work in progress had
-      to stash it to look at anybody's pull request. The dirty-tree refusal
-      does not apply there and is not made: `git worktree add` does not touch
-      the caller's tree, and the fetch and corroboration that still run in it
-      read refs and objects rather than the tree. `--branch` composes, naming
-      the branch the worktree is put on. A path that already holds something,
-      or that is inside a *different* repo, is refused before the branch is
-      created — git checks the path only afterwards, and leaves the branch
-      behind. `.git/config` is shared with the original checkout unless
-      `extensions.worktreeConfig` is set, so the identity and
-      `core.sshCommand` carry over with nothing written twice (verified, not
-      assumed). `--json` reports the branch, the worktree path and which of
-      the branch and patch paths ran
-- [ ] `pr checkout` for a branch-based PR whose `source.repo` is a *different*
-      repo falls back to the patch. Fetching it means turning a repo DID into
-      a clone URL, which needs the knot from Bobbin's repo lookup
-- [x] `pr checkout` now corroborates a branch-based fetch before trusting it:
-      `git diff --no-color base...FETCH_HEAD` is compared, as text, against
-      the diff hunks pulled out of the round's own patch (mail envelope and
-      `format-patch`'s trailing version signature stripped, the way `atgc
-      stack resubmit` already had to before its own no-op check). Content
-      rather than shas or commit counts, because a rebase changes both
-      without changing what the branch does. A mismatch refuses rather than
-      checking out a branch that merely shares the pull's source-branch name
-      — stale, force-pushed, or simply somebody else's `claude/prek` — and
-      names `--patch` as the way past it: the record's own copy, not
-      whatever the name currently resolves to. No new flag for the opposite
-      case, trusting a branch the check refused; plain `git fetch` and `git
-      checkout -b` already do that, uncorroborated, same as ever. Two commits
-      editing the very same lines is the one series shape the comparison
-      does not reproduce faithfully, and is rare enough not to be worth a
-      scratch worktree and a real `git am` just to rule out
-- [ ] `pr diff`/`pr checkout` resolve a pull *number* by fetching the
-      appview's HTML page and reading the record URI out of it, because the
-      number is in no record and in no Bobbin response. It reads the page's
-      `data-aturi` record-identity widget first and falls back to requiring
-      every `sh.tangled.repo.pull` URI on the page to agree, so it refuses
-      rather than guesses — but it is still a scrape of a page that is not an
-      API, and pull 36 already showed the fallback failing on a page whose
-      diff quotes other pulls. Replace it if the appview exposes
-      number-to-URI
-- [ ] The reverse scrape — record to number, for `pr create`, `pr view` and
-      `pr list` — reads the same pages and is the same bet on the appview's
-      markup, now including `?offset=` meaning what it appears to mean.
-      Replace both if a lexicon ever carries `pull_id`. A repo with more than
-      four pages of one state still loses numbers for the oldest of them,
-      which is a ceiling atgc chose rather than one the appview imposes
-- [x] `pr comment` — **not** `sh.tangled.repo.pull.comment`, which is deprecated and whose creates the appview ingests as a no-op; the live record is `sh.tangled.feed.comment`, with a `subject` strongRef and a zero-based `pullRoundIdx` that is required when the subject is a pull (atgc's `--round` stays one-based, matching `pr diff`). No standing check: unlike pull statuses, neither the appview nor Bobbin filters who may comment
-- [x] `pr close` / `pr reopen` — state is a *log* of separate
-      `sh.tangled.repo.pull.status` records, not a field on the pull, so both
-      are an append of a new TID-keyed record and never a put or a delete
-      (verified against the appview's `stateWinner`, which orders by
-      `createdAt` desc then at-uri desc, and against its own web handlers,
-      which create a fresh record for close, reopen and merge alike). The
-      record goes in the *acting* account's PDS, so this works on a pull filed
-      against your repo by somebody else. Reads the current state from the
-      PDSes directly rather than from Bobbin, so a no-op is a no-op even while
-      the index is behind, and refuses to close or reopen a merged pull, which
-      would hide the merge
-- [ ] `pr close` / `pr reopen` act for the pull's author or the target repo's
-      owner only. The Go appview also honors any collaborator with
-      `repo:push`, but Bobbin has no ACL and honors only author-or-owner, so a
-      collaborator's close would show on tangled.org and be invisible in every
-      `pr list`. Needs a knot ACL query to do properly
-- [x] `pr edit` — title and/or body of your own PR, via put_record on the
-      record itself, leaving rounds untouched. `--body-file` (and `-` for
-      stdin) rather than an `$EDITOR` spawn, which would hang under an agent.
-      Author-only, unlike close/reopen: the pull record is in the author's PDS
-- [x] Images in PR bodies — `pr create` (which grew `--body-file` for it) and
-      `pr edit` upload each local path a body's `![…](…)` names as an image/*
-      blob (parallel, retries with backoff), rewrite it to the `blob+at://`
-      URI Tangled's renderer resolves, and list it in the record's `blobs`
-      array so the PDS keeps it. Relative paths never render on a pull page
-      (only READMEs get `/raw/` rewriting), so a path that names no file is
-      refused, not published. Stack bodies (from commit messages) get the
-      same treatment in `stack create` and `stack resubmit`, with new blobs
-      merged into a rewritten member's existing anchors by CID. `pr comment`
-      too: `sh.tangled.markup.markdown` turned out to carry its own `blobs`
-      array on the same image/*-at-1MB terms, so a comment body anchors
-      images exactly as a pull body does
-- [x] `pr resubmit` and `pr edit` send the record's CID as `putRecord`'s
-      `swapRecord` precondition, so two concurrent read-modify-writes no
-      longer both succeed with one silently discarding the other's change.
-      jacquard's `put_record` helper hardcodes `swapRecord: None`, so the
-      request is built by hand — worth reporting upstream alongside the
-      `client_id` bug
-- [x] Tangled pull *numbers* (`/pulls/23`) are not resolvable by any *query*.
-      The number is `pulls.pull_id`, allocated from a per-repo
-      `repo_pull_seqs` counter inside the appview's own database, and no
-      lexicon accepts or returns it. Every command takes one anyway, by
-      fetching the page the number names and reading its `data-aturi`. The
-      writes refused until they didn't: `pr close 23`, `pr reopen 23`,
-      `pr edit 23` and `pr resubmit --pr 23` now go through the same resolver
-      as `pr diff`, since opening the exact page a number names is the check a
-      person does by hand, and all of them print the pull before writing.
-      `pr close` and `pr reopen` grew a `--remote`, read only for a number,
-      because a number is only unique inside one repo
-- [x] `pr resubmit --pr` did not parse its argument at all — it took the last
-      `/`-separated segment and called it a record key, so a number reached
-      the PDS as the key `67` and came back "record not found" with no
-      explanation, and somebody else's at-URI reached it as their key against
-      *your* PDS. Now classified like every other reference, and a pull that
-      is not yours is refused by name rather than by 404
-- [x] `pr resubmit` — append a round (gzip patch) to an existing PR
-- [x] `pr merge` — knot XRPC (`sh.tangled.repo.merge`, `mergeCheck` first),
-      authorized by a service-auth token the PDS mints per call
-      (`crate::knot::xrpc`, the shared spelling of the dance `repo create`
-      grew inline). `repo_facts` finds the knot to call, off the owner's own
-      PDS or off the repo DID's document. After the
-      knot merges, a merged status record is written per landed pull — the
-      knot moved the branch, and without the records every listing keeps
-      calling the pulls open. A stacked pull is refused toward `stack merge`
-- [x] `pr merge`/`stack merge` are not owner-only after all, and the ACL
-      query this entry wanted is not needed to find that out. A knot
-      authorizes `sh.tangled.repo.merge` with `IsPushAllowed(actor, repoDid)`
-      and routes by `repo`, ignoring the `did`/`name` pair unless it is too
-      old for the `repo-did-input` capability — so the merge can simply be
-      sent, as tangled.org sends it, under the account merging rather than
-      the owner. The owner-record lookup that doubled as the check is now a
-      source of those two legacy fields, and the knot is found in the repo
-      DID's own document when this account holds no record for it. A refusal
-      comes back tagged `AccessControl` and is printed as what it is
-- [ ] `pr close` / `pr reopen` keep the author-or-owner rule, which is a
-      different trade: nothing refuses that write, so a collaborator's status
-      record would land in their PDS, show on tangled.org and be dropped by
-      Bobbin. Trying is free for a merge, where a knot answers; here it would
-      be a write that silently means nothing
-- [ ] `pr checks` — pipeline status for the PR's latest round
-- [x] `pr list --json` / `pr view --json` — machine-readable output, the
-      first any read command has grown besides `auth log --json`. Emits the
-      derived view (state, resolved handle, round count, appview number),
-      not the raw record: a pull's state and number are not fields on the
-      record at all, so printing the record back would omit the entire
-      reason to run the command. Unknown reads as `null`, never the `?` the
-      terminal columns print. Colour, hyperlinks and ellipsizing are off
-      unconditionally, not just off a tty. Field set is versioned like every
-      other flag (CONTRIBUTING.md); see docs/output.md
-- [x] `pr status --json` — the `pr list` row plus the repo it targets,
-      flattened in rather than nested, so a caller widening from one repo
-      to every repo reads `.state` and not `.pull.state`. A repo neither
-      Bobbin nor the appview can name reads `null` and keeps its DID: the
-      truncated DID the column falls back to is a display string
-- [x] `stack view --json`, `repo view --json`, `repo list --json` — done.
-      `stack view`'s members carry rkey, number and round count, which
-      `pr view --json`'s `stack.members` deliberately does not (there the
-      chain is a cross-reference; numbering it would be one appview sweep
-      per member). `repo view` counts come in pairs — `branches`/
-      `branches_exact` — because a full knot page means "or more", and
-      `stars` is null rather than 0 since Bobbin answers 0 for a repo it
-      never indexed
-- [x] `--json` on every other command: `auth status`, `key list`, and the
-      whole writing surface (`pr create/resubmit/edit/close/reopen/merge/
-      comment`, `stack create/resubmit/merge`, `repo create/clone/
-      configure/edit/default-branch/delete-branch`, `key add/delete`,
-      `report`). Writers all carry `dry_run`, and leave the identifiers a
-      dry run did not mint `null` rather than guessing a record key the PDS
-      chooses; the ones that can succeed writing nothing say so in
-      `changed`/`added`/`merged` instead of by omission, since both cases
-      exit 0. The shared rules — one value on stdout, notes on stderr,
-      decoration off at `hyperlink::stdout_escapes_wanted`, errors left as
-      plain text — live in `crate::term::jsonout` and in
-      docs/output.md
-- [x] Upload retries were classified by substring-matching the error's prose.
-      `images.rs`'s `transient` read any message containing `404` as a
-      permanent client error and everything else as worth retrying, so a byte
-      count, a port or a CID fragment carrying those digits abandoned an
-      upload, while a real 403 whose message did not spell them was tried
-      three times — `exit.rs`'s opening complaint, applied to a retry
-      decision. It now reads `AgentError::client_error()`'s typed status,
-      which covers all three shapes `process_response` produces: a status
-      other than 400 or 401 becomes `ClientErrorKind::Http`, a transport
-      failure becomes `Transport` with no status and is the one statusless
-      case worth retrying, and 400 and 401 are let through as the endpoint's
-      typed error with no `ClientError` attached, both permanent
-- [x] `Retry-After` was read nowhere in the crate, so a 429 was retried on the
-      fixed doubling schedule whatever the server asked for. The retry policy
-      now lives in `clients/http.rs` for the whole crate, and reads both
-      spellings the spec allows — delta-seconds and an HTTP-date — clamped to
-      `MAX_RETRY_AFTER`, thirty seconds, so a server asking for an hour cannot
-      hang a CLI. The blob upload is the one caller that still cannot see the
-      header: it goes through jacquard's `upload_blob`, and neither
-      `ClientError` nor `AgentError` keeps the response headers, so the value
-      is gone before atgc sees the failure. It runs the same budget, schedule
-      and status table as everything else, minus the header
-- [x] `pr list` and `pr list --all` resolved their columns one round trip at
-      a time: one `handle_from_did_doc` per unique author DID and one
-      `repo_name` per unique repo DID, both in plain `for` loops, so a page
-      from fifteen authors was fifteen serial DID-document fetches, and the
-      `--all` half worse — `repo_name` is up to three requests of its own.
-      Both are now `buffer_unordered` at `LABEL_CONCURRENCY`, the spelling
-      `images.rs` already used, joining `numbers_across_repos`'s `JoinSet`
-      two hundred lines away in the same file. The dedupe the loops did by
-      hand is `unique`, kept because one lookup per row would undo the point
-- [x] `newest` ordered records by comparing `createdAt` as a string, while
-      `Listed::instant` two hundred lines above exists precisely because that
-      does not work: PDS records stamp UTC and an index's come back in
-      whatever offset the writer used, and `…Z` and `…+03:00` do not sort
-      lexically against each other. It now parses, like the sort key it sits
-      beside, and breaks a tie on the raw string so a set with no usable
-      stamps still answers the same way twice. The reachable symptom was `pr
-      view` on a branch with more than one pull behind it, which matches
-      against the *merged* listing and so sees both formats at once. The
-      sibling comparisons in `pr`'s `state_of` and `latest_states` are fine
-      and stay — they read one PDS, so every stamp shares a writer and a
-      format; `cmd/issue/read.rs`'s `newest_state` is not covered by that
-      exemption, merging two accounts' records, and already parsed
-
-## stack (stacked pull requests)
-A Tangled stack is a chain of ordinary pull records, each `dependentOn` the
-pull beneath it. A member is a contiguous *run* of commits — nothing in the
-protocol ties one to a single commit — cut at the marks `atgc stack mark`
-records, and one per commit on a branch with none. Identity across rewrites
-is a `Change-Id:` mail header per commit in the round's patch. No stackId, no
-stack XRPC — the appview indexes the chain off the firehose and rejects
-would-be DAGs at ingest. Design settled 2026-08-09: `stack` commands bail
-with a pointer when the situation is not stacked; `pr` commands keep working
-on stack members and warn where the stack command is better. Revised
-2026-08-24: one-pull-per-commit was atgc's shape, not Tangled's, and it made
-agents file five pull requests for one change.
-
-- [x] Foundations: `dependentOn` named on the pull record (was preserved
-      opaquely in `Pull::extra`), per-commit git helpers (`commits_since`,
-      `format_patch_one`, `change_id` — jj commit header first, else
-      Gerrit-style trailer), and `record::batch` over
-      `com.atproto.repo.applyWrites` — atomic so a reconcile cannot
-      half-land, and one firehose event so a reorder never passes through
-      the transient two-pulls-on-one-parent state the appview rejects
-- [x] `stack create` — one pull per commit of `base..HEAD`, single-commit
-      patches each carrying a `Change-Id:` mail header atgc injects (checked
-      empirically: `git format-patch` does not surface jj's commit header,
-      so injection is the only path), chained bottom-up with TID rkeys
-      minted client-side, one applyWrites. Refuses a single-commit range
-      (that is `pr create`), a branch whose pulls already exist, and commits
-      with no change-id. `--add-change-ids` is the announced rewrite: pure
-      `commit-tree` (trees and authorship byte-for-byte, no conflict
-      possible), untouched prefix keeps its shas and jj headers, existing
-      ids are restated as trailers, and the reflog records the old tip.
-      Proven live 2026-08-09 against the atgc-stack-lab scratch repo: three
-      records in one applyWrites, ids minted by the rewrite, appview
-      indexed and numbered the chain
-- [x] `--add-change-ids` refuses when `HEAD` is not built on the base any
-      more. The rewrite is `commit-tree`, which reuses each commit's tree and
-      only changes its parent, so reparenting onto a base the tree never saw
-      turned every patch into a deletion of whatever that base had gained.
-      Observed 2026-08-13 on 0.15.0: a branch based on `55c4df0` was
-      submitted after `origin/main` had moved to `248281f`, and the rewrite
-      produced commits *parented on* `248281f` but carrying the pre-merge
-      trees, so every patch in the stack carried `src/exit.rs | 298 ----` and
-      the stack would have reverted a merged PR on landing. A real rebase
-      cannot do this — none of the commits touched that file, so replaying
-      them on a parent that has it keeps it — so this is tree-preserving
-      reparenting rather than a replay. Silent: the only tell was one patch
-      being five times its own dry-run size. Reported by an agent that hit it
-      for real (pull 183). Fixed in d04a596: `rewrite_with_change_ids`
-      (src/clients/git/patch.rs) bails unless `base` is an ancestor of `HEAD`,
-      and names the rebase that fixes it. Of the two options, refusing is
-      settled — rebasing on the caller's behalf was rejected because
-      `--add-change-ids` is announced as a rewrite that cannot conflict and a
-      rebase can, so the step that might stop and ask stays the caller's to
-      run. Covered by
-      `adding_change_ids_on_a_stale_base_never_produces_a_revert`
-- [x] `stack create`'s summary marks the change-id column as abbreviated. The
-      Change-Id trailer is a full 40-hex string and the summary printed only
-      the first 8, which reads like the whole id: nine characters of a
-      forty-one character value in a fixed column reads as the whole value,
-      and somebody rebuilding a branch by hand retyped what they saw. The
-      commits then matched no pull, `stack resubmit` reported "N open pull(s)
-      match no commit on the branch any more" and correctly offered
-      `--prune`, which would have deleted four pull records and every review
-      comment on them. Of the two options — print the whole id, or say it is
-      abbreviated — the second shipped: `ellipsize_change_id`
-      (src/cmd/stack/write.rs) appends `…` when it cuts, and its doc comment
-      says the ellipsis is the whole point of the function. `--json` carries
-      it whole, for a caller that needs the value rather than recognizes it
-- [x] A member holds as many commits as its change needs. One pull per commit
-      was read off jj's workflow and written into the docs as though the
-      protocol required it; it does not, and the cost was agents filing a
-      pull request per commit for changes that were one change. A member's
-      patch is now a mailbox with a `Change-Id:` header per commit
-      (`with_change_id_headers`), and the reconcile matches a record to a run
-      when any of the run's ids is one the record already claimed, so an
-      amend anywhere inside a member stays inside it. Every stack written
-      before this reconciles unchanged
-- [x] Where the members end is *marks*: local branches recorded per branch in
-      `branch..atgcMark`, placed by `atgc stack mark ` (named
-      after the commit it lands on) and read back by every stack write. The
-      first cut inferred them — any branch whose tip fell in the range — and
-      that is wrong in a working repository: `git branch backup` before a
-      rebase, or an abandoned worktree's branch, silently re-cut the stack.
-      `gh stack` reaches the same shape from the other side: a stack is
-      `init`ed and its layers `add`ed, never guessed at. With no marks left, the
-      cuts come back out of the records' change-id headers, so a fresh clone
-      still reconciles
-- [x] Not done, and it should not be: naming a mark after the member's
-      *bottom* commit, to match the pull title `stack create` takes from
-      there. The mark is named after the commit it lands on, which is the
-      member's top, so for a grouped member the branch name and the pull
-      title come from different commits. The fix does not exist: a member's
-      bottom commit is whatever the mark below it ends at, and that mark may
-      not have been placed yet — `stack mark part2 HEAD~1` after
-      `stack mark part1 HEAD~3` changes what part2's bottom is, retroactively.
-      Naming from the bottom would be wrong as often as it is right, and the
-      listings already print the subject next to the name, so nothing is
-      being hidden
-- [x] Seams the stack work left untested, now pinned: `stack view`'s `?` for
-      a member whose patch blob is gone, a mark whose branch was deleted,
-      `--add-change-ids` carrying the marks through the rewrite that moves
-      every commit under them, and `pr diff` on a member holding more than
-      one commit
-- [x] `atgc stack up`, `down`, `top`, `bottom` and `checkout` — move between
-      a stack's members. The marks already say where each one ends, and the
-      branches are already there; what was missing was the reverse index, so
-      standing on a mark could not say which stack it cut. `owners_of` reads
-      it back out of the config that recorded it, which is the second reason
-      marks are recorded rather than inferred. Walking past an end stops
-      there rather than refusing: `up` run until it stops is how a script
-      finds the top
-- [x] `atgc stack link` — chain pull requests that already exist, bottom
-      first, without reopening them. `dependentOn` is one field per record,
-      but until now the only way to write it was to build a chain from a
-      branch, so making three separately-opened pulls into a stack meant
-      cherry-picking them onto one branch, opening new pulls and closing the
-      originals: their numbers, rounds and review comments thrown away to
-      express an ordering. Written as one `applyWrites` because a chain built
-      a record at a time passes through a two-pulls-on-one-parent state the
-      appview refuses at ingest, and the order is checked against git wherever
-      both source branches are in the checkout
-- [x] `atgc stack rebase` and `atgc stack sync`. Marks live in git, so they
-      have to travel when the branch does: rebase replays with
-      `--update-refs`, which a plain `git rebase` does not, and sync is that
-      followed by the reconcile — the pairing `gh stack sync` and Graphite's
-      `gt sync` both ship. Sync stops after the rebase if the rebase stops,
-      because a reconcile planned against a half-rebased branch would write
-      rounds for commits that do not exist yet. The halves stay for the cases
-      that want one: an amend needs no fetch, a conflict needs no reconcile
-- [x] `stack create` refuses a cut that leaves one member, and asks before
-      opening a pull per commit on an unmarked branch of more than three.
-      The first was reachable with a mark on the top commit and wrote a pull
-      with no `dependentOn` — a pull request wearing a stack's command. The
-      second is the original complaint: eight commits became eight pull
-      requests with nothing said, and closing them is eight more acts
-- [ ] A member *above* an amended commit gains a round even though its diff
-      is identical: `git format-patch` puts the commit sha in a patch's first
-      line, a rebase gives every commit above the rewrite a new sha, and the
-      reconcile compares patch bytes. Defensible — the patch does now apply
-      to a different base — and pinned by
-      `amending_one_commit_appends_a_round_to_the_record_carrying_its_change_id`
-      so a decision to change it is a deliberate one. Whether Tangled's own
-      resubmit agrees is unchecked
-- [x] `stack resubmit` — reconcile the records with the rewritten branch by
-      change-id (read off each member's latest round patch, where alone it
-      exists): matched commits with changed bytes append a round, identical
-      bytes append nothing — one deliberate divergence from Tangled, whose
-      resubmit rounds every member; it is what makes a rerun a no-op, which
-      is also the partial-failure recovery. New commits create records,
-      vanished ones delete records only under `--prune` (a vanished *merged*
-      member becomes the chain's anchor instead — the new bottom keeps
-      depending on it), the chain is relinked to the new order, and it all
-      goes in one applyWrites. Refuses: members whose state cannot be pinned
-      as unmerged while Bobbin lags (for any destructive fate), updates to
-      merged pulls, stacks not authored by the acting account, and members
-      whose rounds carry no Change-Id header. The reconcile planner is pure
-      and unit-tested, and the write path is proven live against the
-      atgc-stack-lab scratch repo: no-op rerun, amend (rounds only where
-      bytes changed), insert + drop + relink + `--prune` in one batch, and
-      the prune refusal — all against real records
-- [x] `stack resubmit` treated a freshly created stack as untouchable for
-      hours: a pull nobody has acted on has no status record anywhere, so
-      every member showed `?` until Bobbin indexed, and the unknown-state
-      refusal fired on the stack's own author. Found live, first resubmit
-      after the first live create. When the acting account owns the target
-      repo, both authorities Bobbin honours for status records are that
-      account, so its own PDS is *complete* and `?` resolves to open;
-      without ownership the conservative refusal stands
-- [x] `stack create` put the Change-Id trailer into pull bodies: a commit
-      whose body was only its added trailer produced a pull whose entire
-      description was `Change-Id: I…`. Found live on the first created
-      stack. Bodies now drop Change-Id lines from the final trailer
-      paragraph — other trailers stay, and a Change-Id quoted mid-body is
-      prose and stays too
-- [x] `stack view` — the whole chain with numbers, states and rounds, top
-      first; bails on an unstacked pull, pointing at `pr view`. The chain is
-      assembled in `stack::chain_containing` by following `dependentOn` both
-      ways, refusing forks (which the appview rejects at ingest, so one in a
-      listing means damaged data) and loops rather than smoothing them over;
-      a link naming a record outside the listing is reported as "continues
-      below", not treated as the bottom
-- [x] `pr view` shows stack context implicitly. Every member shares
-      `source.branch`, so the branch match on a stacked branch used to pick
-      whichever member was created last and say nothing about the others — a
-      wrong answer in the shape of a right one. It now shows the *top* pull
-      as the detail and prints the chain under it
-- [x] `pr resubmit` refuses a stack member outright — the one refusal in
-      "pr commands work on stack members": a whole-branch round is one the
-      appview can read no change-id from, which breaks the stack's
-      correlation for every later reconcile, permanently. The check reads
-      only the author's own PDS, on which no index can be behind
-- [x] `pr create` warns (never blocks — the check is best-effort) when the
-      branch's pulls already form a stack, naming `stack resubmit` as the
-      way to update the stack itself
-- [x] `pr checkout` of a stacked pull applies the pulls beneath it first,
-      bottom up — walked record by record along `dependentOn` (public reads,
-      no index consulted), latest round each, `--only` for the bare patch.
-      The stacked apply runs `git am --empty=drop`, so a member already
-      merged into the target three-way-resolves to nothing and drops.
-      Proven live against atgc-stack-lab: mid-stack, top and `--only` all
-      produce the right trees
-- [x] The fresh-stack `?` resolution moved from `stack resubmit` into
-      `repo_rows`, so `stack view` and the guards see it too: an authored
-      pull in an owned repo with no status record anywhere is open, not `?`.
-      Scoped to authored-and-owned on purpose — anyone else's pull can have
-      state sitting in a PDS the read never saw
-- [x] `stack merge` — the sub-stack from the bottom `--through` a position
-      (default: all of it) as one combined patch, the way Tangled's own
-      button lands a stack: `mergeCheck` first (also what `--dry-run`
-      stops after), one `merge`, then a merged status record per landed
-      pull, bottom up. Already-merged members contribute nothing; anything
-      not cleanly open is refused. Proven live on atgc-stack-lab, both
-      halves: `--through 1` landed the bottom alone, and after a rebase the
-      resubmit reconciled the survivors around the merged member as the
-      chain's anchor — the one fate that had only unit tests — then a full
-      `stack merge` landed the rest. The complete lifecycle (create, view,
-      amend, resubmit, checkout, partial merge, anchor reconcile, final
-      merge) has now run CLI-only against real records
-- [x] Adversarial round against injected damage (fork: two pulls on one
-      parent; cycle; dangling dependentOn; duplicate change-ids). Found and
-      fixed: a damaged chain — records the appview refuses at ingest but a
-      PDS happily holds — bricked `pr view` *and* `pr create` for the whole
-      branch. Both now degrade: `pr view` warns and shows the newest match
-      flat, `pr create`'s stack warning is best-effort all the way down.
-      `stack view`/`stack resubmit` keep their loud, precise refusals — the
-      stack commands are where damage should stop someone. Duplicate
-      change-ids (a cherry-pick keeps the trailer, so this is an ordinary
-      accident) are now refused at `stack create` and on both sides of the
-      reconcile, naming every claimant; a HashMap was silently keeping the
-      last one, which would have routed rounds to the wrong pull. A deleted
-      mid-chain record shows as "continues below", named as a possibility
-- [x] Adversarial round two, against ordinary git shapes. A **merge commit
-      in the range** produced a silent nonsense stack: rev-list interleaved
-      both parents' histories, the merge itself has no single patch, and
-      the change-id rewrite would have flattened it with wrong intermediate
-      diffs — now refused by every stack write (`git rebase `
-      linearizes). An **empty commit** created a stack the knot refuses to
-      merge (its check answers "conflicted" with no files) — now refused at
-      create and resubmit, and the empty-conflict report prints the knot's
-      message instead of a blank list. `pr checkout`'s `--empty=drop`
-      silently vanished an empty member from the checked-out branch — now
-      `--empty=keep` everywhere, so every patch in the input is visible in
-      the result and a flat empty pull checks out instead of stopping with
-      advice about a moved base. Hostile titles (backticks, quotes, emoji,
-      HTML, long) survived create, view, numbering and checkout untouched
-- [x] Image-upload interplay pass, after body images landed mid-refactor.
-      The consolidation held (`create_pull_op` carries image handling once
-      for create and the reconcile's Add; the Update arm merges blobs the
-      same way flat resubmit does), but the strict image scan ran at
-      *planning* time over every commit — so a Keep member whose embedded
-      image left the disk (deleted by a later commit, never committed, or a
-      different worktree) bricked every resubmit, no-op reruns included.
-      The scan's refusal is now deferred to the fate that publishes:
-      proven live — image-bearing bottom kept while its file was gone,
-      no-op rerun clean, and the amended case still refused by name with
-      both remedies
-- [x] Safety batch from the first full code review of the merged stack:
-      mergeCheck's verdict must be *present* (a truncated 2xx body read as
-      "clean" and merged); `target.branch` is read strictly (the "main"
-      fallback could land a merge on a branch nobody named); truncated
-      listings and dangling `missing_below` chains refuse the write
-      commands instead of re-minting or false-bottom-merging (repo_rows now
-      returns the flag gather always had); the changed-bytes compare strips
-      format-patch's version signature (raw equality broke rerun-is-a-no-op
-      across git versions and web rounds); forks are refused from every
-      chain entry point, not just the parent's side; resubmit's applyWrites
-      sends `swapCommit` read before the member re-reads (pds::latest_commit);
-      `stack create`'s collision check reads the PDS alone so a Bobbin
-      stall cannot block it; the entry branch-match is scoped to the acting
-      account's own pulls; CRLF bodies normalize before trailer stripping;
-      `latest_round_patch` bounds the wire's Content-Length like fetch_patch
-- [x] `pds::blob_bounded`'s Content-Length check was the whole bound, despite
-      its own comment calling it that — chunked transfer-encoding and HTTP/2
-      can both omit that header, and a pull's declared `size` is only ever
-      the author's own claim, written to their own PDS with whatever tooling
-      they like, never checked against what the CID actually serves. A
-      hostile pull could declare `size: 100` and stream gigabytes past `pr
-      diff`, `pr view` and `stack merge` alike, since `fetch_patch` and
-      `latest_round_patch` both trust the record before ever calling this.
-      Chunks are now counted as they arrive too, and reading stops the
-      instant the running total passes `max_bytes` — a bound enforced
-      against bytes actually received rather than a header the sender
-      controls
-- [x] Concurrent member reads, the last of that review batch. `resubmit` and
-      `merge` both read every member with one `.await` per loop iteration,
-      and each read is a `getRecord` plus a blob download — the latest
-      round's patch, where the change-id lives and whose bytes decide whether
-      anything changed — so a ten-member stack was ten round trips end to end
-      before either command could plan anything. Both go through
-      `read_members` now, `buffered` at `MEMBER_READ_CONCURRENCY`. Ordered
-      rather than unordered, because both callers build a `Vec` indexed by
-      position in the chain and the chain's order is the whole subject;
-      `reordering_the_branch_relinks_the_chain_onto_the_new_order` is what
-      holds that, its three members all reading at once at this concurrency
-- [x] The rest of that batch had already landed — the
-      rest landed: the listing-chain-states preamble is one loader now
-      (`complete_rows`/`own_chain` in `stack/mod.rs`), the blob-fetch path
-      is shared (`pds::blob_bounded`, used by `review` and the stack),
-      resubmit's Add slot reuses create's record builder
-      (`create_pull_op`), and of the performance batch, patches gzip once
-      and merged statuses write as one batch. Resubmit and merge still
-      read every member with one `.await` per loop iteration — no
-      `join_all`/`try_join_all` anywhere in the file
-- [x] `pr view ` details the pull named, instead of substituting its
-      stack's top for it. Every member of a stack used to answer with the
-      top's title, body, rounds and state — under the member's own number,
-      rkey, at:// URI or URL, with only `--json`'s `url` disagreeing with
-      the question. The top promotion was right for the *branch* form, where
-      every member matches `source.branch`, and was being applied to both.
-      The chain listing now marks the member and says which of how many it
-      is, and `--json`'s `stack` gained `position`
-- [x] A *closed* stack member whose commits left the branch no longer needs
-      `--prune`. It is kept as-is and left out of the chain, with the
-      members around it relinked — closing a pull is how you take work out
-      of a stack deliberately, usually with a comment saying why, and
-      deletion was the only exit the reconcile offered. `--prune` is now
-      open pulls only, and the refusal says closing is the alternative
-- [x] `pr close` warns when open pulls sit above the target in a stack, and
-      names them rather than counting them: "3 pulls depend on this" says
-      there is something to check and not what to check. Transitive
-      dependents are included, `chain_containing` already walking the whole
-      chain. Not a refusal — closing a member is the documented way to take
-      work out of a stack, and the note says `stack resubmit` relinks the
-      chain around it. `pr reopen` says nothing: restoring a member's open
-      state repairs the dependency a close broke. Best effort throughout, and
-      silent when the listing will not load: this is a note attached to a
-      write that is going to happen anyway, and a read that fails is a reason
-      to say nothing rather than to refuse a close somebody asked for
-- [x] `pr view`/`pr list` printed `?` for the same fresh pulls that `stack
-      view` called open, because the promotion lived in `repo_rows` and they
-      read through `gather` directly. Done since, and the other way round
-      from what this entry proposed: `settle_own_open` moved *into* `gather`,
-      so every listing gets it and `repo_rows` gets it by going through the
-      same door. Its comment there says so
-- [x] `--add-change-ids` rewrites the branch before atgc has a session.
-      `ensure_change_ids` moves `refs/heads/` at the top of both
-      `create` and `resubmit`, and `agent_for_did` is not called until a
-      hundred lines later. So an expired session — the ordinary case —
-      rewrites the commits and then fails with "could not refresh the
-      session", leaving new shas and no pulls. It is worse in `resubmit`: the
-      rewrite mints fresh `I` ids for the commits that lacked one,
-      `reconcile` then refuses because N open pulls match no commit on the
-      branch any more, and the remedy it offers is `--prune`, which deletes
-      those pulls and their review comments. The session is a pure
-      prerequisite and can simply be taken first. `git reflog` is named only
-      in the success message and wants naming in every failure below the
-      rewrite, with the pre-rewrite sha the rewrite already holds
-- [ ] `stack create` still plans *after* the rewrite, so the refusals that
-      come out of planning — an empty patch, a commit message naming an image
-      that left the disk, two commits sharing a change-id — still arrive with
-      the branch already moved. They are annotated with the old tip rather
-      than hoisted, which is the cheap half of the fix. The expensive half is
-      what `--dry-run` already does: plan on the pre-rewrite commits with the
-      ids the rewrite would mint, refuse there, and rewrite last. `resubmit`
-      got the one refusal that mattered (the `--prune` offer) moved up; the
-      rest are the same shape and the same work
-- [ ] Nothing tests what a failed *blob upload* or a refused `applyWrites`
-      leaves behind, which after the fix above are the only two things that
-      can still fail with the branch rewritten. `note_rewrite` covers them by
-      construction; a test would pin the wording
-
-## issue
-An issue is a `sh.tangled.repo.issue` record in the PDS of whoever *filed* it,
-naming its repo by that repo's own DID, with its state in a separate
-newest-wins log of `sh.tangled.repo.issue.state` records. A pull request's
-shape minus the patches, so `cmd/issue/` is a read/write split from the start
-and borrows `pr`'s conventions rather than inventing new ones beside them.
-
-- [x] Vendored `sh.tangled.repo.issue`, `.issue.state` and `.issue.comment`
-      into `lexicons/` and regenerated the bindings, which moved the pinned
-      core commit from 74ce1a0 to 7ccb643. The six lexicons atgc already
-      writes are byte-identical across that range, checked file by file
-- [x] `issue create` — a record in your own PDS naming the repo by its DID,
-      so it needs no permission on the repo, exactly as `pr create` does not.
-      `--body-file` (and `-` for stdin) rather than an `$EDITOR` spawn
-- [x] A body is *required*, though `issue.json` marks it optional. The
-      appview's `Issue.Validate` refuses `issue body is empty` and its
-      ingester logs `failed to ingest record, dropping it without retry`, so
-      a title-only issue is accepted by the PDS, federates, and never appears
-      — the same silent shape as a comment in the deprecated collection,
-      arrived at from the other direction. `issue create` refuses it before
-      the write and `issue edit` will not empty one, since an update the
-      appview drops leaves it showing a paragraph the PDS no longer holds.
-      Pulls are exempt from the equivalent check when the patch is a
-      format-patch, which is why `pr create` needs no such rule
-- [x] `issue view` — by record key or at:// URI, read live from the author's
-      PDS. No branch fallback: an issue record names no branch, so there
-      would be nothing to guess from
-- [x] `issue list` — one account's issues off that account's PDS, scoped to
-      the checkout's repo unless `--all`, anybody's with `--author`, and no
-      session needed for either. Says on stderr every time that it is
-      author-scoped, so its rows cannot be read as the repo's
-- [x] `issue close` / `issue reopen` — an append of a TID-keyed state record
-      into the *acting* account's PDS, never a put or a delete, so a repo
-      owner can close an issue filed by somebody they cannot write to.
-      Refuses for an account that is neither the author nor the repo owner,
-      since the appview drops such a record after a knot ACL check
-- [x] `issue edit` — title and/or body of your own issue, via put_record with
-      the CID as `swapRecord`. Author-only, unlike close/reopen: the record
-      is in the author's PDS
-- [x] `issue comment` — **not** `sh.tangled.repo.issue.comment`, which is
-      deprecated and whose creates the appview ingests as a no-op; the live
-      record is `sh.tangled.feed.comment` with a `subject` strongRef carrying
-      the issue's CID. `pullRoundIdx` is left unset — the appview requires it
-      only when the subject is a pull. No standing check: `ingestComment`
-      performs no ACL lookup, so anyone may comment
-- [x] Every refusal in the family carries an exit status rather than the
-      unclassified `1` a `bail!` gives: `2` for a command line that names no
-      issue or no body, `3` for a bare record key with no session and no
-      `--author`, `4` for a close or an edit of somebody else's record. The
-      numbers are docs/output.md's, and `tests/support`'s `refused_with`
-      pins them — plain `refused` passes against every status, so a site
-      that loses its classification is invisible to it
-- [x] Cross-author listing: "every issue on this repo, whoever filed it".
-      Bobbin turns out to serve `sh.tangled.repo.listIssues` and
-      `listIssuesBy` — the same `subject` parameter, the same
-      `{uri, cid, value}` envelope and the same `state` and `commentCount`
-      beside it as `listPulls` — so the flag that "would describe a decision
-      nobody gets to make" is a real one now. `issue list --source` is
-      `pr list --source`'s, spelled the same and traded the same way: opt-in,
-      because the index is alpha and stalls. `Listed` and `State` were indeed
-      already the right shapes; what they gained is `comments`, `indexed` and
-      a per-row author, the last because a repo-scoped page spans accounts
-      and the subject's handle stopped being every row's
-- [x] Comments are counted. `commentCount` rides in the index's listing
-      envelope, so `issue list --json` has a `comments` field and the number
-      is real rather than null-on-every-row. Still not *shown*: reading the
-      comments themselves is a different job, a comment being a record in its
-      commenter's PDS
-- [x] A state nothing could settle read `?` on anything but your own repo,
-      which was most of them. The index carries the appview's own answer, so
-      a maintainer's close now settles a row that no read of the author's PDS
-      could see. Own records still outrank it: they came from this account's
-      own PDS and cannot be stale, so the index fills gaps rather than
-      overruling. `IssueState::from_label` reads the envelope's bare word
-      where `from_token` reads a record's whole NSID, and an unrecognised
-      word leaves the row unsettled and listable rather than crashing
-- [ ] No issue *numbers*. The number is the appview's own id, in no record
-      written since repos got DIDs and in no XRPC response — the pre-DID shape
-      did carry it, as an `issueId` beside an `owner`, which
-      `tests/fixtures/issue_old_record.json` is a captured example of; that
-      spelling is gone and nothing indexes it. Exactly like a pull's, then;
-      atgc resolves a pull's by scraping the page, and the entry above about
-      that scrape says it is a bet on somebody else's HTML that has already
-      been seen to fail. Making a second bet of the same kind to ship a first
-      cut was not worth it, so `issue view 23` refuses and says why. Every
-      command takes the record key or the at:// URI instead
-- [ ] Residual, after the index landed: a state still reads `?` when Bobbin
-      has not indexed the issue and the author does not own the repo
-      that is most of them: a maintainer's close is a record in the
-      maintainer's PDS. Absence is read as `open` only when the issue's author
-      also owns the repo, and even then a *collaborator's* close is invisible
-      — the same gap the `pr close` entry above records, needing the same
-      knot ACL query to shut
-- [x] Comments are written and now read. Both halves were the cross-author
-      problem — a comment is a record in its commenter's PDS and nothing
-      enumerates who has commented — and both have the same answer:
-      `sh.tangled.feed.listComments`, which takes the *subject's* at-uri and
-      so serves a pull and an issue with one function. `issue view
-      --comments` and `pr view --comments` print the discussion, oldest
-      first, and `--json` carries it as `thread` (not `comments`, which on a
-      pull is already the count). It is an index read, so it is opt-in and
-      says what it is: "as far as Bobbin has indexed".
-
-      Two collections are read, not one. Tangled unified comments into
-      `sh.tangled.feed.comment` and the old `sh.tangled.repo.issue.comment`
-      is deprecated *for writing*; the index returns whatever was written, so
-      a thread older than the unification is mostly legacy records and a
-      reader that knew only the current shape would show it as empty. They
-      also spell their body differently — `body.text` against `body` — which
-      is what `Comment::read` hides
-- [x] No image blobs in issue bodies, though the lexicon has `blobs` and
-      `pr create` already knew how to upload them. `issue create`, `issue
-      edit` and `issue comment` now take the same path as their `pr` twins:
-      scanned before the network so a missing file refuses by name, uploaded
-      only once the write is really going to happen so a dry run leaves no
-      orphan blob, and the body rewritten to `blob+at://` so a later reader
-      and a later edit both see the spelling that still means something.
-      `issue edit` merges the blob list rather than replacing it, since an
-      earlier edit's images are still referenced by the parts of the body
-      this one did not touch, and treats an unchanged body naming a local
-      path as a change, because sending it is what uploads the file — the
-      clause `pr edit`'s `body_edit_is_a_change` already documents.
-      `cmd/pr/images.rs` moved to `cmd/images.rs` on the way: `stack/write.rs`
-      was already reaching it as `crate::cmd::pr::images`, which is the
-      sideways import rule 2 exists to stop
-- [ ] No `mentions` or `references`, which the web UI sets and which ride
-      through an edit untouched in the generated `extra_data`. No labels
-      either — `sh.tangled.label.op` is unimplemented for every subject
-- [x] `settle_states` walked the whole `sh.tangled.repo.issue.state`
-      collection once per row, and twice per row when the acting account was
-      not the author: thirty issues was thirty to sixty `listRecords` calls
-      where one walk would do, over pages that were very often the same
-      bytes. `state_events_for` now takes the whole page and answers a map
-      keyed by issue at-uri, so a listing is one walk of the author's PDS and
-      at most a second of the acting account's. The early stop compares
-      against the *oldest* key asked about rather than each row's, which is
-      the one thing the grouping had to change; `state_events` stays as the
-      one-issue wrapper `issue view` calls. The ownership lookup beside it
-      was already cached, and for the same reason
-- [x] `--state` is a clap `ValueEnum` on all three listings that take it —
-      `issue list`, `pr list`, `pr status` — so an unknown word is a refusal
-      naming the accepted ones (exit 2, clap's own) instead of an empty
-      listing. `issue list --state opne` used to print `no issues for 
-      on `, which does not even echo the word; `pr list --state opne`
-      printed "no opne pull requests". Both read as *there are none*, which
-      is the stale-index failure this tree opted out of, arriving from the
-      command line instead. `--help` gained the possible values as a side
-      effect. Input side only: what a record or an index *says* a state is
-      stays an untyped string, so a state Tangled adds after this build still
-      lists rather than crashing
-- [x] `cmd/issue/read.rs` reached sideways for `cmd::pr::read::ellipsize`,
-      which is the shape rule 2 in docs/module-layout.md exists to stop. Done
-      since: it and `day` live in `term/column.rs`, and `issue`, `repo`,
-      `search` and `pr` all import them from there
-- [x] `issue edit --dry-run` reported what it would write without running the
-      record past `LexiconSchema::validate`, so a dry run could say "would
-      work" for a record the lexicon refuses. It was not two commands but
-      four — `issue create`, `issue edit`, `key add` and `repo edit` all
-      built the record *after* the dry-run return, so `validate` could only
-      ever run on the real one. All four now build and validate above it.
-      The demonstration is `repo edit --description <201 chars> --dry-run`,
-      which used to print "dry run; nothing sent" and exit 0.
-
-      The two `issue` writers validate *twice*, which is what embedding
-      images costs: the check before the dry-run return sees the record as
-      the caller wrote it, and a second one after the upload sees the two
-      fields it could not — `body`, rewritten to `blob+at://`, and `blobs`.
-      Neither may be minted by a dry run, a blob nothing references being one
-      the PDS may collect. The second check is skipped when there were no
-      images, the record then being byte for byte the one already validated
-- [ ] The three writers left out of that are the ones whose record cannot be
-      complete before the network: `pr create`, `pr resubmit` and `stack
-      create` need a patch blob's CID, and `repo create` needs the `repoDid`
-      the knot mints. Validating a stand-in record would check the fields
-      the caller controls, which is most of them, at the cost of a shape
-      that is not the one sent. Worth it or not is an open question
-- [x] `new_body`/`comment_body` in `cmd/issue/write.rs` were the third
-      near-copy of "a body from a flag, a file, or stdin" in the tree, after
-      `cmd/pr/write.rs` and `cmd/report.rs` — three copies serving six
-      commands. Now `term/body.rs`: `read` for the clearable case, whose
-      nested `Option` is what lets an edit tell "leave it alone" from "clear
-      it", and `required` for a comment, which has no second meaning for
-      empty. Below `cmd/` because rule 2 says so, and beside `term/noinput`
-      because both are about what is fed to a terminal. The one real
-      difference between the copies was `report` trimming its result, which
-      is kept at that caller with the reason written down: a report body is
-      prose on a public board, while a pull body's indentation is content
-
-## repo
-- [x] `repo create` — from an existing checkout: knot XRPC create, repo
-      record, remote add, push (proven: created and pushed atgc itself)
-- [x] `repo create` — pick the push SSH identity by matching registered
-      sh.tangled.publicKey records; pin via per-repo core.sshCommand
-- [x] `repo create` refuses an account with no usable key instead of warning,
-      before the knot call rather than after it: a repo record and a knot-side
-      repo left behind by a push that could never work is worse than no repo.
-      `--no-push` downgrades it to a warning. `repo configure` fails the same
-      way (after writing the git identity, which needs no key), and `repo
-      clone` still falls back to https but says pushes from that checkout
-      will fail. `--ssh` with no key is a refusal: there is no anonymous ssh
-- [x] `repo create` — from nothing, not only from a checkout: an unborn HEAD
-      names the default branch instead of failing "commit something first",
-      and a directory that is no repository at all gets `git init` before the
-      remote. Nothing local happens until the knot and the record have both
-      agreed, so a refused name leaves no half-made checkout behind
-- [ ] `repo create` — knot discovery (default from your membership records
-      instead of hardcoded knot1.tangled.sh); confirm before creating on a
-      knot you've never used
-- [x] `repo list` — the owner's sh.tangled.repo records straight from their
-      PDS, newest first; defaults to the active account, or takes a handle/DID
-      plus --limit. Was Bobbin's listRepos, which answered "no repos" for an
-      account that had one
-- [x] `repo view` — one repo's record off the owner's PDS, plus branches,
-      tags, languages and the default branch read live off its knot; the repo
-      and its owner each named by handle *and* DID on one line. Bobbin is asked
-      for the star count and nothing else — pull and issue counts get compared
-      against a list, and it lags, so those are left out rather than stale
-- [x] `repo clone` — owner/name, at-uri or Tangled URL; ssh when a registered
-      key matches, else https; writes the `[user]` identity into the checkout
-- [x] `repo configure` — write the `[user]` identity and pin core.sshCommand
-      in the current checkout, for the selected account; skips rule 3 of
-      account precedence since it writes the value rule 3 reads. Repairs a
-      `repo clone` interrupted after git finished but before the identity was
-      written, which is otherwise unrecoverable
-- [x] `repo edit` — description, topics, website, spindle on the repo record.
-      A read-modify-write over somebody else's schema, so it goes through
-      `record::put` with the CID the read returned: Tangled's web UI edits the
-      same record, and two writers with no precondition lose one of the two
-      edits silently. `knot` is deliberately not editable — rewriting it moves
-      no repo and only makes every clone URL wrong. An empty value clears a
-      field rather than writing an empty string
-- [x] `repo delete` — the record off the owner's PDS first, then the git data
-      over rpc:sh.tangled.repo.delete. That order is the knot's, not a
-      preference: it reserves deleting-past-a-live-record for its admins, so
-      the record deletion is what authorizes the cleanup. The record delete
-      carries the CID the read returned, same precondition as `repo edit`'s
-      put. The repo must be named — the one repo command that never falls
-      back to the checkout's remote, because guessing is how the wrong repo
-      gets deleted. Its rpc scope is the third one-time re-login, the first
-      that was held back on purpose until its command existed
-- [ ] `repo edit --labels`, once something in atgc creates the
-      `sh.tangled.label.definition` records the field points at
-- [ ] `repo fork` — create fork on your knot; `repo sync` via forkStatus/forkSync
-- [x] `repo default-branch` — rpc:sh.tangled.repo.setDefaultBranch. A knot
-      mutation, not a record write, so it needs a repo read only to learn the
-      knot and mint the record's own at-uri, which is the `repo` shape the
-      procedure's vendored lexicon declares. The scope has been in `SCOPES`
-      since before this command did, so no re-login is needed
-- [x] `repo delete-branch` — rpc:sh.tangled.repo.deleteBranch. The third
-      scope this tool has ever added after its command shipped rather than
-      before — `deleteBranch` was held back as destructive on purpose, and
-      this is the re-login that costs. Refuses to delete the repo's own
-      default branch client-side, since the knot's lexicon documents no
-      named error for that case and there is no acceptable way to find out
-      by trying it. Does not check for open pulls targeting the branch —
-      that would mean trusting Bobbin's index for a destructive gate, and
-      `repo view` already declines pull counts over exactly how stale it
-      gets
-- [x] The whole `repo` family bypasses `clients/endpoints.rs`. Ten sites
-      built `https://{knot}/xrpc/…` or `https://tangled.org/…` by hand — in
-      `repo/write.rs`, `repo/read.rs` and `repo/branch.rs` — where
-      `clients/tangled/knot.rs` and the four `fn appview()` wrappers go
-      through the module that exists to be the one place a hostname is
-      spelled. One of the ten moved traffic: `write.rs`'s `knot_procedure`,
-      which is `repo create`'s and `repo delete`'s only knot call, so
-      `ATGC_KNOT` did not reach atgc's two irreversible commands and a test
-      of either would have posted to a real knot. The other nine were the
-      printed `url:`, which said tangled.org whatever `ATGC_APPVIEW` was set
-      to — including on the line directly under a `view:` link that honoured
-      it. Both are closed: the knot call goes through
-      `endpoints::knot`, and one `repo::repo_url` builder replaced the nine
-      (ten, with `clone`'s, which was already correct). `tests/repo_flows.rs`
-      now drives `create`, `delete` and `default-branch` against the mock.
-      Left deliberately: the four `git@tangled.org:` SSH remotes, in
-      `read.rs`, `write.rs` and `checkout.rs`. An SSH host cannot be derived
-      from an HTTP base URL, so moving those means a sixth compiled-in name
-      and a sixth override, which is a new public interface rather than a
-      cleanup — and no test can clone over SSH from a mock anyway
-- [x] `repo delete-branch` fails open on its only guard. The one check is
-      whether this is the repo's default branch, and when the
-      `getDefaultBranch` read fails, or the record carries no `repoDid`, it
-      warns and deletes anyway. The read is an unauthenticated GET and the
-      delete an authenticated XRPC to the same knot, so the first failing
-      while the second succeeds is ordinary — a 5xx from a proxy, a slow
-      response, a renamed query. The command's own doc says that deleting a
-      real default branch on a real knot is not an acceptable way to find out
-      whether the knot refuses it, and that there is nothing to read a
-      mistake back from; failing open contradicts both. The neighbouring
-      decision *not* to check for open pulls is sound and does not transfer:
-      that one reads an index that lags and can be wrong in both directions,
-      this one reads the knot and can only be wrong in one. There was a third
-      way through that the survey did not name — a `getDefaultBranch` that
-      answers 200 with no branch name in it, which is what a proxy's error
-      page and a renamed field both look like from here — and it is closed
-      too. `--force` is the override, and it does not reach a knot that names
-      the branch: no flag on this command means "yes, delete the default"
-- [ ] `repo default-branch` still sends the change when the same
-      `getDefaultBranch` read fails, and that is deliberate: the read is only
-      there to print `old -> new`, and setting a default onto the wrong
-      branch is fixed by setting it again. Left as it is on purpose, recorded
-      so the next reader does not "fix" it into a refusal
-- [x] `repo delete` and `repo create` still reach a real knot under
-      `ATGC_KNOT` — only `knot_query` moved onto `clients/endpoints.rs`, for
-      `delete-branch`'s guard. Closed with the entry above. Worth recording
-      what the tests it unblocked actually found worth asserting, since it is
-      not the URL: both commands are two writes in a forced order — `create`
-      asks the knot before it writes the record, `delete` deletes the record
-      before it asks the knot — and neither service enforces its half. The
-      journal in `tests/support` is the only thing that can see the sequence
-
-## search
-- [x] `atgc search` over `sh.tangled.search.query` — Bobbin's full-text index,
-      the one question in the stack with no second source, so there is nothing
-      to merge and nothing to warn about beyond the index lag that makes a
-      recent record unfindable. Scoped to this checkout's repo by default and
-      widened by `--all`; `--nsid`, `--author`, `--since`/`--until` and
-      `--limit` narrow it; `--json` prints the derived view. A public read
-      with no session and no new scope. The lexicon is deliberately not
-      vendored: `value` and `score` are both typed `unknown`, so codegen has
-      nothing to generate and the fixture is the stronger pin — it is what
-      caught that a comment's `body` is a `sh.tangled.markup.markdown` object
-      rather than a string
-- [ ] No `--cursor`. Bobbin sends one exactly when the page came back full,
-      which `search` reports as "raise `--limit`" since the ceiling is 1000 in
-      a single request. Revisit if a caller ever wants to walk past that, or
-      if the cursor stops being an offset
-- [ ] Hits are not linked to their web pages. A pull's or issue's
-      `tangled.org` URL needs its appview number, which is in no record and no
-      Bobbin response — one listing sweep per repo on the page, for a listing
-      that spans repos. The `at://` URI every row prints is what `pr view`,
-      `pr diff` and `pr checkout` take, so nothing is unreachable, only
-      unclickable
-- [x] The author was a DID and nothing else, while every other listing
-      resolves a handle. `--json` now carries `author_handle` beside
-      `author_did`, resolved per unique account, eight at a time. The wait
-      this was avoiding is real at the top of the range — `--limit` goes to
-      1000 — so it is capped: over `AUTHOR_HANDLE_CAP` (50) distinct authors
-      on a page the column is dropped for the whole page and a note on stderr
-      says so, since a silent null would read as "these accounts have no
-      handles". The table is unchanged; it has no author column to put one in
-- [ ] The table still shows no author at all, only the at-uri whose authority
-      is the DID. A handle column would be the natural place for the field
-      `--json` now carries, and there is no room: the row is already nsid,
-      date, a 54-character excerpt and an uncut uri
-- [ ] `repo_did` gets no such treatment. A repo label is up to three round
-      trips (`cmd/pr/read.rs`'s `repo_name`), and a page of hits spans repos
-      the way it spans accounts, so the same cap would bite far sooner
-
-## pipelines (CI via spindles)
-- [ ] Attach a spindle to this repo. `.tangled/workflows/ci.yml` exists but is
-      unverified — no runner has ever executed it, so its schema is unproven.
-      It also assumes the nixpkgs cargo/rustc attrs resolve, and that the
-      nixpkgs toolchain is close enough to rust-toolchain.toml's pin that fmt
-      and clippy agree with a local run. Deferred to the 1.0.0 arc under
-      Infrastructure, alongside crates.io publishing and release binaries
-- [x] Clear the pre-existing `cargo fmt --check` and clippy findings so the
-      pipeline's fmt and `-D warnings` gates can go green (`prek run
-      --all-files` is clean; the pipeline itself still hasn't run once)
-- [ ] `run list` / `run view` — pipeline runs and workflow logs over spindle
-      XRPC (v1.16+)
-- [ ] `run watch` — poll a run until it completes
-- [ ] `run rerun` / `run cancel` — spindle retry/cancel
-      (rpc:sh.tangled.ci.triggerPipeline / cancelPipeline)
-- [ ] `run trigger` — kick off a workflow manually
-
-## secrets (CI secrets on the knot/spindle)
-- [ ] `secret set` / `list` / `delete` — rpc:sh.tangled.repo.addSecret /
-      listSecrets / removeSecret
-
-## release (artifacts on tags)
-- [ ] `release create` / `list` / `view` — sh.tangled.repo.artifact
-- [ ] `release upload` / `download` / `delete`
-- [ ] Point the README's current-release line at the binary once there is
-      one. The line already exists and is rewritten on every bump; it says
-      only the tag today because a tag is all there is to say. Widening it
-      is a one-line edit to `pre-release-replacements` in release.toml
-## key
-- [x] `key add` / `list` / `delete` — sh.tangled.publicKey records, which
-      are what a knot authorizes a push against. `add` defaults to the one
-      public key in ~/.ssh, takes a path or the key line, redirects a private
-      key path to the `.pub` beside it and refuses when there is none, and is
-      idempotent against a key already on file. `delete` takes a record key,
-      a name, a fingerprint or a path, and refuses an ambiguous one rather
-      than picking. `list` says which registered key is on this machine
-- [x] Scope-gap detection, since both `key add` and `pr comment` are refused
-      by a session older than the scope they need and the PDS says so as a
-      403 about record creation. `auth status` lists, per account, the scopes
-      atgc now asks for that the login does not carry, named by the command
-      each one costs; the three writes on those two collections refuse before
-      writing, and say `would fail:` under `--dry-run`. Found a real one: a
-      logged-in account here predates `sh.tangled.feed.comment`
-- [x] Keyed by the whole scope token rather than by collection, so an `rpc:`
-      or `include:` gap names its command too — `repo delete-branch` and
-      `report` were listed as bare NSIDs nobody could act on
-- [ ] Tangled also writes an `ssh -T` greeting per key; nothing reads it back,
-      so there is no way to confirm from here that a knot has ingested a
-      newly added record other than trying a push
-
-## logs (atgc's own logs about itself)
-- [x] `logs oauth` — the OAuth log reader, moved out of `auth log` and given
-      a command group of its own, because the OAuth log is about to have
-      siblings and a second reader hanging off `auth` would have named the
-      wrong thing twice. The reader module moved with it, `authlog.rs` to
-      `logs/oauth.rs`
-- [x] `auth log` removed outright. It survived one release as a hidden alias
-      that printed the new spelling, on the same terms as bare `atgc login`,
-      and that was the wrong trade: `atgc login` is the first thing a new
-      user types and worth protecting, while the log reader's whole audience
-      is people already reading their own machine's output, who can see
-      `logs oauth` in `atgc logs --help`. The alias cost a variant, a wrapper
-      struct and a note on stderr to save them a word
-- [x] `logs pds` — every write atgc sends to a PDS, in `pds.jsonl`. One line
-      leaving and one coming back, with the collection, record key, the
-      swapRecord/swapCommit precondition and the CID the record landed at; an
-      `applyWrites` lists its ops so a stack reconcile is legible one op at a
-      time. Observed at the transport like the OAuth log, so no call site can
-      forget to log and none can be added without being logged. Record
-      content is never written — only a fingerprint of it
-- [x] The reader is generic over its event type (`logs/render.rs`), so every
-      command is one rendering rather than several that resemble each other,
-      and the writers share `logging/file.rs`: one envelope, one size cap, one
-      rotation, one invocation id across all the files
-- [x] `logs git` — every git subprocess atgc starts, in `git.jsonl`. One line
-      leaving and one coming back, with the argv, the `-C` directory, the
-      exit status, git's stderr on a failure, and — for an explicit list of
-      ref-moving subcommands — where HEAD stood before and after. That list
-      is the design: probing HEAD costs a subprocess, and asking either side
-      of every `rev-parse` would double what atgc spawns to record nothing.
-      Every git process now starts from one `Run` builder in
-      `clients/git/run.rs`, so `patch.rs`'s stdin runner and `review.rs`'s
-      are covered by construction; `cmd/report.rs`'s `git --version` was the
-      last one outside it and now calls `run::version()`
-- [x] Credentials never reach `git.jsonl`: a remote URL's whole userinfo, a
-      `-c key=value` value and a `git config` value all become an
-      eight-character fingerprint, the last by an allowlist (`user.name`,
-      `user.email`) so an unknown key is redacted rather than trusted. Stdin
-      is a length and the environment is variable names only. An argv is the
-      one field in any of these logs that cannot be typed `Fp`, so the
-      redaction is one function with one test rather than a type
-- [ ] A knot write log. Knot calls — `repo create`, `delete-branch`,
-      `default-branch`, `merge` — are the mutations `logs pds` deliberately
-      does not cover, because they are not the PDS. Same shape, different
-      service, and the transport wrapper is already the place to put it
-- [ ] `logs git` probes HEAD and nothing else, so a command that moves some
-      other ref — `push`, or an `update-ref` on a branch that is not checked
-      out — records an unchanged HEAD. The row says "unchanged" rather than
-      nothing, which is honest, but the ref a command actually named is
-      knowable for `update-ref` and `branch -f` and is not read. Worth doing
-      the day something moves a ref that is not the current branch
-- [x] `atgc agent` names the logs, in one bullet rather than the three lines
-      this entry asked for: "atgc logs git says whether atgc moved a ref;
-      also logs pds, oauth". The rewrite that made room paid for it out of
-      the same 76 lines, so the line kept the half an agent cannot get
-      anywhere else — that a ref moved because *atgc* moved it, which a
-      reflog does not say — and dropped the jsonl paths and `ATGC_*_LOG=off`,
-      both one `logs --help` away
-- [ ] `logs git` cannot see a `pr diff` pager or an `atgc report` editor:
-      `cmd/pr/review.rs` spawns `sh -c` for the pager git itself would use,
-      which is not a git subprocess and so is not this log's business, but it
-      *is* a subprocess atgc starts and nothing records it. Either widen this
-      log's name or leave it — recorded so the gap is a decision
-- [ ] An index-read log. Bobbin and the web index lag chronically and
-      service-wide, and every conversation about it starts by reconstructing
-      what a listing returned and when. Recording the answers turns "the
-      index is behind" from a recurring guess into a timestamped
-      observation, and would let `pr list` say how stale its own answer is
-- [ ] Unify the logs, once there are four. Three of them exist now — OAuth,
-      PDS writes, git subprocesses — and the knot write log is the one left.
-      Three things are still per-log that should not be: nothing correlates
-      them beyond a shared `inv` — a write refused for authentication and the
-      token refusal explaining it are two lines in two files, joined by eye;
-      each rotates and is capped on its own, so a busy log can age out the
-      one that would have explained it, and `git.jsonl` is the noisiest of
-      the three by an order of magnitude, which makes that likelier than it
-      was; and there is no way to ask a question of all of them at once. The
-      shape to aim at is `atgc logs` with no subcommand reading every log
-      into one invocation-grouped stream, `--since` and `--inv` spanning all
-      of them, and one rotation budget shared out rather than four
-      independent ones. Worth doing when the fourth log lands, not before —
-      three is still few enough to open three terminals
-- [x] `--since` no longer panics on a non-ASCII value. `render.rs` took the
-      duration's unit with `split_at(len - 1)`, a byte index, so `--since 2д`
-      landed off a char boundary and aborted in core — exit 101 where the
-      ASCII path had always exited 2 with the sentence naming the spellings
-      that do work. It reached every `logs` subcommand on both `--since` and
-      `--until`, and `search` through the same shared parser. The unit is the
-      last `char` now, so an unreadable value is a usage error whatever
-      alphabet it is written in. The truncated verbatim duplicate of the
-      function's own doc comment is gone with it, and `render.rs` has its
-      first tests — 1,262 lines carrying none, which is not unrelated to how
-      this survived
-- [x] `logs pds` loses a column whenever colour is on. Three of the four
-      `action` values were painted and the fourth was a bare string, and the
-      `{:<8}` ran after the paint, so on a tty the padding counted SGR bytes
-      and applied none — `update` rows and the rest disagreed by two columns.
-      Correct under `NO_COLOR` and in a pipe, which is why the tests passed.
-      Fixed with `term::column::pad_to` along with every other fixed column;
-      the two `logs oauth` sites that were right only because `short_inv`
-      happens to return exactly eight characters now go through `pad_start`,
-      so they are right by construction rather than by luck
-
-## status (dissolved; the listing is `pr list --all`)
-- [x] `status pr` — your pull requests across every repo, moved out from
-      under `pr`. `pr list` is a repo's pull requests and this is an
-      account's, across all of them; that is the largest fact about either
-      listing and neither word carried it while the two were sibling verbs.
-      So the scope went into the first word, where it is read first: `atgc pr
-      …` is this repo, `atgc status …` is not. A surface move and nothing
-      else — same flags, same table, same `--json` rows — with the
-      implementation left in `cmd/pr/read.rs` beside the two listings it
-      shares a gather, a state filter and the `#` sweep with. It also
-      frees the name the cross-repo overview in `misc` has wanted since
-      before this command existed, which was the other half of the problem:
-      the planned `atgc status` could not be added while a verb one level
-      down was called that
-- [x] `status tangled` — whether Tangled's services are answering, and what
-      they are running. The other half of `doctor`, which asks whether *you*
-      are set up and hands you a command per bad row; this asks whether
-      *they* are up, so no row is about you and none has a remedy
-- [x] Both halves under one word, as `doctor local` and `doctor remote`. The
-      pair was two top-level commands answering one question — "can what I am
-      about to do work" — from the two ends of it, and only the first word
-      said which end. `local`/`remote` is the vocabulary a reader arrives
-      with, git having spent thirty years teaching it. `doctor` is a container
-      now and runs nothing itself, which is how every other family here works;
-      being both a verb and a parent would have made it the only one
-- [x] One knot probe, and it is `remote`'s. Whether a knot answers is a fact
-      about the knot, so `local` names the knot its origin resolves to and
-      stops there — the row that judged it was never local. The `remote` row
-      also survives an index outage, which the one it replaced could not: it
-      reads the knot out of the repo's DID document rather than off the repo
-      record, whose address only Bobbin can supply
-- [x] `doctor`'s `remote` row renamed `origin`. One word cannot mean both the
-      git remote a row is about and the end of the wire a sibling command is
-      about, least of all in a tool whose readers arrive knowing the first
-      sense. `origin` is the more honest name anyway: the row is about that
-      one remote and says so on every line
-- [x] Each service asked the question atgc's own use of it depends on — the
-      appview's site, Bobbin's search, `sh.tangled.knot.version`, the PDS's
-      `_health` — rather than one health endpoint for all four. Tangled's
-      appview implements `_health` in its source and the deployed build
-      404s it, so a uniform probe would have reported the same 404 for every
-      service in every state; and a green row now means the thing atgc
-      actually needs works. The knot answers with its version and the
-      protocol capabilities it declares, which nothing else in atgc prints
-- [x] The knot for that row comes from the repo's own DID document rather
-      than from its `sh.tangled.repo` record, which is where `doctor` reads
-      it. That record sits in the owner's PDS at an address only Bobbin can
-      supply, so `doctor`'s route goes dark exactly when the index does —
-      and an index outage is one of the things somebody runs this to confirm
-- [x] `atgc agent`'s briefing carries both, as a trade rather than an
-      addition — its 76-line budget was already spent, which is the point of
-      the number. The scope rule earns a "details that surprise" line
-      because `gh pr status` is the muscle memory an agent arrives with and
-      it means something else here; the services half rides in the `doctor`
-      paragraph, being its other end. What went is the `logs git` line, the
-      one fact in the file that `atgc logs --help` answers as well
-- [x] One lag row, and it is `remote`'s — the same rule as the knot probe,
-      applied to the other fact about somebody else's service. `local` loses
-      its `index` row and `remote` gains one between `bobbin` and `knot`: the
-      other half of "is Bobbin working", and the failure that is silent rather
-      than loud, since a stalled ingest keeps the `bobbin` row green while it
-      answers about a world hours old
-- [x] Widened to the account in the move, which the move requires rather than
-      being a bonus taken along the way: the repo-scoped measure reads `n/a`
-      outside a checkout, and that is most of where `remote` is run. Bobbin's
-      `listPullsBy` for your DID against your own pull records — the pair
-      `pr list --all` already merges — with `lag_seconds` in `--json` for
-      something that alerts on a threshold
-- [x] It ignores the `--source bobbin` opt-in the old row honoured. `local`
-      answers as the rest of atgc would, so an index nothing reads cannot make
-      an answer wrong; in `remote` every row is about the service and none is
-      about your configuration, which is also the question somebody arrives
-      with — whether opting in is worth it, or why yesterday's run was wrong
-- [ ] The measure is account-scoped, not service-wide: it proves only that
-      Bobbin has caught up to *your* last write, so an account that has not
-      opened a pull request in a fortnight learns nothing about the fortnight
-      since. The row prints that evidence's age rather than hiding it. A
-      service-wide measure wants a newest-indexed-record timestamp, and
-      `sh.tangled.search.query` still ranks by relevance with no way to ask
-      for the newest — `since`/`until` filter server-side but the page that
-      comes back is the most *relevant* within the window, so its newest hit
-      is a lower bound on freshness and not a measure of lag
-- [x] Dissolved, and the group with it. `status` was a container built to put
-      scope in the first word, and once `tangled` left for `doctor` it held
-      one verb — a family of one, whose whole justification was being a
-      family. The listing went back to `pr` as `pr list --all`, where the
-      scope is a flag among the other filters rather than a second verb whose
-      name carries nothing. `pr status` and `status pr` are both gone; there
-      is one spelling
-- [x] `--author` requires `--all`. Widening is per account and not per repo,
-      the records being read from a PDS: a pull lives in the PDS of whoever
-      wrote it, so "every repo" is answerable for one account and there is no
-      listing of everyone's pulls everywhere for an author to narrow. A flag
-      that silently meant something else in the narrow case would be worse
-      than a refusal that says so
-- [x] `pr list --author` without `--all`: this repo, narrowed to one
-      account. It needed no new gather — a repo listing's complete half was
-      always one account's PDS, so naming somebody else only changes which
-      DID is read — plus a post-filter for the index sources, which have
-      every author in them. The work that was actually in it was the prose:
-      four sentences about completeness said "your PDS" from a time when
-      there was only one answer, and a listing of somebody else's pulls that
-      says "your PDS" is the same class of wrong answer as a stale index.
-      `Whose` in `cmd/pr/read/sources.rs` is those four sentences' one
-      source of truth, and `--author` naming the selected account resolves
-      back to `Mine` so it reads like the bare listing
-
-## report (feedback via userinput.app)
-- [x] `report` — an `app.userinput.discussion` in the reporter's own PDS,
-      pointing at a board's space record; kind validated against the kinds
-      the board declares (`{label, value}` objects on the live board, bare
-      strings tolerated), fixed diagnostics lines unless --no-diagnostics,
-      the whole record printed before the write, --dry-run stops there.
-      Self-upvote keyed by the report's own rkey, matching the web client
-- [x] `include:app.userinput.authBasic` in SCOPES — the third re-login. The
-      authorization server expands a permission set before minting the token,
-      so the granted scope never carries the string asked for. Found in
-      lance.blue/headquarters, which shipped a literal check and had it
-      refuse every write
-- [x] Check the set anyway, by its members: `PERMISSION_SETS` pairs the scope
-      with the collections the vendored `permission-set` record names, so a
-      grant holding any of them counts as a grant of the set. `report`
-      refuses before it reads the board or composes the record, `auth status`
-      lists the set as `atgc report` will be refused without it, and a stale
-      login sees the login command instead of a 403 about record creation
-- [x] Generate the userinput records with jacquard-lexicon codegen from the
-      published schemas rather than hand-reading the minified web client:
-      every `app.userinput.*` schema is vendored under `lexicons/` with its
-      at://-uri and CID pinned in the unified `lexicons.json` (`scripts/
-      vendor-userinput-lexicons.sh`, the way headquarters vendors
-      blue.lance.camo), and both records `report` writes —
-      `app.userinput.discussion` and `app.userinput.upvote` — are turned
-      into Rust in `vendor/userinput-lexicon/`, a path-dependency crate
-      alongside `vendor/tangled-lexicon`. `Discussion` and `Upvote` in
-      `src/lexicon/userinput.rs` are now the generated types directly, built by the
-      free functions `new_discussion`/inline construction rather than
-      wrapper structs (Rust's orphan rule blocks an inherent `Discussion::
-      new`, the same shape as `crate::lexicon::tangled::pull_target` on the
-      sh.tangled.* side). `new_discussion` calls the generated `validate()`
-      before returning, so title 600/300, body 20000/10000 and tags max 8
-      (the limits hand-reading the web client only guessed at) refuse
-      locally instead of at the PDS's 400. `StrongRef` stays hand-written on
-      purpose: `com.atproto.repo.strongRef` requires `cid`, but a
-      discussion's `space` sometimes has none even though a vote's
-      `subject` always does, and jacquard-codegen resolves a ref the same
-      way for every field that names it — vendoring strongRef would make
-      both required at once. `space`/`subject` stay the untyped `Data` a
-      ref resolves to unvendored, built from `StrongRef` by
-      `StrongRef::to_data`
-- [ ] Generate `#image` too and attach a screenshot to a report (`images`,
-      max 4, png/jpeg/webp/gif, 1 MB) — widen `scripts/vendor-userinput-
-      lexicons.sh`'s codegen input past `discussion.json`/`upvote.json` once
-      this lands, so the size/mime limits come from the same generated
-      `validate()` rather than a second hand copy. Declare the blob's
-      content type on upload — a PDS that cannot sniff it records `*/*` and
-      the lexicon then rejects the record — and upload before the write,
-      since an unreferenced blob is collected
-- [x] The atgc board, baked into `report::DEFAULT_BOARD` — bug, feature and
-      question, at userinput.app/s/did:plc:nlzmjyfv6loqtxyzvdcznwgf/3msrnb776772b.
-      Only the address is compiled in; the name and kinds are read off the
-      record on every run. Creating a space is not in authBasic, so making
-      the board was a web-UI act and moving it would be another
-- [ ] Nothing reads the board back: `report` files and prints a URL, and
-      whether the report is *there* is a question only the web answers. A
-      constellation-backed `report list` would close that, at the cost of a
-      dependency on an index the filing path deliberately does not need
-- [ ] Lift `crate::lexicon::userinput` out into a reusable crate once the shape
-      settles — it is deliberately self-contained (jacquard types only) and
-      carries the conventions recovered from the web client: vote rkey = the
-      subject's rkey, write-time autolinking, self-upvote on post
-- [ ] `report reply` / vote on an existing report would fit inside authBasic
-      if wanted; nothing asks for them yet
-
-## misc
-- [x] Four entries in this file claimed work was undone that was done, which
-      before a 1.0 is worse than no entry at all: two were plain staleness
-      (the reserved-TLD list, which jacquard enforces and always has, and the
-      `?` promotion, which moved *into* `gather` rather than being aligned
-      the way its entry proposed), and two were damage from resolving rebase
-      conflicts in this file — one entry kept a `[ ]` box over a body
-      rewritten to describe the finished work, and one survived twice, once
-      in each state. Found by reading every `- [ ]` back against the code
-      rather than by trusting the boxes
-
-- [x] "N scope(s) granted since this login" said the opposite of what it
-      meant, in all three places it was printed: `doctor local`'s scopes row,
-      `auth status`'s per-account lines, and `scope_gap`'s own doc comment.
-      Nothing was granted — these are the scopes *this build asks for* that
-      the session does not carry, which is why the row is a failure rather
-      than a note. The agent notes made it worse by telling an agent to
-      ignore the message "which is just advisory", when a missing scope fails
-      `doctor local` and refuses the command it names; they now separate it
-      from the expired access token, which really is advisory
-- [x] `--limit 0` parsed on `pr list`, `issue list` and `repo list` and then
-      printed "no open pull requests" / "no issues for " — an empty
-      listing that reads as *there are none*, which is the stale-index
-      failure this tree opted out of, arriving from the command line instead.
-      Exactly the `--state opne` bug wearing a different hat, and closed the
-      same way: a `value_parser` floor of 1, so it is clap's own refusal
-      naming the range, exit 2. `search` has carried that floor since it
-      shipped, which is how the other three were found
-- [x] `atgc logs oauth --limit 20` was "unexpected argument '--limit' found".
-      `-n`/`--lines` is `tail(1)`'s spelling and is the right one for a log,
-      but `--limit` is what every listing in the tool takes and what a caller
-      arrives here having learned, so it is a `visible_alias` now. No floor
-      on that one: 0 is meaningful for a log, being the head lines and the
-      summary with no events between them
-- [ ] None of the four listings has an upper bound except `search`, whose
-      1000 is Bobbin's own ceiling. `--limit 0` refusing now reads as "0 is
-      not in 1..=4294967295", which is accurate and ugly. A ceiling would fix
-      the message and invent a policy at the same time: nothing in the PDS
-      protocol caps these, and refusing a value that works today to tidy an
-      error message is the wrong trade without a caller who wants it
-- [ ] No config file. Twenty-four `ATGC_*` variables steer atgc — the
-      account, four service endpoints, two timeouts, four log paths, the
-      input gate, the log filter — and every one is set per invocation or
-      exported into a shell, where it then applies to every other tool in
-      that shell. There is nowhere to write down "always this account in
-      this checkout", "my appview is not tangled.org", or "keep thirty days
-      of logs". `~/.config/atgc/` already exists and already holds
-      `sessions.json` and `accounts.json`, so this is a third file rather
-      than a new directory, a new lock, or a new idea.
-      What makes it worth writing down is the precedence, not the parser.
-      `--account` already outranks `ATGC_ACCOUNT`, which outranks the
-      checkout's own `user.email`, which outranks the persisted active
-      pointer — and `auth login` and `auth switch` *print* that chain at
-      people, worked out from the state at the time. A file inserts a rung
-      into a ladder that is already explained out loud, and every one of
-      those sentences has to stay true afterwards. Flag beats env beats file
-      beats persisted state is the ordering that keeps them true: a file is
-      a standing preference and a variable is a deliberate override of one.
-      Format is a real cost and not a taste question. `serde_json` is
-      already here and the two neighbouring files are JSON, so JSON is free;
-      TOML is what a person expects to hand-edit and comment, and costs a
-      dependency atgc does not have. Free and consistent with its neighbours
-      probably wins, but it is a choice, not a default.
-      Two things it must not become. Not a per-repo place to set the four
-      endpoints — `ATGC_APPVIEW` and its siblings exist to point a developer
-      at a test service, and a checked-in file that silently redirects
-      somebody's PDS traffic is a different object with a different threat
-      model, so endpoints stay env-only or the file stays strictly
-      user-global. And not a second spelling for every flag: the value is in
-      the few settings a person holds constant. Start with the account, log
-      retention and the `ATGC_LOG` filter, and stop
-
-- [x] Output channels — one rule, on every flag: stdout is the answer,
-      everything else is on stderr. Warnings, notes and progress lines all
-      go through `crate::term::say` at a level (`warn`/`note`/`step`/`debug`) and
-      under a topic (`account`, `config`, `ssh`, `git`, `index`, `knot`,
-      `pds`, …), so `-q`/`-qq` and `ATGC_LOG=note,pds=debug` can turn them
-      down without touching the answer or the exit code. Replaces the old
-      `jsonout::say!`, which moved notes to stderr only under `--json` and
-      so left the same prose in the middle of every other pipeline; `--debug`
-      is now the loudest rung of the same ladder. The bug that prompted it:
-      `logs pds --json` printed two sentences of prose on stdout when no log
-      file existed yet, which is a `jq` parse error rather than an empty
-      stream. See docs/output.md
-- [ ] `-v` has no messages yet: the ladder has room for a verbose rung
-      between `step` and `debug`, and nothing emits at it. Add it when there
-      is a line that wants it, not before
-- [ ] `crate::debug`'s dumps are still topic-less — one `[debug]` firehose
-      gated by the global ceiling. Tagging them by subsystem would let
-      `ATGC_LOG=pds=debug` mean the PDS wire traffic specifically
-- [x] `about` — version and repo link on a panel over a drawn helix, sized
-      to the terminal (with a fixed frame when it can't be read, and bare
-      lines when it's too small for the panel); color drops out when stdout
-      isn't a terminal or NO_COLOR is set
-- [x] `browse` — open the repo's tangled.org page, with an optional section
-      path (`atgc browse pulls`); --no-open only prints the URL
-- [x] `agent` — working notes for AI agents, compiled into the binary so
-      they version with the code they describe. Plain ASCII, model-first:
-      the one fact (a pull is a record carrying patches) and its
-      consequences, then only the behaviors other forge CLIs lack. The
-      command-level help gained the same corrections at point of use
-      (`pr` group, `pr create`/`resubmit`, `auth login`, `repo` group)
-- [x] `agent` rewritten to track what the CLI grew into. `doctor` landed
-      before the notes' last edit and went unmentioned anyway; `api` and the
-      `issue` family landed after it and moved nothing here at all, leaving
-      42 commits with the page frozen. The 76-line cap is why — it was full,
-      so a subject could only arrive by displacing one, and none did. The cap
-      held anyway, concision being the ask, and the room came out of padding:
-      the two lines that editorialised rather than reported, the seven-line
-      images section (one bullet now), and the blank line under every
-      heading, which the indentation was already doing. Added `doctor` and
-      `auth login` as the first move in an unfamiliar checkout, `pr checkout
-      --worktree`, `api`, the eight exit-status meanings, `issue`'s two
-      refusals, and `logs git`
-- [x] `doctor` — eight checks in one report: who else on this machine can
-      read the stored credentials, the acting account and its
-      session, the scopes its login predates, whether a push from this
-      machine can be authenticated, this checkout's git identity, which repo
-      `origin` resolves to and from which directory, whether that repo's knot
-      answers, and how far Bobbin trails your PDS. Assembly, not new logic:
-      every fact was already computed by the command that needed it, at the
-      moment it needed it, which is why a diagnosis took seven commands. No
-      row is a precondition for the report — outside a checkout the
-      repo-scoped rows are `n/a` and the rest still answer, and logged out it
-      is the other way round. Exits on the first *broken* row (a warning is
-      not one), carrying the status the command that row stands for would
-      have exited with, so no new exit code was needed. The first command
-      here to overlap independent network reads rather than awaiting them in
-      a row, which is what keeps a machine that can reach nothing to one
-      connect timeout instead of six
-- [ ] `doctor`'s knot row asks the knot named on the repo record, so a repo
-      Bobbin has not indexed reads `n/a` — the record is addressed by an
-      owner and name only the index can supply. The redirect probe in
-      `clients/tangled/resolve.rs` already lands on the knot and could hand
-      it back, which would make that row independent of the index
-- [x] Exit statuses that mean something — `src/exit.rs`, eight of them, with
-      `main` returning `ExitCode` instead of `anyhow::Result`. Every failure
-      atgc has ever had exited 1, so a caller could tell that something went
-      wrong and nothing else; the five that want different next moves (no
-      session, denied, not found, unreachable, conflict) now say which. The
-      code rides inside one error type rather than as a marker beside it,
-      because both other shapes break the message — above it `to_string()`
-      goes empty, below it `{e:#}` gains a trailing `": "`, and `pr read`,
-      `stack write` and `backfill` all print errors with `{e:#}` today.
-      Unclassified stays 1, so this is additive. `Unreachable` is mostly
-      inferred rather than tagged: every request goes through one reqwest
-      client, and a connect failure or timeout says so about itself
-- [x] Errors print as `error: ` on stderr with stdout left empty,
-      matching `say`'s `warning:`/`note:` labels; `--debug` prints anyhow's
-      full multi-line form, which makes that flag's "full error details"
-      true of errors and not only of HTTP bodies
-- [x] Running atgc outside a checkout is a real error rather than git's
-      plumbing. `remote_url`, `current_branch` and `repo_root` forwarded
-      git's own `fatal: not a git repository (or any parent up to mount
-      point /)` with an unclassified 1 behind it, naming neither the
-      directory nor a way out. Four cases now, all `Usage`: no repository, no
-      remote of that name (listing the ones the checkout does have), a
-      detached HEAD, and a repo with no commits — the last of which used to
-      leak `ambiguous argument 'HEAD'`, because `rev-parse --abbrev-ref` fails
-      on an unborn ref before the detached check can run. Each names the
-      directory atgc looked in, which is the whole diagnosis when a shell or
-      an agent harness is standing somewhere other than its operator believes
-- [x] More sites worth classifying, done for `pr`, `repo` and the identifier
-      parser. `pr view 99999` and `pr diff 99999` exited the unclassified `1`
-      for a pull that does not exist, and the arm they actually take is not
-      the status check — tangled.org answers `/pulls/99999` with a 200 and a
-      page carrying no record URI, so "there is no such pull" is decided by
-      finding nothing in the HTML. That is `5` now, as is a branch with no
-      pull. `2` covers the command lines that name nothing: a pull or repo
-      reference that does not parse, a `--source` or `--round` value that
-      means nothing, `--from` without `--interdiff`, a `--website` with no
-      scheme, a `--spindle` that is a URL. `4` covers the account being
-      wrong: a round, an edit or a close against somebody else's record, and
-      an `--ssh` clone with no registered key, which is the status `doctor`
-      already gave that case. The knot funnel in `repo/mod.rs` reads the
-      status rather than assuming `Denied`, since these calls also answer
-      "not here" and "already there"
-- [ ] The knot funnel in `clients/tangled/knot.rs` is the one left. `Refused`
-      is a typed error carrying the knot's HTTP status and its `AccessControl`
-      tag, and `exit::classify` only downcasts `Coded`, so every refusal
-      through it — `pr merge`, `stack merge`, `repo delete-branch` — still
-      exits `1` with the answer sitting in a field. Two shapes work and the
-      choice is real: give `Refused` an `exit` field and teach `classify` to
-      ask it, or convert at the call sites the way `repo/` now does. The
-      first is less code and widens what `classify` knows about; the second
-      keeps exit.rs's "one error carries the code" rule intact
-- [x] One HTTP-status-to-exit table, not four. `exit::from_status` is it, and
-      `exit_for` kept only the XRPC-error-name layer on top, since a name like
-      `RecordNotFound` is more specific than the `400` it arrives with.
-      `knot_exit` is gone; `web/pulls.rs` and `repo/read.rs` call the table
-      instead of matching inline. Two arms moved as a result, both toward the
-      table and both worth naming: a knot answering `400`, `405` or `422` is
-      now `2` rather than `1` — XRPC puts most refusals there and a request a
-      knot will not accept is a command line to fix — and a `502` from the
-      appview's pull page is now `6` rather than `1`
-- [x] The two readers that authenticate nothing take `from_public_status`, the
-      same table with the four session-shaped answers removed, rather than a
-      subset written by hand. A DID document from plc.directory and a pull's
-      page from the appview are public GETs, and `4` on one of them would tell
-      the reader to try a different account when the request never carried
-      one. Derived from `from_status` and not a second table, so a row added
-      there is considered here too; the sweep test pins that every status
-      answers either the table's answer or `1`
-- [x] `ellipsize` and `day` are `term::column`, not `cmd::pr::read`. Neither is
-      about pull requests: `issue list` and `search` were reaching in as
-      `crate::cmd::pr::read::ellipsize`, `repo list` had the cut open-coded
-      with the off-by-one done by hand, and `day` — ten characters off an
-      RFC 3339 stamp — existed five times over as the same one-line body.
-      `cmd/pr/mod.rs` argued there was nothing to put in a shared module
-      because neither had a caller outside the read half; that is why the
-      paragraph making the argument moved too
-- [ ] One backwards walk over a subject's records, not two.
-      `cmd/issue/read.rs`'s `state_events` and `cmd/pr/write.rs`'s
-      `list_statuses` are the same function: page `listRecords` newest-first,
-      keep the records naming this subject, and stop once a page ends below
-      the subject's own rkey, because a record about a thing cannot predate
-      it. They differ in the NSID, the subject field (`issue`/`pull`), the
-      state field (`state`/`status`), the struct built, and a 50-page cap
-      spelled twice under two names. The comparison is strict in both, for the
-      same unobvious reason — Tangled's backfill wrote state records under the
-      *subject's* own key and those must still be seen — and a rule that
-      subtle stated twice is a rule that will be fixed once
-- [ ] Not to be merged with it: `newest_state` and `state_of` look like the
-      same pair and are deliberately not. The issue half orders on a parsed
-      instant because it merges the author's records with the acting account's
-      and two accounts write `…Z` and `…+03:00`; the pull half reads one PDS
-      and orders on the string. Written down because the two are a page apart
-      and the obvious cleanup is wrong — see `StateEvent::instant`, which
-      argues it. If the pull half ever merges a second account's statuses it
-      needs the instant compare, and that is the change to make, not the
-      sharing
-- [x] `report`'s `error:` label takes `style::BAD`, through the same
-      `say::label` the `warning:` and `note:` prefixes go through rather than a
-      second copy of the gate. That gate is stderr's — `is_terminal`,
-      `NO_COLOR`, `TERM=dumb`, and no `--json` veto — so `atgc … --json` still
-      colours the label above the document it did not touch
-- [x] `--version` says which build, not just which release — the commit on the
-      version line, then target triple, profile and rustc under it. `-V` stays
-      one bare line for scripts. `build.rs` records the four while they are
-      still knowable and `src/build_info.rs` is the only reader; every value
-      can be empty and an empty one drops its line, because a crates.io
-      tarball carries no `.git` and `commit unknown` would be worse than no
-      commit line in exactly the build this is groundwork for. No dirty flag
-      and no build timestamp, both on purpose — see build.rs for why each
-      would have been a field that lies
-- [x] `report`'s diagnostics gained the same version line, and its platform is
-      now the target triple rather than `OS (ARCH)`, which said
-      `linux (x86_64)` for a glibc and a musl build alike. Still names of
-      software and one public host, so the privacy rule the block is written
-      to is unchanged
-- [ ] `about` still prints the bare version on its panel. Left alone
-      deliberately: the panel is sized to its widest line and the art is laid
-      out around it, so a build line is a layout change rather than a text
-      one. Worth doing when the panel next moves
-- [x] `html/` — a folder for the documents atgc serves a browser, sibling to
-      `term/`, and `art.rs` at the root for the field of DNA both media draw.
-      The OAuth callback pages left `clients/atproto/oauth/`, where markup and
-      SVG sat in the middle of a module about tokens; the field left
-      `cmd/about.rs`, which is how a *client* came to import a **command**
-      module for `field_html` — the one edge in the tree running against
-      docs/module-layout.md. `respond_html` stays with the client: the line
-      falls at the `TcpStream`, since building a page opens nothing and
-      writing one to a socket is talking to a counterpart. A move and not a
-      rewrite: `atgc about` is byte-identical across 16 frame sizes, and the
-      two renderings now share `Canvas::rows` rather than keeping a
-      `rposition` call each
-- [x] One style vocabulary, on `anstyle` — `term/style.rs` holds the six
-      styles anything painted is painted in, and the one `paint`. Three
-      modules had each grown the same `format!("\x1b[{code}m{text}\x1b[0m")`
-      over a stringly-typed SGR parameter: `say`'s prefixes, `logs`' `Palette`
-      and `repo view`'s bold heading. Nothing was wrong with any of them,
-      which is the problem with three copies — `93` was the warning colour in
-      two of them by coincidence, and a fourth caller had nowhere to look it
-      up. anstyle was already in the tree via clap, so it costs no dependency,
-      and the bytes are unchanged: `logs oauth` and `logs pds` render
-      byte-identically against a read-only fixture, across all six styles
-- [x] The `art` canvas carries an `Ink` per cell — `A`/`T`/`G`/`C`/`Muted`/
-      `Default` — instead of an SGR parameter. `art` maps it to an
-      `anstyle::Style`, `html::field` to a CSS class, and `class_for` no
-      longer recovers a base by searching for which one produces a given
-      escape. The four base colors stay in `art` rather than joining
-      `term::style`'s six: those are semantic and these are the chromatogram
-      colors the bases are read in, so retheming atgc's secondary text should
-      not repaint the field's hydrogen bonds
-- [x] SIGPIPE restored to its default disposition in `main`, so a reader that
-      stops reading ends atgc quietly instead of panicking. Rust sets it to
-      `SIG_IGN` before `main` and `println!` panics on the resulting `EPIPE`,
-      which made `atgc completion zsh | head` exit 101 with a Rust panic from
-      inside `clap_complete` — a failure no `Result` returned to `main` could
-      have caught, and one that applied to all ~240 `println!` sites. Through
-      the `sigpipe` crate because the call is `signal(2)` and this crate
-      forbids `unsafe`; it adds eight lines and no transitive dependency,
-      libc already being in the tree. `tests/broken_pipe.rs` drives the real
-      binary and fails without the fix. One consequence, stated rather than
-      discovered later: quitting the pager early during `pr diff` now ends
-      atgc with SIGPIPE where the write error used to be swallowed, which is
-      where git ends too, and needs a patch past the 64 KiB pipe buffer to
-      reach at all
-- [x] The atproto vocabulary comes from jacquard rather than being restated.
-      `Did`/`Handle` carry the grammar and `identity.rs` explains a refusal in
-      atgc's own words afterwards; `AtUri` decides whether a string is a
-      record URI, which the old `split('/')` could not — it knew nothing about
-      NSIDs; `DidDocument` replaces indexing into a `serde_json::Value` by
-      hand. Breaking: the handle spec's reserved TLDs (`.local`, `.internal`,
-      …) are refused now, where this module deliberately allowed them. Such a
-      handle was never resolvable — the list exists because those names are
-      not globally unique — so what changed is where the refusal happens
-- [x] A handle resolves the four ways the spec defines — DNS TXT, well-known,
-      the account's PDS, then a public appview — instead of only asking
-      `public.api.bsky.app`. A self-hosted account whose appview has never
-      indexed it did not resolve here at all, and every handle anyone named
-      went to a third party. `jacquard::identity` was already compiled in:
-      `dns` is in jacquard's default features, so atgc had been shipping
-      `hickory-resolver` and never calling it. Demonstrated on this project's
-      own handle, which now resolves by DNS with zero requests to Bluesky
-- [ ] Three fetchers for one DID document: `clients::atproto::did::did_doc`,
-      `cmd::repo::read::owner_doc` and jacquard's own `resolve_did_doc`. The
-      first two are now typed the same and differ only in whether a failure
-      is an `Option` or a `Result` with a sentence attached. Collapsing them
-      onto jacquard's resolver — which also validates that the document's
-      `id` matches the DID asked for, and which nothing here does — is the
-      next step and wants its own change
-- [x] One DID-to-handle resolver, `clients::atproto::handles`, in front of a
-      memo that lives exactly as long as the process. Four listings had their
-      own `buffer_unordered` over `handle_from_did_doc` — `pr list`, `issue
-      list`, `search` and a `view`'s comment thread — differing in the parts
-      nobody meant them to differ in and agreeing that each would re-fetch a
-      document the same invocation had already read. What is cached is only
-      DID to handle, only successes, only up to a cap, and never to disk: a
-      handle is a hint that can be re-registered by somebody else, so seconds
-      is the whole safe window. The other direction is not cached at all —
-      `resolve_handle` decides which account atgc acts as, and `repo
-      checkout` keeps asking the document itself because that handle is
-      written into a checkout's config
-- [x] The module layout is enforced rather than described. Everything in
-      `cmd/` is now `pub(super)` (119), `pub(in crate::cmd)` (41) or —
-      for the entry points `main.rs` dispatches through, the clap types
-      reachable from them, and the JSON shapes
-      docs/output.md links to by path — `pub(crate)` (125).
-      No bare `pub` is left. That
-      makes "nothing outside `cmd/` imports `cmd/`" a compile error for
-      everything except those entry points, which is as far as visibility can
-      go inside one crate
-- [x] `tests/module_layout.rs` for the two rules visibility cannot express —
-      `lexicon/` importing `clients/`, and a client asking `config/` who we
-      are. Neither is expressible because `clients` has to be `pub(crate)`
-      for `cmd/` to call it, and `pub(crate)` is visible to `lexicon/` too;
-      only a workspace split would do it. Both were violated in the tree at
-      some point and both were found by grep, which is the argument for the
-      test
-- [x] The one edge that pointed the wrong way: `clients/atproto/oauth/
-      pages.rs` called `cmd::about::field_html`. It was named as the single
-      allowed exception in `tests/module_layout.rs`, and the `html/`+`art.rs`
-      move deleted both the call and the allowlist entry — the page and the
-      field of DNA now live in `html/` and `art.rs`, and `pages.rs` is gone.
-      Nothing outside `cmd/` imports `cmd/` any more, in code or in the
-      allowlist
-- [x] `auth.rs` split into `clients/atproto/oauth/{client,sessions,store,
-      login}.rs` and `cmd/auth.rs`, which is the piece the folder move
-      deliberately left behind. A pure move: no printed string and no step of
-      the OAuth flow changed. The seam is the `config/` constraint rather
-      than the verbs — `login` and `agent_for_did` read
-      `crate::config::account` to pick a DID and so stay in `cmd/`, while
-      everything they call takes that DID as an argument
-- [x] The HTTP bounds are the user's to set: `--connect-timeout` /
-      `ATGC_CONNECT_TIMEOUT` and `--read-timeout` / `ATGC_READ_TIMEOUT`,
-      flag over environment over default, the same precedence `--account`
-      and `--no-input` follow. The five seconds is a judgement about what a
-      *reachable* host takes, and it is right for the network it was measured
-      on and wrong for others — a tether, a slow relay, a runner in another
-      hemisphere. When it is wrong the failure reads "could not reach", which
-      is indistinguishable from the host being down. The default stays short
-      because a script or an agent would rather be told quickly
-- [ ] Neither bound can be switched off: 0 is refused, because an unbounded
-      connect is the wait `clients/http.rs` was written for. Revisit if a case
-      turns up that a very large number cannot serve
-- [x] non-interactive mode — `--no-input` / `ATGC_NO_INPUT=1`, automatic
-      when stdin is not a terminal or `CI` is set: `auth login` refuses with
-      instructions instead of waiting out its five-minute browser timeout,
-      `browse`/`--web` print URLs without opening anything, and git runs
-      with `GIT_TERMINAL_PROMPT=0` (plus SSH BatchMode on clones) so it
-      fails rather than prompts. Agent-vendor env vars deliberately ignored;
-      the signals are universal ones only
-- [x] A gap in that gating, found after the fact: `pr checkout`'s fetch of a
-      branch-based pull's source branch went through `review.rs`'s own git
-      runners, which predate `clients/git/run.rs`'s `GIT_TERMINAL_PROMPT=0` and never
-      read it, so a credential prompt on that one real network fetch could
-      still hang. Those runners now build their `Command` from `git::cmd()`
-      instead of a bare `Command::new("git")`, so every git subprocess in
-      the tree shares the one gate, fetch included
-- [x] A second gap next to it: `repo clone --ssh` skipped its own refusal
-      and its own `BatchMode` both when the push-key lookup itself failed —
-      a network hiccup, not a confirmed missing key — because the failure
-      collapsed to the same `None` a confirmed miss produces, and only the
-      confirmed case was refused. An unverified key went ahead over SSH
-      with no gate on it at all. `--ssh` now refuses on a lookup failure
-      explicitly, the same way it already refused on a confirmed one
-- [x] `hyperlink.rs`'s OSC-8 gate said in its own comment that it matched
-      the colour gate `about.rs`, `repo.rs`, `review.rs` and `authlog.rs`
-      each carried a copy of, `TERM=dumb` included — but none of the four
-      actually checked `TERM`, so a dumb terminal suppressed links while
-      still receiving raw ANSI colour underneath them. Factored the
-      three-fact decision (a real terminal, `NO_COLOR` unset, `TERM` not
-      `dumb`) into one pure function, `hyperlink::escapes_wanted`, and
-      pointed every call site at it, so the comment is true instead of
-      aspirational and there is one place left to get the policy right
-- [x] brand — a logo, in `brand/`. The mark is two base pairs, A-T over
-      G-C, because that is what the name is; the four colors, the two-line
-      and three-line rungs, and the rounded panel all come from `about`'s
-      art rather than being invented beside it. Two rungs and not five
-      because five is illegible at 16px. Generated by `brand/build.py`
-      from the geometry and palettes in `brand/gen.py`, the same way the
-      helix is drawn and not stored, with `--check` to catch assets that
-      drift from it
-- [x] README header — the readme leads with the lockup (mark and name) at
-      512px instead of a 128px mark under an `# atgc` heading that said the
-      name a second time. The lockup rasters now come from a neutral master
-      the way `logomark.svg` already did: Tangled themes itself by swapping
-      a `dark` class, so a raster in a README cannot know its background,
-      and the `` element that answers this on GitHub is dropped by
-      Tangled's sanitizer (`appview/pages/markup/sanitizer`, which allows
-      `source`'s attributes but never the `picture` element)
-- [ ] Serve the favicon from the OAuth callback page. That page already
-      prerenders `about`'s field of DNA, and it is the one HTML atgc
-      serves, so it is the only place in the tree that can use
-      `brand/favicon.ico` without a host to put it on. Inline it as a data
-      URI rather than adding a second request to a loopback server that
-      exists for a few seconds
-- [x] `browse --pr` — the current branch's PR instead of a hand-typed
-      section path. `pr view`'s resolution exactly, shared rather than
-      copied: the same gather, the same `source.branch` match, the same
-      refusal to fall back to a newer pull on another branch, so the page it
-      opens is the pull `pr view` would print or the same error saying why
-      there isn't one. Conflicts with a section argument by declaration
-      rather than by precedence
-- [x] `browse --json` — the URL as data, with the pull's at:// URI beside it
-      when `--pr` resolved one, and whether a browser was actually opened.
-      The one command whose whole output was a URL was the one that could
-      not be read by a script without parsing stdout
-- [ ] `status` — cross-repo overview: your open PRs and mentions. The name is
-      free now and the group exists (see the `status` section); this is the
-      bare `atgc status`, which today prints the group's help. `status pr`
-      already covers the authored-PR half, so what is left is the mentions
-- [x] `api` — an authenticated raw XRPC escape hatch, `atgc api `.
-      Tangled's lexicon is far larger than the part atgc has verbs for —
-      issues, labels, stars, follows, collaborators, secrets, artifacts,
-      pipelines, notifications — and none of it was reachable at all. The
-      design question was which credential, and the answer is that the host
-      decides: `--host pds|knot:|appview|bobbin`. "An XRPC call" is
-      three unrelated acts here — a PDS call under the session's DPoP-bound
-      access token, a knot procedure under a service-auth JWT minted for that
-      one method, a public read under nothing — so a flag naming the
-      *credential* would ask the user to already know the thing they came
-      here to find out, and a wrong guess would hand a third-party knot a
-      token for the PDS. A knot's queries and its procedures therefore differ
-      under one `--host`, which is not a special case: its queries are public
-      and its procedures are not
-- [x] `api`'s parameters are `gh api`'s, spelling and meaning both, because
-      that is the muscle memory people arrive with: `-F` guesses a type, `-f`
-      never does, and both go in the query string of a query and the JSON
-      body of a procedure. XRPC declares query-or-procedure and a method name
-      does not, so `--input` means a procedure, `-X` settles it, and a wrong
-      guess comes back as the 404/405 it really is with a line naming the
-      other verb. No `--json`: the output is the service's own answer, so the
-      flag would be a switch with nothing behind it. A refusal prints the
-      service's `error` and `message` and exits as that kind of refusal —
-      the XRPC error name decides over the HTTP status, since a PDS answers
-      `RecordNotFound` with a 400
-- [x] `api` adds no OAuth scope, and could not: a scope added to `SCOPES` is
-      a re-login for every account, and an escape hatch is the last thing
-      that should cost one. What it reaches is what the grant already covers,
-      and a `createRecord`/`putRecord`/`deleteRecord`/`applyWrites` whose
-      collection the grant does not name fails with `scope.rs`'s own refusal
-      before the request, naming the scope and the login that would grant it.
-      A knot procedure goes through `require_rpc` the same way. Writes made
-      this way land in `pds.jsonl` like every other write, because the
-      DPoP arm sends through `logging::pds::LoggedPdsClient` — a hatch able
-      to create a record outside that log would be the one hole in it
-- [ ] `atgc api` cannot address a service by URL, only by the four names
-      `--host` takes, so reading *another* account's PDS directly — the shape
-      that answers "does their record really say that" — is not reachable and
-      `--host pds` always means the acting account's own. The names are what
-      make the credential derivable; a URL would have to be sent
-      unauthenticated, or be asked which credential it takes, and both are
-      worse than not having it. `ATGC_BOBBIN`, `ATGC_APPVIEW` and `ATGC_KNOT`
-      still move the three that are not the PDS
-- [ ] `atgc api --host knot:` needs the knot's hostname typed out. Deriving
-      it from the checkout means turning a repo DID back into an owner and a
-      name, which only the appview's index can do — the same gap `doctor`'s
-      knot row has — and guessing it off the `origin` remote is wrong for
-      every repo cloned through tangled.org, which proxies git for its knots:
-      the guess would mint a service-auth token for `did:web:tangled.org` and
-      send it to something that is not the knot. Refusing to guess is the
-      right failure, but it does mean `atgc repo view` first
-- [ ] `atgc api` does not write the DPoP nonce back to the session store.
-      `DpopCall` retries once on a nonce challenge by itself, so the cost is
-      one extra round trip on the first `api` call of a process; carrying it
-      back would mean this command rewriting jacquard's own session record
-      behind jacquard's back, which is a worse trade for one round trip
-- [ ] `atgc api` sends JSON bodies only, so `com.atproto.repo.uploadBlob` —
-      the one PDS procedure whose body is bytes — is not reachable through
-      it, and neither is `gh api`'s `-F key=@file`. `--input` is the
-      file-shaped answer here and two spellings of it would be a choice with
-      no right answer, but a blob upload genuinely has no spelling yet
-- [x] `atgc api` reads no more of an answer than anything else does:
-      `http::MAX_BODY`, with no ceiling of its own. A raw hatch is the most
-      plausible place to want a bigger one and still the wrong place to grant
-      it — nothing anybody reads at a terminal is within an order of
-      magnitude of eight mebibytes, the one case that could genuinely exceed
-      it is a patch blob, which `pr diff --max-bytes` already reads properly,
-      and a per-command ceiling would be the single exception to "atgc holds
-      at most this much of a stranger's response" on the command most likely
-      to be pointed at a stranger. The public arm stops mid-stream through
-      `bytes_bounded`; the authenticated arm is the entry below
-- [ ] **A body read through jacquard's transport is buffered whole before
-      atgc sees it**, so `http::MAX_BODY` cannot be enforced as it arrives on
-      any authenticated request — every PDS write atgc makes, plus `atgc
-      api`'s `--host pds` arm. `HttpClient::send_http`'s whole contract is
-      `http::Response>`, and its associated error type is
-      `reqwest::Error`, which there is no way to build an `anyhow` refusal
-      out of; bounding it means either a fork of that trait's impl or an
-      upstream change. `http::refuse_if_oversize` closes the visible half —
-      an over-limit body is not handed to a caller or printed, and `api`'s
-      two arms answer identically — and does not stop the memory being
-      spent. That is the honest state of it and the reason this entry exists
-      rather than the cap quietly having an exception in it
-- [x] shell completions (clap_complete) — `atgc completion ` writes a
-      static script to stdout for bash/zsh/fish/powershell/elvish; generated
-      from the binary, never checked in. Dynamic completion was rejected:
-      it routes every TAB through a process spawn, and the values worth
-      completing are behind Bobbin
-- [x] `--help`'s command list is split into three headed groups — meta and
-      identity, repository operations, other — instead of one flat column.
-      clap cannot do this itself (`help_heading` is for arguments, and
-      `subcommand_help_heading` renames the one section), so `help.rs`
-      renders the section from the built command tree and hands it to the
-      root as `before_help`, with a template that drops clap's own list.
-      Only the grouping is written down; names and abouts still come out of
-      the tree, and a test holds that the groups cover it exactly
-- [x] **Every JSON response atgc reads is unbounded.** `resp.text()` or
-      `resp.json()` with no size cap, at ten sites across `atproto/pds.rs`,
-      `atproto/did.rs`, `tangled/knot.rs`, `web/pulls.rs` and the `repo`
-      family. `clients/http.rs` bounds time only — its own doc says
-      `READ_TIMEOUT` caps the gap *between* bytes of a response — so a server
-      that streams steadily never trips it, and `pds.rs`'s claim of "the
-      bounded request through `crate::clients::http`" is true of latency and
-      false of size. The hosts are not atgc's: a `did:web` in a pull URI
-      names a hostname the author chose, and a `did:plc` document's
-      `serviceEndpoint` is equally theirs, so `atgc pr view
-      at://did:web:evil.example/…` fetches `.well-known/did.json` from them
-      and reads whatever they send, then reads that host's `getRecord`
-      response the same way. Same reach through `pr diff`, `pr list` and
-      `repo clone /` for any repo whose owner or pull author is
-      hostile. The blob path was hardened for exactly this and has
-      `blob_bounded` and a `RunningTotal`, with a doc noting a
-      `Content-Length` is only as trustworthy as its sender; the JSON path
-      never got it. Now it does: `http::MAX_BODY` is 8 MiB and fixed,
-      `text_bounded`/`bytes_bounded` share the one streaming loop that
-      `blob_bounded` now calls rather than owning, and eleven call sites go
-      through it. A refusal names the host and the limit, hands back none of
-      what it read, and exits `6` — the status this module's other two
-      bounds already carry. Measured against the flood: peak RSS 143 MB
-      before, 33 MB after. `bobbin.rs:79` and `web/backfill.rs:147` are the
-      two reads still unbounded; another branch owned both files at the
-      time, and converting them is a two-line change each
-- [x] Two exit statuses docs/output.md promises that no code produces — and
-      the entry was wrong in both directions, which is why it contradicted
-      itself: it claimed nothing produced `4` or `7` while also saying the
-      sites "already hold the status when they refuse". Driven against the
-      real binary, both were already reachable before this branch. `4`:
-      `atgc doctor local` exits it on a scope gap or an unregistered push key,
-      `repo default-branch` on a repo you do not own exits it through
-      `repo/mod.rs`'s `own_repo`, and `tests/issue_flows.rs` had been pinning
-      it since the issue family was classified. `7`: hold
-      `~/.config/atgc/.lock` with `flock` and any command that writes account
-      state exits it from `config/lock.rs` after its 30-second wait, and
-      `atgc api` maps an `InvalidSwap` to it. What was actually missing was
-      narrower than "the status": `record.rs`'s `swap_advice` and
-      `batch_advice` built bare `anyhow!`s, so the one refusal that means
-      *re-read and try again* was indistinguishable from the ones that mean
-      *this will fail identically forever*. Now `7`, with both unit tests
-      pinning the classification in both directions. The knot half is still
-      open and has its own entry above: `knot.rs`'s `Refused` holds an HTTP
-      status and an `AccessControl` tag that `exit::classify` never looks at
-- [x] `day()` parses. `term::column::day` took the first ten characters of a
-      raw stamp, so a record written at `+03:00` printed the *writer's* local
-      date and two rows of one listing could disagree by a day about the same
-      instant. It is `DateTime::parse_from_rfc3339` now, rendered in UTC —
-      the only choice that makes two rows comparable, which is what a column
-      is for, and the rule is written down in docs/output.md. Visible
-      consequence, intended: a record written at `+03:00` just before
-      midnight prints the previous day. Anything that will not parse still
-      falls back to the first ten characters, so an empty `createdAt` still
-      comes back empty and a malformed record still lists.
-      `chrono` is a direct dependency for it, which reopens the line in the
-      surveyed-and-left-alone entry below on purpose; the reasoning is
-      recorded there and in Cargo.toml. One correction to what this entry
-      used to say: `Datetime` *does* hand back its inner value, through
-      `impl AsRef>` — the inherent methods are
-      only `as_str`/`timestamp*`, which is what the survey saw. It does not
-      help here either way, because `day` takes a raw `&str` from
-      `serde_json` at every call site and jacquard's own `ISO8601_REGEX` is
-      stricter than RFC 3339 (it rejects `-10:00`, among others), so routing
-      through it would narrow what a listing can render.
-      Still worth a jacquard report: `Datetime` could offer a `date` or
-      `format` of its own, and the `AsRef` impl deserves a mention on the
-      type rather than only in three method docs
-- [ ] The `"?"` sentinel is round-tripped through three modules while
-      docs/output.md says unknown is `null`, never `"?"`. `State` is a proper
-      enum, `label()` flattens `Unknown` to the string, `StackRow.state` and
-      `stack::state_of` carry it as a `String`, and `known_state` at the far
-      end re-derives the type by comparing against `"?"` again. What should
-      *not* change with it: `State::Known(String)` holds whatever word the
-      index returned, which may be one this build has never heard of, and
-      typing it would silently drop a future state
-- [x] Fixed columns pad by cells now, not by `char`. `term::column::width`
-      answers "how many terminal cells will this print in", with `pad_to` and
-      `pad_start` beside it, and every `{:N}` over a string in the
-      tree goes through them. It fixes two defects at once: an emoji or CJK
-      title was half the cells it should be and shoved every column to its
-      right, and padding applied *after* painting counted the SGR bytes and
-      so applied none — which is what `logs pds` was losing a column to. So
-      `width` discounts escapes rather than only measuring characters: CSI
-      for colour, and OSC 8 for the hyperlinks where a whole URL sits in the
-      string and draws as nothing. `unicode_width` alone gets that wrong in
-      the same direction the `char` count did.
-      The truncation *unit* is unchanged and stays chosen: `ellipsize` counts
-      chars, a test pins the emoji case, and a second test now pins that the
-      two units are allowed to disagree.
-      `unicode-width` is taken at 0.1, the copy miette already pulls in, so
-      it costs no crate; 0.2 would have put a second one in the graph
-- [ ] No table crate, surveyed while sizing the entry above and recorded so
-      it is not surveyed twice. `comfy-table` and `tabled` both draw a
-      *table* — borders, headers, a layout engine — where every listing here
-      prints bare space-separated columns in the `gh` idiom, so either one
-      would be fought to make it render less; `comfy-table` is ANSI-aware
-      only behind `custom_styling`, which pulls `console`, and `tabled`'s
-      width machinery is a dependency tree of its own. `tabwriter` is the
-      right size and solves the wrong problem: elastic tabstops size a
-      column to its content, and these widths are fixed on purpose so that
-      two invocations line up. What is wanted is one `term::width` and one
-      `pad_to` beside it — the entry above, and about forty lines
-- [x] `tempfile` as a dev-dependency. Eleven hand-rolled temp-directory
-      helpers across `testutil.rs`, `config/`, `cmd/`, `logging/` and
-      `tests/` — not nine — plus seven more directories built inline at the
-      test that used them, four with a `Drop` impl of their own, most
-      disambiguating by pid, which is an assumption about how the suite runs.
-      Dev-only, so it never ships. Production `write_atomic` stays as it is:
-      it sets the mode on the `open(2)` call, which `NamedTempFile::persist`
-      would regress.
-      The one thing that did not survive a straight swap, recorded because it
-      is invisible until something asserts on it: **`tempfile` creates a
-      directory `0o777 & ~umask`**, where the helpers it replaced handed back
-      a path that did not exist yet and let `create_private` make it `0700`.
-      `create_private` is `DirBuilder::recursive(true).mode(…)`, and that
-      applies its mode only to a directory it actually creates — so on an
-      existing one it is a silent no-op. Two consequences. The narrowing test
-      caught it directly, and now creates a child of the throwaway root so
-      the directory under test is one `create_private` made. And the five
-      helpers standing in for `~/.config/atgc` are pinned to `0700` through
-      `Builder::permissions`, because a test that plants a `0600` store file
-      inside a world-traversable directory is checking something weaker than
-      the thing it names. The ordinary scratch directories are left at the
-      default, which is what `create_dir_all` gave them before
-- [x] `cmd/pr/read.rs` was past the split rule at 4,287 lines, and the seam
-      was not the verbs. It is a directory now, along the line the module doc
-      had been drawing all along. `sources.rs` is the source reconciliation —
-      `Source`, `Reach`, `pds_pulls`, `merge`, `missing_from_bobbin`,
-      `warn_stale`, `gather`, `settle_own_open` — which is to say deciding
-      what the set of pulls *is*; `mod.rs` is the arguments, the two listing
-      verbs, `pr view` and the `--json` shapes, which is deciding how to say
-      it. A third file fell out rather than being planned: `labels.rs`, the
-      handle, repo-name and appview-number lookups, which neither half owns
-      and both need — the columns want them and so does the backfill, for a
-      repo's web root. `Listed` is the currency between all three, so there
-      is no arrangement without a back edge; three files at least name what
-      each one is. Re-exports keep every `crate::cmd::pr::read::…` path other
-      modules already use, so nothing outside the directory moved, and the
-      only visibility that widened is private → `pub(super)` on the items a
-      listing reads off a gathered row. The three entries this was said to be
-      waiting behind did not block it in the end: the concurrency and
-      ordering ones landed, and the `"?"` sentinel one is a change to
-      `State`, `label()` and `known_state`, which now sit closer together
-      than they did
-- [ ] Two doc comments in the read half are attached to the wrong item, both
-      found by the split and both deliberately moved as they were rather than
-      fixed inside a move. `append_backfilled`'s paragraph — "Splice scraped
-      pulls into a merged listing", which describes an append and says why
-      `indexed` stays false — sits on top of `apply_backfill_states`, whose
-      own doc runs on underneath it in the same block, so the function it
-      describes has none. And in the tests, "The observation this whole
-      change is built on, as it stood on" is a half-sentence stranded above
-      `builds_a_repo_url_from_the_label_it_already_had`, which it says
-      nothing about; it is a fragment of the `-- the staleness evidence --`
-      section's opening, and the separator now sits in `sources.rs` with the
-      tests it heads while the fragment went to `labels.rs` with the test it
-      is stuck to
-- [ ] Three near-identical body readers — `new_body` and `comment_body` in
-      `pr/write.rs`, `read_body` in `report.rs` — with identical `(Some,
-      Some)` / `(Some, None)` / `"-"` / file / `(None, None)` arms, differing
-      only in their context strings and in what empty means. `report.rs`
-      already cross-references `pr write`'s reasoning, so the duplication is
-      known. Low value: it is not currently diverging
-- [ ] Surveyed and deliberately left alone, recorded so nobody re-litigates
-      them: the two percent-encoders (not inverses, and documented at length
-      as to why), the four base64/base32/CID hand-rolls, `config/lock.rs` on
-      std's `File::try_lock`, the thirty-line word wrapper, a retry/backoff
-      crate, `rand`/`uuid` for the invocation id, `strsim`, shell-quoting and
-      glob crates, and the sixty-line loopback HTTP server.
-      `chrono` was on this list and has been taken off it deliberately, not
-      by oversight. The rejection was made against a cost that is not paid:
-      jacquard-common holds a `chrono::DateTime` inside every
-      `Datetime`, with the same feature set, so chrono is compiled into every
-      build of atgc regardless and declaring it adds no crate and no compile
-      time. What it buys is `term::column::day` printing a real UTC calendar
-      date instead of ten characters off the front of somebody else's local
-      stamp — a behaviour fix, and one with no cheaper spelling: `day` takes
-      a raw `&str` at every call site, and reaching through
-      `jacquard::common::deps::chrono` instead would couple atgc to another
-      crate's re-export surface, which it is free to withdraw
-
-## Infrastructure
-- [ ] Every fresh worktree pays one >4 GiB compile, and it is not the debug
-      info. `[patch.crates-io] jacquard-oauth = { path = "vendor/…" }` puts an
-      absolute path into the metadata hash, so `jacquard-oauth` and everything
-      downstream of it — `jacquard-api` above all — is rebuilt from cold in
-      every new worktree, however warm the main checkout's target dir is.
-      Measured while capping builds in a cgroup: a single `rustc` on
-      `jacquard-api` will not complete under a 4 GiB ceiling even at `-j1`
-      with the machine otherwise idle, while atgc's own units peak around
-      1.5-1.8 GiB. That is worth knowing before running two agents at once on
-      a 30 GiB laptop, and it is **not** an argument for a shared target
-      directory — that was reversed deliberately in 0a8a56e and would trade a
-      known cost for a known bug. Left recorded rather than fixed: the patch
-      is what pins the three vendored fixes, and dropping it is the same work
-      as landing them upstream
-- [ ] **The read paths stay on `serde_json::Value`, and that is a finding
-      rather than a backlog item.** The generated bindings under
-      `vendor/tangled-lexicon/` are lexicon-*faithful*: a property the schema
-      marks required is a non-`Option` field, so a record missing it fails to
-      deserialize whole rather than arriving with that one field empty. Every
-      `sh.tangled.*` record atgc reads was written by somebody else, and the
-      shapes in the wild predate the schema:
-
-      - a pre-rounds `sh.tangled.repo.pull` carries an inline `patch` and has
-        neither `rounds` nor `target`, both required, so `Pull` refuses it
-        outright — while `pr list`, `pr view`, `pr diff` and `pr merge` all
-        still work on one;
-      - a legacy `sh.tangled.repo.issue.comment` names its subject as a bare
-        `issue` at-uri and its body as a plain string, where
-        `sh.tangled.feed.comment` takes a `subject` strongRef and a markdown
-        object, so neither type reads the other's record
-        (`clients::tangled::comments` reads both shapes);
-      - `sh.tangled.repo.issue` types `repo` as a DID and the appview still
-        ingests records carrying an at-uri there, which `FetchedIssue::
-        repo_did` answers `None` for — where `Issue` would refuse the record.
-
-      So typing a read path is either *less* tolerant than what is there, or
-      exactly what is there with a discarded parse in front of it. The
-      generated types earn their keep on the write side, where atgc controls
-      the bytes and a schema-invalid record is a bug — `cmd/*/write.rs`,
-      `cmd/key.rs` and `fetch_own_pull` already use them, and `fetch_own_pull`
-      says in as many words that it is "the caller that wants the lot".
-      `lexicon::tangled`'s `generated_types_against_live_records` pins which
-      captured shapes each generated type accepts and which it refuses, so a
-      regeneration against a tightened schema fails there instead of in
-      somebody's listing
-- [x] The issue family is captured now: eight files under `tests/fixtures/`,
-      taken off live PDSes and off Bobbin by unauthenticated requests, run
-      through the generated types in `lexicon::tangled` and through the read
-      path's own helpers in `cmd/issue/fixtures.rs`. What they pin, in the
-      order the prose claimed it:
-
-      - **`repo` holds an at-uri in the wild.** 268 of 339 records surveyed
-        across eight accounts do, and four of the ten on the captured page.
-        `Issue` refuses every one; `FetchedIssue::repo_did` answers `None`,
-        as its doc comment says, and now on bytes rather than on a
-        description of them.
-      - **`createdAt` is required and often absent or empty.** 99 of the 339
-        carry no usable stamp — 12 without the property at all, 87 with the
-        empty string — and 82 of 130 `.issue.state` records have none either,
-        two of them naming no issue at all. `state_event` reads them and lets
-        them lose on the sort, which was the claim.
-      - **`body` is optional and always there.** Every one of the 339 has
-        one, which is the appview's `issue body is empty` refusal seen from
-        the record side, and the reason `issue create` refuses a title-only
-        issue before the write.
-      - **The number is in a record after all.** The pre-DID shape carries
-        `issueId` beside an `owner`, neither of them ever in the lexicon.
-        Nothing written since has it, so `issue view 23` refusing still
-        stands — but "in no record" was too strong, and the captured record
-        is `issue_old_record.json`.
-      - **A close/reopen/close chain is three records seven seconds wide.**
-        Captured live; `newest_state` settles it the same way whichever order
-        the records arrive in
-- [ ] Bobbin does not hand back the issue record it indexed, and for one
-      collection it does not hand back a record at all. `listIssuesBy` rewrites
-      a pre-DID record's `repo` at-uri to the repo DID and drops the record's
-      own `repoDid`, under the record's real CID; `feed.listComments` rewrites
-      a legacy comment further — the body lifted into an object, the bare
-      `issue` at-uri turned into a `subject` strongRef whose `cid` is
-      `bafkqaaa`, the CID of no bytes — while leaving `$type` naming the
-      legacy collection. Both are pinned by paired captures of the same
-      records off both services. The pull half is not like this
-      (`the_indexed_copy_of_a_pull_is_the_same_record`), so `cmd::pr::read`'s
-      merge and `cmd::issue::read`'s merge do not rest on the same assumption
-      even though they are the same shape of code
-- [ ] `names_repo` compares `repo` to the repo DID exactly, so a repo-scoped
-      `issue list` reading only the PDS drops every pre-DID issue the account
-      filed against that repo — six of ten rows survive on the captured page,
-      and the index shows nine of the same ten because it rewrote them first.
-      So `--source bobbin` lists issues that the PDS half of the same command
-      cannot, on records the account itself wrote. Matching the at-uri too
-      means resolving it, and it names the repo *record*, whose key is not the
-      repo name and whose authority is the owner, not the repo — one lookup
-      per distinct at-uri, cached, is the shape of the fix. Left recorded
-      rather than done: the rows are wrong by omission today and would be
-      wrong by a network round trip tomorrow, and which is worse is a
-      judgement worth making deliberately
-- [ ] The generated record types do not check their own `$type` on the way
-      in. A legacy comment as the index rewrites it deserializes cleanly into
-      `FeedComment` although its `$type` still says
-      `sh.tangled.repo.issue.comment`, and reserializing it emits `$type`
-      twice — the container's tag and the one `extra_data` carried in. Both
-      are pinned in `a_legacy_issue_comment_is_not_the_record_the_index_hands
-      _back`. Nothing reads records that way today, so this is a note about
-      what a typed read path would not buy: the collection would still have to
-      be checked by hand
-- [x] Integration environments: whole *sequences* of commands driven through
-      the built binary against mock services on loopback ports
-      (`tests/support/`, `tests/stack_flows.rs`, `tests/pr_flows.rs`). What
-      they assert is the request side — what atgc sends, in what order, as
-      whom — which no fixture can observe and no unit test can reach: a round
-      appended to the wrong record, a chain whose `dependentOn` points outside
-      its own batch, a write made with the wrong account's credentials, a
-      read-modify-write that drops the rounds it was not editing. Two
-      properties make it hermetic: `Command::env` is not `unsafe`, so a child
-      may have its own `HOME`, and jacquard returns an unexpired session
-      without contacting anything, so a fixture session restores offline. The
-      argument against mocking a *parser* is unchanged; see docs/testing.md
-- [x] `clients/endpoints.rs` — the five hostnames atgc has compiled in
-      (`ATGC_PLC`, `ATGC_BOBBIN`, `ATGC_APPVIEW`, `ATGC_KNOT`,
-      `ATGC_BSKY_APPVIEW`), each a whole base URL so a local instance on a
-      port works. `ATGC_BOBBIN` was the only one before, and "what does this
-      tool contact" was a question you answered by grepping
-- [ ] The mock PDS serves `listRecords` oldest-first, by ascending record
-      key, where a real one lists newest first. Every paging test therefore
-      arranges its keys backwards from reality, and the one rule that reads
-      the *order* rather than the count — the status walk's floor stop, which
-      takes the last record on a page as the oldest seen — cannot be
-      exercised against it at all. Fixing it means reversing the mock and
-      re-reading every test that plants more than one record in a collection
-- [ ] No spindle is attached, so the integration suite has never run anywhere
-      but a developer's machine. It needs only a Rust toolchain, `git` and
-      loopback — no secrets, no network — so it should go green on the first
-      CI run that happens. Until one does, that is unproven
-- [x] --debug / ATGC_DEBUG=1 — HTTP traffic, token claims, full error bodies
-- [x] prek hooks over file hygiene, `cargo fmt --check` and clippy
-      `-D warnings`, on the pinned toolchain (prek.toml, rust-toolchain.toml)
-- [x] `rust-version = "1.89"` in Cargo.toml. Three things set that floor
-      independently: edition 2024's let-chains want 1.88, `File::try_lock`
-      and `TryLockError` (src/config/lock.rs) stabilised in 1.89, and
-      `smol_str 0.3.6` — the highest `rust-version` in the resolved graph,
-      arriving under jacquard — declares 1.89 itself. Declaring it also
-      turns clippy's `incompatible_msrv` on, so the floor is now enforced
-      on every build rather than asserted once; dropping the key to 1.88
-      reports `try_lock` and nothing else, which is how the number was
-      established. Not rust-toolchain.toml's 1.97.1, which is what it is
-      built with here and moves on its own schedule
-- [x] `Cli::command().debug_assert()` runs in `main.rs`'s
-      `no_duplicate_ids_or_dangling_references_anywhere_in_the_command_tree`.
-      It walks every subcommand, which is what nothing else in the suite
-      does — parsing a line only builds the subcommands on that line, so a
-      malformed definition three commands away stays invisible until
-      somebody types it. Found nothing on the tree as it stands, which is
-      worth saying only because the check was proven to bite first: giving
-      `--no-input` a `-q` short makes it fail with "Short option names must
-      be unique for each argument, but '-q' is in use by both 'quiet' and
-      'no_input'". The `--account` collision that
-      `auth_switch_and_logout_do_not_collide_with_the_global_account_flag`
-      documents is the same class, and was found by hand months late
-- [x] `record::put` — one `putRecord` with a `swapRecord` precondition, for
-      every command that edits a record rather than creating one. `pr
-      resubmit` and `pr edit` had the only copy, inside `pr/write.rs`, and
-      `repo edit` needed the same thing; jacquard's own helper hardcodes
-      `swapRecord: None` and cannot express the precondition at all
-- [x] One module per remote, instead of a URL built wherever a command needed
-      one: `crate::pds` owns the `{pds}/xrpc/…` reads — getRecord,
-      listRecords and its paging, blob downloads — and `crate::bobbin` owns
-      the appview's base URL and its three endpoints. `pr`, `repo`, `review`
-      and `ssh` all went through their own copy before, so a paging or
-      error-shape fix landed in one caller and not the rest.
-      docs/architecture.md says which commands reach which
-- [x] `pr` split into a directory at 3,141 lines: `pr/read.rs` for `list`,
-      `status` and `view`, `pr/write.rs` for `create`, `resubmit`, `edit`,
-      `close`, `reopen` and `comment`. A move rather than a refactor — the two
-      halves share one function between them, and `pr/mod.rs` re-exports the
-      same names at the same paths, so `main.rs` did not change
-- [x] Six folders under `src/` instead of thirty siblings: `cmd/` for the
-      command families, `clients/` for everything that talks to a counterpart
-      (git included), `lexicon/` for the pure record and identifier
-      conventions, and `config/`, `logging/` and `term/` for the rest.
-      docs/module-layout.md is the contract, and it lands first so the moves
-      can be read against it. Landing as a stack, one folder per PR, with the
-      splits that fall out of it: `auth.rs` four ways, `review.rs`'s appview
-      scrape out to `clients/tangled/web/`, every git invocation under
-      `clients/git/`, `repo.rs` into read/write/branch/checkout, and clap
-      inlined into `cmd/` so the 31 mirror arg structs go away. Landed as a
-      fifteen-pull stack. Two pieces are deliberately left: `auth.rs` still
-      holds both the OAuth client and the `auth` verbs, and wants splitting
-      into `clients/atproto/oauth/{client,session}.rs` and `cmd/auth.rs`; and
-      `write_git_identity` stayed in `cmd/repo/checkout.rs` as policy over
-      the `clients/git/config.rs` primitives rather than moving wholesale
-- [x] Conventional Commits enforced at commit time by `committed` as a
-      commit-msg hook, and the version in Cargo.toml computed from them —
-      `git cliff --bumped-version` into `cargo release` (cliff.toml,
-      committed.toml, release.toml). Bumped on main as a maintainer act, not
-      inside a PR: several branches are open at once and every one of them
-      would otherwise edit the same `version =` line. Breaking changes bump
-      the minor while pre-1.0, overriding git-cliff's jump to 1.0.0. No longer
-      unexercised: v0.2.0, v0.2.1 and v0.3.0 were all cut this way, through
-      scripts/release.sh
-- [x] The bump also writes which release is current into the README, on an
-      italic line under the lockup linking the tag on Tangled. A
-      `pre-release-replacements` rule in release.toml rewrites the block
-      between two HTML comment markers, so it lands in the `chore(release):`
-      commit rather than trailing it; `tests/release_metadata.rs` fails if
-      that line and Cargo.toml ever disagree. The markers, rather than a
-      match on the prose, because the line is meant to grow — see the
-      release-artifacts entry above, which is what it should point at once
-      there is a built binary to point at
-- [x] Push the `v0.1.0` epoch tag. Annotated, on 6d16818 — the last commit
-      before Conventional Commits start — so the first bump counts every
-      conforming commit. It is on origin, and the bumps since have counted
-      from it
-- [x] Documentation: narrative pages in docs/ (architecture, pull requests,
-      accounts, sessions), compiled into rustdoc through src/docs.rs so prose
-      and generated reference are one build — `cargo docs`, aliased in
-      .cargo/config.toml, with rustdocflags = -D warnings making a
-      prose-to-code link that no longer resolves a build failure. mdBook was
-      rejected: a second tool outside the rust-toolchain.toml pin, and it
-      cannot check the links that rot
-- [x] One `target/` per worktree, cargo's default, and no scheme on top of it.
-      Sharing the build across worktrees was tried twice and is settled as a
-      no: the uplifted `target/debug/atgc` is one name written by whichever
-      worktree last compiled, and it is what `env!("CARGO_BIN_EXE_atgc")`
-      bakes into the integration tests — once a green test run against
-      another branch's binary
-- [ ] Publish the built docs somewhere. target/doc is a self-contained static
-      tree with no external requests, and Tangled hosts static sites, but
-      building it on a push needs a spindle this repo does not have. Where it
-      goes is also unsettled: a real domain (atgc.gg or similar) is on the
-      table, not just a Tangled static site
-- [x] Dependencies build without debug info, this crate keeps all of it —
-      `[profile.dev.package."*"] debug = false`, whose `"*"` reaches every
-      dependency and no workspace member. Measured on a full debug build:
-      2.43 GiB to 1.66 GiB, target/debug/deps 1.93 to 1.31, the binary 228
-      to 102 MB and its .debug_info 70 MiB to 27, and the build itself a
-      little quicker. Cheaper in debuggability than it looks, because a
-      dependency's generic code is monomorphized into atgc's codegen units
-      and keeps atgc's settings: 253 dependency source files still have line
-      tables in the result. Only separately compiled non-generic dependency
-      code loses file and line, and symbol names survive regardless
-- [ ] Narrow the `blob:*/*` scope. Patch blobs are application/gzip and body
-      images are image/*; the scope grants every MIME type, which is still
-      wider than the two families atgc actually uploads
-- [ ] Add held-back scopes when their commands ship: secrets,
-      collaborator/membership changes. publicKey shipped with `key`, and
-      cost the second re-login — holding it back was the wrong call, since
-      registering a key is what makes a *newly added account* able to do
-      anything at all. deleteBranch shipped with `repo delete-branch` and
-      cost the third, repo.delete with `repo delete` and the fourth; unlike
-      publicKey, holding those two back was fine — both are destructive and
-      nothing else in atgc needed them sooner
-- [ ] Two RustSec advisories, both under jacquard and neither fixable here.
-      `hickory-proto 0.24.4` (RUSTSEC-2026-0119, O(n²) name compression, CPU
-      exhaustion) needs jacquard-identity to move off hickory-resolver 0.24 —
-      a semver-incompatible bump `cargo update` cannot make. `rsa 0.9.10`
-      (RUSTSEC-2023-0071, Marvin timing sidechannel) has no fixed version at
-      all; it arrives through jose-jwk, for a key type atgc's ES256 DPoP keys
-      never exercise. Both still stood on 2026-08-15, confirmed independently
-      by `cargo audit` and cargo-deny 0.20.2 against the same lockfile, and
-      both are now in deny.toml's `advisories.ignore` with the removal
-      condition for each — visible rather than silenced, and cargo-deny
-      reports `advisory-not-detected` once an ignored id stops matching, so
-      neither can outlive its advisory unnoticed. `cargo audit` adds a third
-      that cargo-deny does not: `atomic-polyfill 1.0.3` (RUSTSEC-2023-0089,
-      unmaintained), four levels down through heapless <- postcard <-
-      jacquard-common. That difference is deny.toml's `unmaintained =
-      "workspace"` working as intended, not a disagreement about facts
-- [ ] RUSTSEC-2026-0119 rechecked on 2026-08-17, and the answer is still no.
-      The blocker is one line upstream: jacquard-identity 0.12.1 declares
-      `hickory-resolver = "^0.24"`, and 0.12.1 is the newest release of every
-      jacquard crate — the crates.io index lists nothing above it for
-      jacquard, -identity, -oauth or -common, and the repo's last commits are
-      a month old and unrelated to DNS. 0.24.4 is the end of the 0.24 line,
-      there is no backport, and the advisory's `patched` list is exactly
-      `>= 0.26.1`. So `cargo update -p hickory-proto` locks 0 packages, and
-      `--precise 0.26.1` (or 0.25.2, on either hickory crate) fails outright
-      quoting the `^0.24` requirement. The part worth writing down is that
-      `[patch.crates-io]` aimed at hickory-resolver 0.26.1 is not an error
-      but a *silent no-op*: cargo printed no warning at all and
-      `cargo tree -i hickory-proto` still showed 0.24.4. Anyone who tries
-      that and does not re-run `cargo audit` will believe they fixed it.
-      Fixable when jacquard-identity requires hickory-resolver >= 0.26.1 and
-      jacquard depends on that release. No upstream issue tracks it — ten are
-      open and none mention hickory, DNS or RustSec — so the only thing to
-      watch is the version, and filing one upstream would be worth more than
-      another recheck here
-- [ ] Two real routes to RUSTSEC-2026-0119, both rejected on 2026-08-17 and
-      recorded so they are not rediscovered as bright ideas. Vendoring a
-      forked hickory-proto still numbered 0.24.4 would satisfy cargo, and the
-      upstream fix is genuinely small — `COMPRESSION_CANDIDATE_LIMIT = 64` in
-      `serialize/binary/encoder.rs`, `COMPRESSED_NAME_LIMIT = 120` in
-      `rr/domain/name.rs` — but it lands in an API rewritten between 0.24 and
-      0.26 (no_std `alloc`, `ProtoErrorKind` -> `ProtoError`, `EncodeMode` ->
-      `NameEncoding`), so it means hand-porting into a 46k-line DNS
-      wire-format crate this repo would then own, and a path-sourced fork is
-      invisible to `cargo audit` — silencing the tool rather than fixing the
-      code. The other route is dropping jacquard's `dns` feature, which drops
-      hickory outright; jacquard-identity then resolves `_atproto.`
-      TXT over DNS-over-HTTPS against a hardcoded `cloudflare-dns.com`. That
-      trades a DoS advisory for sending every handle lookup to one third
-      party and ignoring the system resolver, which is the wrong trade for an
-      ATProto CLI. Weighing against both: hickory-resolver 0.24.4 has no
-      non-test site that re-encodes a received message — `DnsResponse` keeps
-      the wire buffer it was handed — so the only `BinEncoder` run on atgc's
-      path is the single-question query it builds itself, which is not the
-      many-records encode the advisory needs. That lowers the practical
-      exposure; it does not clear the advisory, so the ignore stays
-- [x] `cargo deny check` runs, and passes. cargo-deny 0.20.2 (prebuilt Linux
-      binary in ~/.local/bin, not a source build) parsed deny.toml on
-      2026-08-15 without complaint, so the version guesses in its header held.
-      Three findings, all now answered in the file: the two advisories above;
-      `tangled-lexicon` and `userinput-lexicon` counted as unlicensed
-      third-party code, since both are generated here and carry no `license`
-      key (`[licenses.private] ignore = true`, cargo-deny's own answer for an
-      unpublished workspace member); and the wildcard finding below. The
-      license allow list needed no change at all — no missing entry, no
-      unused one. Ten `multiple-versions` warnings, as expected
-- [ ] atgc cannot be published to crates.io as it stands, and cargo-deny is
-      what said so: `tangled-lexicon` and `userinput-lexicon` are path
-      dependencies with no version requirement, which is a wildcard, and
-      `allow-wildcard-paths` is only consulted for a crate that is itself
-      `publish = false`. atgc is not, so `bans` failed. The finding is real —
-      crates.io disallows path dependencies, and both crates are
-      `publish = false`, so `cargo publish` would refuse for the same reason
-      one step later. Left as a finding rather than fixed: the choices are to
-      publish the two generated crates, to inline them, or to say atgc is not
-      publishable, and none of those belongs in a hygiene pass. `wildcards`
-      is `"warn"` meanwhile, so it is printed on every run instead of failing
-      every run; restore `"deny"` with the fix
-- [ ] Drop the vendored jacquard-oauth once the rpc `?aud=*` serialization
-      fix lands upstream; report the bug (Cargo.toml [patch] has details)
-- [x] jacquard-lexicon codegen from vendored sh.tangled lexicons, like Bobbin
-      does, instead of hand-written models — proven first on the
-      userinput.app board's lexicon (`.claude/report`'s branch), then
-      applied to all of `src/lexicon/tangled.rs` as a six-PR stack, lowest-risk
-      first. `lexicon/tangled.rs` now holds only NSID constants and the domain glue
-      no generated type could carry (`PullState`, `pull_target`) — every
-      record shape comes from `vendor/tangled-lexicon/`. Landed as
-      `claude/tangled-lexicon`, real divergences found and fixed along the
-      way (nested `$type` on a union-collapsed object, `target.repoDid`
-      needing `extra_data`), not just a mechanical type swap:
-  - [x] Vendor the seven lexicons in use (`sh.tangled.repo.pull`, `.pull
-        .status`, `.repo`, `.publicKey`, `.feed.comment`, `.markup.markdown`,
-        `com.atproto.repo.strongRef`) under `lexicons/`, pinned to the
-        commit they were fetched at (`lexicons.json`) — Tangled's lexicons
-        are not published as `com.atproto.lexicon.schema` records the way
-        userinput.app's are, so provenance is a git commit rather than an
-        at://-uri+CID. `vendor/tangled-lexicon/` carries the generated Rust,
-        `scripts/vendor-tangled-lexicons.sh` refreshes both
-  - [x] `PublicKey` (`src/key.rs`) — the real lexicon marks `name` required,
-        matching the hand-written struct exactly (no divergence to carry);
-        `key`'s 4096-char limit is now enforced by generated `validate()`,
-        which the hand-written version never checked at all
-  - [x] `FeedComment`/`Markdown` (`src/pr/write.rs`'s `comment()`) — one real
-        divergence found and fixed: every live comment record's nested
-        `body` carries `$type`, confirmed against this account's own
-        records, but jacquard-lexicon's codegen only tags `$type` on
-        top-level records, not on an object collapsed out of a
-        single-variant ref-union. `markdown_plain`/`markdown_with_blobs`
-        inject it by hand through `extra_data` rather than leaving it off —
-        would have been a silent wire-format regression otherwise.
-        `StrongRef` here uses the real, generated
-        `com.atproto.repo.strongRef` (required `cid`), unlike userinput.app's
-        board records: `subject.cid` is always populated from the pull's own
-        CID, so there's no absent-cid case to preserve
-  - [x] `Repo` (`src/repo.rs`) — first one with a real read-modify-write
-        (`repo edit`); the generated `extra_data` catch-all survives
-        `record::put`'s `swapRecord` (same three fixture tests as before,
-        ported and passing against the generated type). `website`/`source`
-        generate as `UriValue`, not a bare string, so `repo edit`'s existing
-        text-diffing helpers now stage through local `Option` and
-        convert at the boundary rather than being rewritten themselves.
-        `description`'s 140-grapheme cap and 1-grapheme floor (an *empty*
-        description is invalid, not merely absent) and `topics`' 50-item cap
-        are now enforced by generated `validate()` before every write —
-        neither was checked at all before this migration. `validate()`
-        originally ran before the `--dry-run` check, so a description over
-        the cap failed a dry run that every other write command promises
-        never fails; moved to run after, matching `repo create`/`pr create`
-  - [x] `Pull`/`Round`/`Source`/`Target` (`src/pr/write.rs`,
-        `src/stack/write.rs`) — the largest of the six: four
-        read-modify-write sites across two precondition mechanisms
-        (`record::put`'s per-record `swapRecord` in `pr resubmit`/`pr edit`,
-        `record::batch`'s one whole-repo `swap_commit` in `stack resubmit`),
-        both proven against the real fixtures the hand-written catch-all
-        was pinned against. `review.rs` stays on its own untyped reader on
-        purpose — confirmed live: a lexicon-faithful `Pull` refuses the same
-        pre-`rounds` fixture the hand-written one did, same as predicted.
-        `mentions`/`references` are now named, typed fields
-        (`Vec>`/`Vec>`) instead of living only in a
-        catch-all. `target.repoDid`: confirmed the current lexicon really
-        has dropped it (`target` now has only `repo`/`branch`), so
-        `crate::lexicon::tangled::pull_target` writes it through `extra_data` instead
-        — behavior preserved, just relocated. `Blob`/`BlobRef` conversion at
-        every `pull.blobs`/`images::merge_blobs` boundary, same pattern as
-        `Markdown.blobs` in the FeedComment migration. `pr resubmit` and
-        `stack resubmit`'s relink-only case (`Slot::Keep { relink: true }`)
-        pushed their `Op::Update` with no `.validate()` call at all — the
-        other two mutate-and-write paths (`pr edit`, `stack resubmit`'s
-        `Slot::Update`) always had one; both now do too. Debug-log dumps of
-        a read-then-mutated record (every `swapRecord`/`Op::Update` site)
-        could show `$type` twice — the generated container tag re-emits it
-        unconditionally and `extra_data`'s flatten doesn't strip a `$type` a
-        live read carried in — harmless on the wire (both writes agree, and
-        `record::put`/`create_record` collapse it through a real map either
-        way) but read as corrupted JSON in `--debug` output; `crate::debug::
-        pretty` now routes every such dump through `serde_json::Value` first
-        to collapse it before printing
-  - [x] `PullStatus`/`PullState` (`src/pr/write.rs`'s `set_state`,
-        `src/stack/write.rs`'s `run_merge`) — `pr/read.rs`'s `latest_states`
-        and `list_statuses` never touched the hand-written struct in the
-        first place (both read status records as untyped JSON already), so
-        this was the smallest of the six once found. `PullState` itself
-        stays hand-written on purpose — `token()`/`label()` are atgc's own
-        convenience, not something a lexicon generates — but now converts
-        through the generated `StatusStatus` enum's `Other(S)` catch-all
-        rather than a bare `String`; a new test pins that an unrecognized
-        token still deserializes (into `Other`, never a parse error) and
-        that `PullState::from_token` then names no state for it, which is
-        the exact contract `state_of` and `latest_states` depend on to skip
-        a variant this build doesn't know rather than crash on it
-- [ ] Releases with prebuilt binaries. The version and the tag are handled
-      now; what is missing is everything downstream of the tag — building
-      artifacts and attaching them, which needs a spindle
-- [ ] The 1.0.0 arc: CI (the spindle entry under pipelines), publishing to
-      crates.io, and the release binaries above are deliberately deferred and
-      meant to land together after 1.0.0 ships. Each one needs credentials and
-      a signup, each is hard to get right, and doing them piecemeal leaves
-      three half-configured pipelines
diff --git a/deny.toml b/deny.toml
index f3ae370..f02e1d7 100644
--- a/deny.toml
+++ b/deny.toml
@@ -12,7 +12,7 @@
 # removing each; the two generated in-tree lexicon crates counted as unlicensed
 # third-party code, answered by [licenses.private]; and atgc's two path
 # dependencies read as wildcards, which is a real publishability finding, so it
-# is recorded in TODO.md and left visible here rather than silenced. Nothing in
+# is recorded in plan/dependencies.md and left visible here rather than silenced. Nothing in
 # the license allow list needed changing — no unused entry, no missing one.
 # `cargo audit` was run against the same lockfile and agrees; the one place the
 # two disagree is noted at `unmaintained` below.
@@ -39,7 +39,7 @@ yanked = "warn"
 unmaintained = "workspace"
 unsound = "workspace"
 # Both of these arrive under jacquard and neither is fixable in this tree; the
-# "Two RustSec advisories" entry in TODO.md has the reasoning at length. They
+# "Two RustSec advisories" entry in plan/dependencies.md has the reasoning at length. They
 # are ignored so the rest of the check can be a gate, not because they stopped
 # counting — and an ignore here cannot quietly outlive its advisory, because
 # cargo-deny reports `advisory-not-detected` against an id that no longer
@@ -53,7 +53,7 @@ ignore = [
     # declares `hickory-resolver = "^0.24"`. Remove when jacquard-identity
     # requires hickory-resolver >= 0.26.1 and jacquard depends on that
     # release — not before, since a `[patch.crates-io]` across that boundary
-    # is silently ignored rather than rejected. TODO.md has the evidence.
+    # is silently ignored rather than rejected. plan/dependencies.md has it.
     { id = "RUSTSEC-2026-0119", reason = "hickory-proto 0.24.4 under jacquard-identity; no fix below a semver-major resolver bump" },
     # rsa 0.9.10, under jose-jwk. No fixed version exists at all, and the key
     # type is one atgc's ES256 DPoP keys never exercise. Remove when rsa ships
@@ -100,8 +100,8 @@ multiple-versions = "warn"
 # dependencies as wildcards and fails the check — correctly, in the sense that
 # atgc cannot be published to crates.io while two of its dependencies are
 # `publish = false`. That is a real finding about publishing, not a lax
-# version bound, and it is written down in TODO.md rather than fixed by
-# editing the manifest to look tidier. "warn" keeps it printed on every run
+# version bound, and it is written down in plan/dependencies.md rather than
+# fixed by editing the manifest to look tidier. "warn" keeps it printed on every run
 # while letting the rest of `bans` be a gate; restore "deny" once the lexicon
 # crates are published or atgc stops claiming that it can be.
 wildcards = "warn"
diff --git a/docs/architecture.md b/docs/architecture.md
index 3af0437..61dd3a9 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -131,7 +131,7 @@ gets a longer session, only a `web` client may be confidential, and only a
 native client may use a loopback redirect — so a confidential atgc would need
 a hosted HTTPS callback standing between a login and the PDS. That is a
 runtime dependency on somebody's server for the one command that has none, and
-it is refused. See TODO.md.
+it is refused. See `plan/sign-in.md`.
 
 The `client_id` a grant is issued to includes the login's ephemeral callback
 port, so it cannot be reconstructed later. It is recorded at login, and a
diff --git a/plan/README.md b/plan/README.md
new file mode 100644
index 0000000..2c8c52d
--- /dev/null
+++ b/plan/README.md
@@ -0,0 +1,219 @@
+# Plan
+
+What atgc is building, and roughly in what order. One file per epic.
+[milestones.md](milestones.md) is the other view of the same work: releases
+worth naming, in prose, without reference to any of this.
+
+An **epic** is a line of work that takes many pull requests and has an exit
+criterion somebody could check. It is not a task list — the tasks live inside
+it, and in the code and its doc comments, which are where the reasoning behind
+a decision belongs.
+
+## Two facts shape all of it
+
+**Everything is somebody's record.** A pull request, an issue, an SSH key and a
+repo's description are records in the PDS of whoever wrote them, not rows in a
+forge's database. Opening a pull needs no permission on the target repo; a
+pull's state is a separate newest-wins log rather than a field; pushing a
+branch never updates anything. `docs/architecture.md` is the long version, and
+nearly every surprise in this register follows from it.
+
+**No index is trusted by default.** The one thing a PDS cannot answer is
+"everything anybody wrote about this repo", and the index that can — Bobbin —
+stalls for hours at a time and answers about a stale world in the shape of a
+fresh answer. So every index is opted into, every listing says what it could
+not see, and [indexes](indexes.md) is an epic rather than a footnote.
+
+## The id is the commit scope
+
+Each epic's filename is its id, and that id never changes. `plan/complete/agent.md`
+is `agent`, so its commits read `feat(agent): …` and `fix(agent): …`. That is
+the whole reason the ids have no number prefix, and the reason there is no
+order field either: a filename or a field that has to be rewritten when
+something else moves is not an identifier. Archiving moves the file between
+directories and never renames it, for the same reason.
+
+`plan` is a valid scope too, for changes to this register.
+
+Not every scope in `git log` is an epic id, and that is not enforced by
+anything today. The ids were chosen to match the scopes already in use where
+one existed — `stack` reads as [stacks](stacks.md) and `pr` spans four epics —
+so adopting the rule is a decision about the commit hook, not a rewrite of this
+directory.
+
+## Order is advisory, and unnumbered
+
+The Open table below reads roughly top to bottom, and that is the whole of the
+ordering. There is no `order` field and no numbered column, because a total
+order over thirty epics is a lie that costs something to maintain: inserting
+one at the front would renumber every file beneath it and every row here, so
+two branches each adding an epic conflict by construction, over a number
+neither of them cares about.
+
+What actually constrains the work is `dependsOn`, which says what genuinely
+cannot start first, and `status`, which says what is blocked. Those are per
+epic, they are true independently of what anybody else is doing, and moving an
+epic is moving one line.
+
+The sequence the tables read in comes from [order.txt](order.txt), which is a
+list of ids and nothing else. It is advisory and it is optional: an epic it
+does not name still appears, alphabetically, at the end of whichever table it
+belongs to.
+
+## Status
+
+`shipped` — the exit criterion is met. The file stays, because how something
+was built and what it cost to learn is worth more after it works than before.
+A shipped epic with nothing open left moves to [complete/](complete/); one with
+loose ends stays here until they close.
+
+`open` — being worked on or ready to be.
+
+`blocked` — cannot start until something in `dependsOn` lands.
+
+`continuous` — no exit criterion and no completion date. Worked whenever
+adjacent code is open. Output contracts and documentation are like this: they
+are never done, and pretending otherwise just produces an epic that is
+permanently 80% complete.
+
+`declined` — decided against. Not `blocked`, which is waiting: the answer is no
+rather than not yet. The file stays, because a decision that is not written
+down gets made again.
+
+## The tables are generated
+
+The five tables below are output, not source. `scripts/gen-plan-readme.py`
+reads every epic's frontmatter and rewrites what sits between the
+`` fences. Everything around them is hand-written and is
+passed through untouched, so prose can go anywhere except inside a fence. The
+`plan-register` prek hook runs the script with `--check`, which means a row
+that disagrees with the file it points at fails the commit, and `plan-shape`
+checks the epics themselves.
+
+Adding an epic is therefore one file and one command: write `plan/.md` with
+the frontmatter keys every other epic carries, run `scripts/gen-plan-readme.py`,
+and stage both. Nothing here is typed by hand — that is what stops two branches
+each adding an epic from conflicting over rows neither of them cared about.
+
+## Complete
+
+Nothing open, nothing left to decide. These live in [complete/](complete/) so
+the list below stays the list of things somebody might work on. They are not
+deleted, and their ids stay valid commit scopes, because a follow-up fix to a
+finished epic is still that epic's commit.
+
+
+| id | title |
+|---|---|
+| [agent](complete/agent.md) | An agent arriving cold uses atgc correctly on the first try |
+
+
+## Shipped, with loose ends
+
+The exit criterion is met and something is still open in the file.
+
+
+| id | title |
+|---|---|
+| [sign-in](sign-in.md) | A person acts as their own ATProto account, from a terminal |
+| [credential-store](credential-store.md) | The stored credentials survive a crash, and are nobody else's to read |
+| [identity](identity.md) | One answer to what an identifier is, and who it names |
+| [keys](keys.md) | A push from this machine is authorized before it is attempted |
+| [api](api.md) | Everything the lexicon has, reachable without a verb for it |
+| [http-bounds](http-bounds.md) | No stranger's response costs atgc unbounded time or memory |
+| [module-layout](module-layout.md) | Where code goes is a compile error, not a convention |
+| [toolchain](toolchain.md) | The build is cheap enough to run two of |
+| [brand](brand.md) | atgc looks like one thing wherever it is drawn |
+
+
+## Open
+
+
+| id | title | status |
+|---|---|---|
+| [pull-requests](pull-requests.md) | A pull request's whole life runs from the terminal | open |
+| [branch-pulls](branch-pulls.md) | A pull publishes the branch it claims | open |
+| [pull-numbers](pull-numbers.md) | A pull's number comes from an API, not from a page | open |
+| [review](review.md) | Somebody else's pull can be read, compared and run here | open |
+| [stacks](stacks.md) | A change that is several pull requests stays one chain | open |
+| [issues](issues.md) | An issue is filed, found and closed from here | open |
+| [repos](repos.md) | A repo is created, configured and taken apart from here | open |
+| [search](search.md) | The index's full-text search is a command | open |
+| [indexes](indexes.md) | An answer says which index it came from, and how far behind that index is | open |
+| [doctor](doctor.md) | Whether the next command can work is one question | open |
+| [logs](logs.md) | What atgc did to somebody else's service is on disk afterwards | open |
+| [report](report.md) | Feedback reaches a public board without leaving the terminal | open |
+| [pipelines](pipelines.md) | CI runs, and its runs are readable from here | open |
+| [secrets](secrets.md) | A CI secret is set from the terminal, not from a web form | blocked |
+| [artifacts](artifacts.md) | A tag carries binaries somebody can download | blocked |
+| [publishing](publishing.md) | atgc is installable by somebody who did not build it | open |
+| [configuration](configuration.md) | Pointing atgc somewhere else is a setting, not a patch | open |
+| [lexicons](lexicons.md) | Record shapes come from the schema, not from reading somebody's JSON | open |
+| [testing](testing.md) | A defect in a sequence of commands is caught here, not in production | open |
+| [dependencies](dependencies.md) | What atgc ships is known, licensed, and not advised against | open |
+
+
+The forge verbs read first because they are what somebody came here to run.
+The three that follow them — indexes, doctor, logs — sit in the middle rather
+than at the end because they are what makes a wrong answer from any of the
+others visible, and every one of them exists because an answer was wrong first.
+CI and everything downstream of a tag is one block near the bottom on purpose:
+it is the 1.0.0 arc, and it is deferred together rather than piecemeal. The
+tail is the tree's own hygiene, none of which is a feature and all of which is
+load-bearing for the next one.
+
+## Continuous
+
+
+| id | title |
+|---|---|
+| [output](output.md) | Every command's output obeys the same three rules |
+| [docs](docs.md) | Only what cannot be discovered by running the tool or reading the code |
+
+
+## Not pursued
+
+Answered no. A file here is a list of answers rather than a line of work.
+
+
+| id | title |
+|---|---|
+| [declined](declined.md) | What atgc is not building |
+
+
+## Where the schemas are
+
+There is no lexicon epic in the sense of a schema track running ahead of the
+features. [lexicons](lexicons.md) is about *vendoring* somebody else's:
+`sh.tangled.*` is Tangled's and `app.userinput.*` is userinput.app's, atgc
+authors none of them, and what that epic owns is keeping the generated types
+pinned to a fetched commit and honest about the records in the wild, which
+predate the schemas.
+
+## Every epic ends in Done
+
+Open work above, finished work as `- [x]` items under a trailing `## Done`.
+An epic with nothing under Done says so.
+
+That is what makes "is this finished" answerable by looking rather than by
+reading: an epic with no `- [ ]` left is a candidate for [complete/](complete/).
+
+## Where TODO.md went
+
+This directory replaced it. TODO.md had become 3,400 lines of everything
+anybody had noticed, in fifteen sections that mapped to command families rather
+than to lines of work, with a decision record, a bug report, a rejected
+proposal and a one-line wish all wearing the same checkbox. Every entry in it
+now lives in the epic that covers it, verbatim where the wording was already
+right — which is nearly everywhere, because the entries were the good part.
+
+Two entries did not survive the move, both because the work they describe had
+landed under a different entry: `stack create`'s `source` question, answered by
+pushing the branch, and the three body readers, answered by `term/body.rs`.
+Both supersessions are recorded in the prose of the epic that took the rest
+([branch-pulls](branch-pulls.md), [module-layout](module-layout.md)), because
+an entry that quietly disappears is indistinguishable from one that was lost.
+
+There is deliberately no general TODO file any more. Something worth writing
+down belongs in an epic — and if it belongs to no epic, that is the useful
+signal, not an excuse to start another list.
diff --git a/plan/api.md b/plan/api.md
new file mode 100644
index 0000000..6162fbf
--- /dev/null
+++ b/plan/api.md
@@ -0,0 +1,104 @@
+---
+id: api
+title: Everything the lexicon has, reachable without a verb for it
+status: shipped
+repos: [atgc]
+dependsOn: [sign-in]
+exitCriterion: >
+  Any XRPC method a PDS or a Tangled service serves can be called with the
+  right credential, without atgc growing a verb and without a new scope.
+---
+
+# api
+
+Tangled's lexicon is far larger than the part atgc has verbs for — labels,
+stars, follows, collaborators, notifications — and none of it was reachable at
+all. `atgc api ` is the hatch.
+
+The design question was which credential, and the answer is that the host
+decides: `--host pds|knot:|appview|bobbin`. "An XRPC call" is three
+unrelated acts here — a PDS call under the session's DPoP-bound access token, a
+knot procedure under a service-auth JWT minted for that one method, a public
+read under nothing — so a flag naming the credential would ask the user to
+already know the thing they came here to find out, and a wrong guess would hand
+a third-party knot a token for the PDS.
+
+It adds no scope, and could not: a scope added to `SCOPES` is a re-login for
+every account, and an escape hatch is the last thing that should cost one.
+
+## What it needs
+
+- [ ] `atgc api` cannot address a service by URL, only by the four names
+      `--host` takes, so reading *another* account's PDS directly — the shape
+      that answers "does their record really say that" — is not reachable and
+      `--host pds` always means the acting account's own. The names are what
+      make the credential derivable; a URL would have to be sent
+      unauthenticated, or be asked which credential it takes, and both are
+      worse than not having it. `ATGC_BOBBIN`, `ATGC_APPVIEW` and `ATGC_KNOT`
+      still move the three that are not the PDS
+- [ ] `atgc api --host knot:` needs the knot's hostname typed out. Deriving
+      it from the checkout means turning a repo DID back into an owner and a
+      name, which only the appview's index can do — the same gap `doctor`'s
+      knot row has — and guessing it off the `origin` remote is wrong for
+      every repo cloned through tangled.org, which proxies git for its knots:
+      the guess would mint a service-auth token for `did:web:tangled.org` and
+      send it to something that is not the knot. Refusing to guess is the
+      right failure, but it does mean `atgc repo view` first
+- [ ] `atgc api` does not write the DPoP nonce back to the session store.
+      `DpopCall` retries once on a nonce challenge by itself, so the cost is
+      one extra round trip on the first `api` call of a process; carrying it
+      back would mean this command rewriting jacquard's own session record
+      behind jacquard's back, which is a worse trade for one round trip
+- [ ] `atgc api` sends JSON bodies only, so `com.atproto.repo.uploadBlob` —
+      the one PDS procedure whose body is bytes — is not reachable through
+      it, and neither is `gh api`'s `-F key=@file`. `--input` is the
+      file-shaped answer here and two spellings of it would be a choice with
+      no right answer, but a blob upload genuinely has no spelling yet
+
+## Done
+
+- [x] `api` — an authenticated raw XRPC escape hatch, `atgc api `.
+      Tangled's lexicon is far larger than the part atgc has verbs for —
+      issues, labels, stars, follows, collaborators, secrets, artifacts,
+      pipelines, notifications — and none of it was reachable at all. The
+      design question was which credential, and the answer is that the host
+      decides: `--host pds|knot:|appview|bobbin`. "An XRPC call" is
+      three unrelated acts here — a PDS call under the session's DPoP-bound
+      access token, a knot procedure under a service-auth JWT minted for that
+      one method, a public read under nothing — so a flag naming the
+      *credential* would ask the user to already know the thing they came
+      here to find out, and a wrong guess would hand a third-party knot a
+      token for the PDS. A knot's queries and its procedures therefore differ
+      under one `--host`, which is not a special case: its queries are public
+      and its procedures are not
+- [x] `api`'s parameters are `gh api`'s, spelling and meaning both, because
+      that is the muscle memory people arrive with: `-F` guesses a type, `-f`
+      never does, and both go in the query string of a query and the JSON
+      body of a procedure. XRPC declares query-or-procedure and a method name
+      does not, so `--input` means a procedure, `-X` settles it, and a wrong
+      guess comes back as the 404/405 it really is with a line naming the
+      other verb. No `--json`: the output is the service's own answer, so the
+      flag would be a switch with nothing behind it. A refusal prints the
+      service's `error` and `message` and exits as that kind of refusal —
+      the XRPC error name decides over the HTTP status, since a PDS answers
+      `RecordNotFound` with a 400
+- [x] `api` adds no OAuth scope, and could not: a scope added to `SCOPES` is
+      a re-login for every account, and an escape hatch is the last thing
+      that should cost one. What it reaches is what the grant already covers,
+      and a `createRecord`/`putRecord`/`deleteRecord`/`applyWrites` whose
+      collection the grant does not name fails with `scope.rs`'s own refusal
+      before the request, naming the scope and the login that would grant it.
+      A knot procedure goes through `require_rpc` the same way. Writes made
+      this way land in `pds.jsonl` like every other write, because the
+      DPoP arm sends through `logging::pds::LoggedPdsClient` — a hatch able
+      to create a record outside that log would be the one hole in it
+- [x] `atgc api` reads no more of an answer than anything else does:
+      `http::MAX_BODY`, with no ceiling of its own. A raw hatch is the most
+      plausible place to want a bigger one and still the wrong place to grant
+      it — nothing anybody reads at a terminal is within an order of
+      magnitude of eight mebibytes, the one case that could genuinely exceed
+      it is a patch blob, which `pr diff --max-bytes` already reads properly,
+      and a per-command ceiling would be the single exception to "atgc holds
+      at most this much of a stranger's response" on the command most likely
+      to be pointed at a stranger. The public arm stops mid-stream through
+      `bytes_bounded`; the authenticated arm is the entry below
diff --git a/plan/artifacts.md b/plan/artifacts.md
new file mode 100644
index 0000000..23d3e17
--- /dev/null
+++ b/plan/artifacts.md
@@ -0,0 +1,35 @@
+---
+id: artifacts
+title: A tag carries binaries somebody can download
+status: blocked
+repos: [atgc]
+dependsOn: [pipelines, publishing]
+exitCriterion: >
+  A tagged release has built binaries attached, listed and downloadable by
+  name.
+---
+
+# artifacts
+
+The version and the tag are handled ([publishing](publishing.md)); what is
+missing is everything downstream of the tag. Building artifacts and attaching
+them needs somewhere to build them, which is [pipelines](pipelines.md).
+
+The README already carries a current-release line, rewritten on every bump,
+which says only the tag today because a tag is all there is to say.
+
+## What it needs
+
+- [ ] `release create` / `list` / `view` — sh.tangled.repo.artifact
+- [ ] `release upload` / `download` / `delete`
+- [ ] Point the README's current-release line at the binary once there is
+      one. The line already exists and is rewritten on every bump; it says
+      only the tag today because a tag is all there is to say. Widening it
+      is a one-line edit to `pre-release-replacements` in release.toml
+- [ ] Releases with prebuilt binaries. The version and the tag are handled
+      now; what is missing is everything downstream of the tag — building
+      artifacts and attaching them, which needs a spindle
+
+## Done
+
+Nothing yet. Everything this epic wants is above.
diff --git a/plan/branch-pulls.md b/plan/branch-pulls.md
new file mode 100644
index 0000000..aa35ff1
--- /dev/null
+++ b/plan/branch-pulls.md
@@ -0,0 +1,184 @@
+---
+id: branch-pulls
+title: A pull publishes the branch it claims
+status: open
+repos: [atgc]
+dependsOn: [pull-requests]
+exitCriterion: >
+  Every pull whose record carries a `source` has that branch really on the
+  knot, on the round the record's newest patch describes.
+---
+
+# branch-pulls
+
+`source: {branch}` is a claim the appview believes and never checks. atgc used
+to write it over a branch that had never been pushed, which is what made the
+tree link on a pull page describe code nobody could fetch.
+
+The default now pushes the branch to the target's knot and asks
+`sh.tangled.repo.compare` what it holds, and `--patch-only` keeps the old local
+`format-patch` and writes no `source` at all — because a `source` is exactly
+the claim that a branch is there. Rounds republish under a
+`--force-with-lease` read off the last round's own patch.
+
+What is left is the third shape (a pull from a fork), the branch a
+`--patch-only` pull came off, and retargeting one in place. The entry that
+asked whether `stack create` should keep writing `source` is not repeated here:
+it was answered by pushing the branch, the same way the flat pull was, and
+`src/cmd/stack/write.rs` opens with the reasoning.
+
+## What it needs
+
+- [ ] `stack resubmit` should re-push the branch, and today does not — the
+      sibling of the `pr resubmit` entry below and left out for the same
+      reason: one PR, one thing. Now that `stack create` publishes, a
+      reconcile after an amend leaves the knot holding the pre-amend branch,
+      so `resubmitCheck` says "should resubmit" forever and the tree link
+      describes commits no member's patch matches. The push itself is one
+      line; what the entry has to settle is force, since every reconcile this
+      command exists for rewrites history and a plain push will be refused
+- [ ] `pr resubmit` on a branch-based pull should re-push and re-compare,
+      and today does neither. Deliberately left out of the `pr create` change
+      so that one PR did one thing, and it is a real gap rather than a tidy
+      one: after a resubmit the record's newest round is the local patch
+      while the branch on the knot is whatever was last pushed, so the tree
+      link and the diff describe different code. The shape is already there —
+      `push_and_compare` in `cmd/pr/write.rs` is the whole of it — and the
+      question the entry has to answer first is how a resubmit knows which
+      shape it is appending to, since the pull record says (`source` present
+      and no `source.repo`) but a `--patch-only` pull must not suddenly grow
+      one
+- [ ] the branch a `--patch-only` pull came off is recorded nowhere, so
+      `pr view`, `browse --pr`, `pr diff` and `pr checkout` cannot find it
+      from the checkout it was opened in. `pr create --patch-only` says so
+      and prints the at-uri, which is the honest stopgap and not an answer.
+      The obvious candidate is still a trailer in the patch beside
+      `Change-Id:`, and the reason it was not just done is that
+      `read::for_branch` matches over *listing items*, which hold a blob CID
+      and not the patch — so matching on a trailer means fetching a blob per
+      candidate. Worth measuring before building: the candidate set is one
+      account's pulls on one repo, which is usually small
+- [ ] fork-based pulls, the third shape and the one `pr create` deliberately
+      does not implement. Harder than the branch shape in three separate
+      ways: `sh.tangled.repo.hiddenRef` on the *fork's* knot under service
+      auth, push access on the fork rather than on the target, and the
+      compare run there rather than here — and the appview only recognises a
+      fork it already knows (`db.GetForkByRepoDid`), so a fork atgc created
+      by other means does not qualify. `--patch-only` is the answer until
+      this exists
+
+## Done
+
+- [x] `pr create` — records `source: {branch}`. The reasoning that stood
+      here was wrong on a checkable fact, so it is corrected rather than
+      quietly dropped: this is **not** "the shape every pull request written
+      by anything other than atgc carries". Tangled's own web UI passes
+      `pullSource = nil` for a patch-based pull
+      (`appview/pulls/create.go`, `handlePatchBasedPull`), and only its two
+      branch shapes write a `source` at all. What the field buys atgc is
+      real — it is the only thing matching a pull back to the branch it came
+      off, which `pr view` needs — but the appview reads it as a claim and
+      not as a hint. See the entry below
+- [x] `pr create` pushes the branch by default, with `--patch-only` for the
+      shape it has today. Read off Tangled core at master `1adde466` rather
+      than guessed, so the ground was settled before the work started.
+
+      **There is no patchless pull**, and there still isn't:
+      `lexicons/pulls/pull.json` requires `rounds` and every round requires
+      `patchBlob`, so the patch is gzipped, uploaded and put in the round
+      exactly as before. What changed is who computes it and what the record
+      is then entitled to claim.
+
+      The default pushes the branch to the target's knot over SSH, `GET`s
+      `sh.tangled.repo.compare?repo=&rev1=&rev2=` —
+      a public query, no scope and no session — refuses an empty comparison
+      as the appview does, and writes `source: {branch}` over a branch that
+      is now really there. `--patch-only` keeps the old local
+      `git format-patch` and writes **no** `source`, because a `source` is
+      exactly the claim that a branch is on the knot and the appview never
+      checks it.
+
+      Two things learned in the doing, both cheap to get wrong. The knot
+      serializes `types.RepoFormatPatchResponse` with Go's names, not the
+      lexicon's: the mailbox arrives as `patch` and the per-commit list as
+      `format_patch`, and every field is `omitempty`. And a scenario can take
+      a real push without a git transport on the mock knot — the checkout
+      carries `url..pushInsteadOf`, which rewrites the push target and
+      leaves `git remote get-url` alone, so the repo DID is still resolvable
+      out of the remote path. Plain `insteadOf` would not have worked;
+      `get-url` applies it
+- [x] `stack create` pushes the branch and keeps `source: {branch}` on every
+      member, still formatting each member's patch locally. Read off Tangled
+      core at master `e4eb96e9`, and the read made the choice rather than
+      narrowing it.
+
+      The claim was never the wrong *shape*: `newStack` in
+      `appview/pulls/create.go` gives every member of its own stacks the same
+      `pullSource` while storing one commit's `fp.Raw` as that member's patch.
+      A member's `source` says where the stack came from; its patch says which
+      commit of it. So there was nothing to fix in the record — only the push
+      that makes it true, exactly as for the flat pull.
+
+      Dropping `source` was never open, which the read settled in one pass.
+      `resubmitCheck` in `appview/pulls/single.go` compares the *top* member's
+      sha (the stack is stored top-first) against the branch's live head;
+      `resubmitBranch` routes the web's resubmit through `repo.compare` and
+      re-splits the range by change-id — it dispatches on `IsBranchBased`
+      before it ever looks at the stack, so a sourceless member degrades to a
+      paste-a-patch box for one commit; `compose.go` will not compose a
+      stacked pull that is patch-based at all
+      (`isStacked := mode == "stack" && !isPatchBased`). And closer to home,
+      `read::for_branch` matches on `source.branch`, which is how `stack
+      view`, `stack resubmit` and `stack merge` find a chain — a sourceless
+      stack is one atgc could never pick up again. Hence no `--patch-only`
+      here: a push that fails refuses and points at `pr create --patch-only`.
+
+      The compare is still asked for, demoted from patch source to proof: an
+      empty answer refuses as `handleBranchBasedPull` does, a count that
+      disagrees with the plan is a note, and `sourceRev` needs nothing written
+      at all — `PullSubmissionFromRecord` derives it from the blob's last
+      patch, so the top member's sha becomes the branch head for free.
+
+      One thing learned that nothing above predicted:
+      `knotserver/git/diff.go` adds the `Change-Id:` header to a knot's
+      format-patch only from a commit object's jj `change-id`, never from a
+      `Change-Id:` trailer. So a `--add-change-ids` branch makes a stack the
+      *website* cannot resubmit — the appview finds no ids in what the knot
+      hands it. `stack create` says so when the compare shows it, and
+      `stack resubmit` is unaffected because it injects the headers itself
+- [x] `pr resubmit` on a branch-based pull re-pushes and re-compares, so
+      the newest round and the branch on the knot describe the same code
+      again. Which shape a round follows is read off the record and never
+      off a flag — `source` present and no `source.repo` is branch-based —
+      which is why there is no `--patch-only` on `pr resubmit` to match the
+      one on `pr create`.
+
+      The three awkward cases, decided. A `--patch-only` pull stays
+      patch-only forever: a round that grew a `source` would start claiming
+      a branch the pull's earlier rounds never published, and the appview
+      believes `source` without checking it, so the pull would look
+      branch-based back to its first round. Reopening with `pr create` is
+      the way to change shape, and it is honest about it. A fork-based pull
+      (`source.repo` set, which only the web UI opens) is refused: the
+      compare has to run on the fork's knot, which is the same thing that
+      keeps `pr create` from opening one. And a round raised from a branch
+      other than the one the record names is refused rather than resolved
+      either way round — pushing the checked-out branch under the recorded
+      name moves a branch nobody asked about, pushing the recorded name from
+      here sends commits the round never read — which is also the answer for
+      a source branch that no longer exists locally: fetch it from the knot
+      and check it out
+- [x] a round republishes its branch with a lease, which is what settled the
+      force question `stack resubmit` was waiting on. Both resubmits push
+      `--force-with-lease=:`, and the sha is read off the last
+      round's own patch — the `From ` line git and the knot both write
+      (`knotserver/git/diff.go` runs plain `format-patch`, no
+      `--zero-commit`) — never off the remote-tracking ref, whose argument-
+      less lease a `git fetch` quietly disarms by advancing it onto the
+      commits worth protecting. So a rewritten branch lands without a manual
+      force-push, and a branch somebody else moved refuses with both shas
+      named, before anything is sent. `stack resubmit` pushes at all now,
+      including on the no-op path, since records that already match an
+      unpushed branch are the state it has to repair. With no lease to be
+      had — a create, or a patch that names no head — the push is unleased
+      and fast-forward-only, which is what it always was
diff --git a/plan/brand.md b/plan/brand.md
new file mode 100644
index 0000000..beae9bd
--- /dev/null
+++ b/plan/brand.md
@@ -0,0 +1,70 @@
+---
+id: brand
+title: atgc looks like one thing wherever it is drawn
+status: shipped
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  The mark, the README, the terminal art and the page atgc serves a browser
+  all come from one geometry and one palette.
+---
+
+# brand
+
+The mark is two base pairs, A-T over G-C, because that is what the name is. The
+four colours are the chromatogram colours the bases are read in, and they are
+kept separate from the six styles anything else is painted in, so retheming
+atgc's secondary text does not repaint the field's hydrogen bonds.
+
+Nothing is stored that can be generated: `brand/build.py` draws the assets from
+the geometry in `brand/gen.py`, with `--check` to catch one that has drifted,
+the same way the helix on `atgc about` is drawn rather than stored.
+
+One constraint shapes the README: Tangled themes itself by swapping a class,
+not by `prefers-color-scheme`, and its sanitizer drops the `` element
+that answers this elsewhere. So a raster in a README cannot know its
+background, and the lockup comes from a neutral master.
+
+## What it needs
+
+- [ ] `about` still prints the bare version on its panel. Left alone
+      deliberately: the panel is sized to its widest line and the art is laid
+      out around it, so a build line is a layout change rather than a text
+      one. Worth doing when the panel next moves
+- [ ] Serve the favicon from the OAuth callback page. That page already
+      prerenders `about`'s field of DNA, and it is the one HTML atgc
+      serves, so it is the only place in the tree that can use
+      `brand/favicon.ico` without a host to put it on. Inline it as a data
+      URI rather than adding a second request to a loopback server that
+      exists for a few seconds
+
+## Done
+
+- [x] `about` — version and repo link on a panel over a drawn helix, sized
+      to the terminal (with a fixed frame when it can't be read, and bare
+      lines when it's too small for the panel); color drops out when stdout
+      isn't a terminal or NO_COLOR is set
+- [x] The `art` canvas carries an `Ink` per cell — `A`/`T`/`G`/`C`/`Muted`/
+      `Default` — instead of an SGR parameter. `art` maps it to an
+      `anstyle::Style`, `html::field` to a CSS class, and `class_for` no
+      longer recovers a base by searching for which one produces a given
+      escape. The four base colors stay in `art` rather than joining
+      `term::style`'s six: those are semantic and these are the chromatogram
+      colors the bases are read in, so retheming atgc's secondary text should
+      not repaint the field's hydrogen bonds
+- [x] brand — a logo, in `brand/`. The mark is two base pairs, A-T over
+      G-C, because that is what the name is; the four colors, the two-line
+      and three-line rungs, and the rounded panel all come from `about`'s
+      art rather than being invented beside it. Two rungs and not five
+      because five is illegible at 16px. Generated by `brand/build.py`
+      from the geometry and palettes in `brand/gen.py`, the same way the
+      helix is drawn and not stored, with `--check` to catch assets that
+      drift from it
+- [x] README header — the readme leads with the lockup (mark and name) at
+      512px instead of a 128px mark under an `# atgc` heading that said the
+      name a second time. The lockup rasters now come from a neutral master
+      the way `logomark.svg` already did: Tangled themes itself by swapping
+      a `dark` class, so a raster in a README cannot know its background,
+      and the `` element that answers this on GitHub is dropped by
+      Tangled's sanitizer (`appview/pages/markup/sanitizer`, which allows
+      `source`'s attributes but never the `picture` element)
diff --git a/plan/complete/agent.md b/plan/complete/agent.md
new file mode 100644
index 0000000..7bde166
--- /dev/null
+++ b/plan/complete/agent.md
@@ -0,0 +1,60 @@
+---
+id: agent
+title: An agent arriving cold uses atgc correctly on the first try
+status: shipped
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  `atgc agent` states the one fact the tool cannot be used without and the
+  behaviours other forge CLIs lack, inside a fixed budget.
+---
+
+# agent
+
+Agents arrive with `gh` muscle memory, and almost every way that misleads them
+here follows from one fact: a pull request is a record carrying patches, not a
+branch on a server. So the notes are model-first — that fact and its
+consequences, then only the behaviours no other forge CLI has — and they are
+compiled into the binary so they version with the code they describe.
+
+The 76-line cap is the point of the file rather than a limit on it. It was full
+once, so a subject could only arrive by displacing one, and for 42 commits
+nothing did. The cap held anyway and the room came out of padding: two lines
+that editorialised rather than reported, a seven-line images section that is
+one bullet now, and the blank line under every heading, which the indentation
+was already doing.
+
+## Done
+
+- [x] `agent` — working notes for AI agents, compiled into the binary so
+      they version with the code they describe. Plain ASCII, model-first:
+      the one fact (a pull is a record carrying patches) and its
+      consequences, then only the behaviors other forge CLIs lack. The
+      command-level help gained the same corrections at point of use
+      (`pr` group, `pr create`/`resubmit`, `auth login`, `repo` group)
+- [x] `agent` rewritten to track what the CLI grew into. `doctor` landed
+      before the notes' last edit and went unmentioned anyway; `api` and the
+      `issue` family landed after it and moved nothing here at all, leaving
+      42 commits with the page frozen. The 76-line cap is why — it was full,
+      so a subject could only arrive by displacing one, and none did. The cap
+      held anyway, concision being the ask, and the room came out of padding:
+      the two lines that editorialised rather than reported, the seven-line
+      images section (one bullet now), and the blank line under every
+      heading, which the indentation was already doing. Added `doctor` and
+      `auth login` as the first move in an unfamiliar checkout, `pr checkout
+      --worktree`, `api`, the eight exit-status meanings, `issue`'s two
+      refusals, and `logs git`
+- [x] `atgc agent` names the logs, in one bullet rather than the three lines
+      this entry asked for: "atgc logs git says whether atgc moved a ref;
+      also logs pds, oauth". The rewrite that made room paid for it out of
+      the same 76 lines, so the line kept the half an agent cannot get
+      anywhere else — that a ref moved because *atgc* moved it, which a
+      reflog does not say — and dropped the jsonl paths and `ATGC_*_LOG=off`,
+      both one `logs --help` away
+- [x] `atgc agent`'s briefing carries both, as a trade rather than an
+      addition — its 76-line budget was already spent, which is the point of
+      the number. The scope rule earns a "details that surprise" line
+      because `gh pr status` is the muscle memory an agent arrives with and
+      it means something else here; the services half rides in the `doctor`
+      paragraph, being its other end. What went is the `logs git` line, the
+      one fact in the file that `atgc logs --help` answers as well
diff --git a/plan/configuration.md b/plan/configuration.md
new file mode 100644
index 0000000..682b1f2
--- /dev/null
+++ b/plan/configuration.md
@@ -0,0 +1,102 @@
+---
+id: configuration
+title: Pointing atgc somewhere else is a setting, not a patch
+status: open
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  Every service atgc contacts and every bound it applies can be named from
+  outside the binary, and the precedence between the ways of naming them is one
+  chain.
+---
+
+# configuration
+
+Five hostnames are compiled in and all five are overridable
+(`clients/endpoints.rs`), each as a whole base URL so a local instance on a port
+works. Before that module existed, "what does this tool contact" was a question
+you answered by grepping.
+
+The bounds are the user's too — connect and read timeouts, by flag over
+environment over default, the same precedence `--account` and `--no-input`
+follow. Five seconds is a judgement about what a *reachable* host takes, and it
+is right for the network it was measured on and wrong for a tether or a runner
+in another hemisphere.
+
+What is not here is a config file, and the open entry below is mostly about why
+that is a decision about precedence rather than about a parser.
+
+## What it needs
+
+- [ ] No config file. Twenty-four `ATGC_*` variables steer atgc — the
+      account, four service endpoints, two timeouts, four log paths, the
+      input gate, the log filter — and every one is set per invocation or
+      exported into a shell, where it then applies to every other tool in
+      that shell. There is nowhere to write down "always this account in
+      this checkout", "my appview is not tangled.org", or "keep thirty days
+      of logs". `~/.config/atgc/` already exists and already holds
+      `sessions.json` and `accounts.json`, so this is a third file rather
+      than a new directory, a new lock, or a new idea.
+      What makes it worth writing down is the precedence, not the parser.
+      `--account` already outranks `ATGC_ACCOUNT`, which outranks the
+      checkout's own `user.email`, which outranks the persisted active
+      pointer — and `auth login` and `auth switch` *print* that chain at
+      people, worked out from the state at the time. A file inserts a rung
+      into a ladder that is already explained out loud, and every one of
+      those sentences has to stay true afterwards. Flag beats env beats file
+      beats persisted state is the ordering that keeps them true: a file is
+      a standing preference and a variable is a deliberate override of one.
+      Format is a real cost and not a taste question. `serde_json` is
+      already here and the two neighbouring files are JSON, so JSON is free;
+      TOML is what a person expects to hand-edit and comment, and costs a
+      dependency atgc does not have. Free and consistent with its neighbours
+      probably wins, but it is a choice, not a default.
+      Two things it must not become. Not a per-repo place to set the four
+      endpoints — `ATGC_APPVIEW` and its siblings exist to point a developer
+      at a test service, and a checked-in file that silently redirects
+      somebody's PDS traffic is a different object with a different threat
+      model, so endpoints stay env-only or the file stays strictly
+      user-global. And not a second spelling for every flag: the value is in
+      the few settings a person holds constant. Start with the account, log
+      retention and the `ATGC_LOG` filter, and stop
+- [ ] Neither bound can be switched off: 0 is refused, because an unbounded
+      connect is the wait `clients/http.rs` was written for. Revisit if a case
+      turns up that a very large number cannot serve
+
+## Done
+
+- [x] The HTTP bounds are the user's to set: `--connect-timeout` /
+      `ATGC_CONNECT_TIMEOUT` and `--read-timeout` / `ATGC_READ_TIMEOUT`,
+      flag over environment over default, the same precedence `--account`
+      and `--no-input` follow. The five seconds is a judgement about what a
+      *reachable* host takes, and it is right for the network it was measured
+      on and wrong for others — a tether, a slow relay, a runner in another
+      hemisphere. When it is wrong the failure reads "could not reach", which
+      is indistinguishable from the host being down. The default stays short
+      because a script or an agent would rather be told quickly
+- [x] non-interactive mode — `--no-input` / `ATGC_NO_INPUT=1`, automatic
+      when stdin is not a terminal or `CI` is set: `auth login` refuses with
+      instructions instead of waiting out its five-minute browser timeout,
+      `browse`/`--web` print URLs without opening anything, and git runs
+      with `GIT_TERMINAL_PROMPT=0` (plus SSH BatchMode on clones) so it
+      fails rather than prompts. Agent-vendor env vars deliberately ignored;
+      the signals are universal ones only
+- [x] A gap in that gating, found after the fact: `pr checkout`'s fetch of a
+      branch-based pull's source branch went through `review.rs`'s own git
+      runners, which predate `clients/git/run.rs`'s `GIT_TERMINAL_PROMPT=0` and never
+      read it, so a credential prompt on that one real network fetch could
+      still hang. Those runners now build their `Command` from `git::cmd()`
+      instead of a bare `Command::new("git")`, so every git subprocess in
+      the tree shares the one gate, fetch included
+- [x] A second gap next to it: `repo clone --ssh` skipped its own refusal
+      and its own `BatchMode` both when the push-key lookup itself failed —
+      a network hiccup, not a confirmed missing key — because the failure
+      collapsed to the same `None` a confirmed miss produces, and only the
+      confirmed case was refused. An unverified key went ahead over SSH
+      with no gate on it at all. `--ssh` now refuses on a lookup failure
+      explicitly, the same way it already refused on a confirmed one
+- [x] `clients/endpoints.rs` — the five hostnames atgc has compiled in
+      (`ATGC_PLC`, `ATGC_BOBBIN`, `ATGC_APPVIEW`, `ATGC_KNOT`,
+      `ATGC_BSKY_APPVIEW`), each a whole base URL so a local instance on a
+      port works. `ATGC_BOBBIN` was the only one before, and "what does this
+      tool contact" was a question you answered by grepping
diff --git a/plan/credential-store.md b/plan/credential-store.md
new file mode 100644
index 0000000..f2ccc85
--- /dev/null
+++ b/plan/credential-store.md
@@ -0,0 +1,274 @@
+---
+id: credential-store
+title: The stored credentials survive a crash, and are nobody else's to read
+status: shipped
+repos: [atgc]
+dependsOn: [sign-in]
+exitCriterion: >
+  Every write to sessions.json lands whole and under a lock, every file in
+  ~/.config/atgc is 0600 inside a 0700 directory, and doctor says so when it is not.
+---
+
+# credential-store
+
+`~/.config/atgen/atgc` holds an access token, a rotating refresh token and a
+DPoP private key per account. Two weeks of standing authority to write to
+somebody's repository, in a file, on a laptop.
+
+This epic is the whole of what that costs: atomic writes, one advisory lock
+across every read-modify-write including the one that spans a network round
+trip, modes set at `open(2)` rather than chmodded afterwards, and a repair for
+every install that predates each fix. Most of it was found by an incident and a
+log rather than by reading, which is why so many entries below cite a
+measurement.
+
+Two techniques lost the argument here and stay recorded rather than being
+re-proposed: the OS keyring, and encrypting the DPoP key at rest.
+
+## What it needs
+
+- [ ] Failing to take the lock fails the command — it never proceeds
+      unlocked. Weighed deliberately: an agent waiting a few seconds notices
+      nothing, while an unlocked read-modify-write can delete a live session,
+      which costs a person a browser login and is invisible at the moment it
+      is caused. The cost is that the lock is now a hard dependency, so a
+      `$HOME` on a filesystem where `flock` returns `ENOLCK` rather than
+      merely blocking (some remote mounts) fails these commands outright
+      instead of degrading. Nobody is known to run that way; if someone does,
+      the answer is a real fix for that filesystem, not a flag that turns the
+      exclusion off
+- [ ] The DPoP private key sits in the clear beside the token it protects.
+      Each session in `sessions.json` carries the access token, the
+      single-use rotating refresh token and the P-256 key as a JWK, so
+      proof-of-possession buys nothing against anyone who can read the file.
+      Measured: an access token lives 60 minutes and the session two weeks
+      (public loopback client), and the scopes include
+      `rpc:sh.tangled.repo.delete?aud=*`
+- [ ] **The OS keyring was researched and rejected**, written down here so
+      nobody does it twice. `keyring` 3.x and 4.x both reach macOS through
+      the *legacy* `SecKeychain*` API, whose ACL is keyed to the binary's
+      designated requirement — an unsigned or ad-hoc-signed binary gets a new
+      code hash on every build, so every rebuild re-prompts and "Always
+      Allow" does not survive one. The data-protection keychain that fixes
+      that needs a provisioning profile, and apple-native-keyring-store's own
+      README says there is little point in a command-line tool using it.
+      Headless is worse than a prompt on both platforms: macOS over SSH
+      returns `errSecInteractionNotAllowed` (-25308), which `keyring` does not
+      map and so surfaces as an opaque `PlatformFailure`; Windows over SSH
+      gets a network-class logon with no credential set and fails with
+      `ERROR_NO_SUCH_LOGON_SESSION`, the same limitation Git Credential
+      Manager documents. Windows also caps a credential blob at 2,560 bytes
+      and `sessions.json` is 8,869 for two accounts, while the kernel
+      keyutils backend has a 20,000-byte per-user quota that those two
+      accounts would already fill to 44%. atgc goes non-interactive off a
+      terminal by design, so a store that prompts or blocks is a regression
+      in the case that matters. The work belongs in hardening the file —
+      atomic, locked, 0700 — not in a keyring.
+
+      Re-checked when the atomic-write work landed, because it is the
+      question anyone arriving at this file asks first. It still holds, and
+      the size argument has got stronger rather than weaker: this machine's
+      store is now 18,825 bytes for three accounts, which is **7.3x the
+      entire Windows credential-blob cap** and **94% of the whole keyutils
+      per-user quota** — for one user, before a fourth account. A store that
+      cannot hold the credentials is not a store, and no amount of prompting
+      behaviour fixes that. Nothing in the rest of the entry has changed
+      either, so the conclusion is unchanged: harden the file
+
+## Done
+
+- [x] File session store at ~/.config/atgc/sessions.json, mode 0600
+- [x] `sessions.json` and `accounts.json` are now written atomically: stage
+      the bytes in a same-directory temp file created at the destination's
+      mode (0600, before any byte lands — never `chmod`'d afterward), fsync
+      it, then rename it over the target. Closes the crash window a bare
+      `fs::write` left open — a kill -9, OOM or full disk mid-write used to
+      be able to truncate either file, and a truncated sessions.json is not
+      the self-healing case: `read_store` hard-fails on it, and that sits on
+      the path of nearly every account-aware command. Temp names fold in the
+      pid, wall-clock nanoseconds and a per-process counter, since there is
+      no locking anywhere in this codebase and a fixed name would hand two
+      concurrent invocations a new collision to replace the old one. Does
+      **not** touch the separate unlocked read-modify-write race `read_store`
+      and `write_store` describe — that one needs locking, not atomicity, and
+      is the item below
+- [x] `read_store`'s corrupt-JSON error now says so and names the fix — move
+      the file aside and log in again — distinct from the read-failure
+      message a missing file or a permissions error still gets. A truncated
+      sessions.json used to hard-fail with `serde_json`'s bare parse error
+      and nothing pointing at `rm ~/.config/atgc/sessions.json` as the way out
+- [x] Hypothesis 2 is closed: one advisory lock (`~/.config/atgc/.lock`,
+      `flock(2)`, `crate::lock`) now covers every read-modify-write of both
+      files, held across *both* halves rather than around the write.
+      `write_store` and `account::save` take a guard argument they never
+      read, so a write not computed under the lock is a compile error, and
+      the registry's four mutators go through one `update` closure instead of
+      four load/edit/save pairs — losing `client_id` there costs the session,
+      not a cached handle. Reads stay unlocked: `write_atomic` renames, so a
+      reader sees one whole file or the other
+- [x] The refresh race needed the lock held across a *network* round trip,
+      which nothing wrapped around the store can do — the section spans three
+      store calls with a token request between them. Vendored jacquard-oauth
+      gains a defaulted `ClientAuthStore::lock_for_refresh` hook (no-op for
+      every other implementor) that `get_refreshed` takes before the read and
+      holds past the write; a process that queued then re-reads and finds a
+      token it does not have to spend. Report upstream — see
+      the note beside the patch in Cargo.toml for the shape and the alternative weighed
+      against it
+- [x] `prune_stale_sessions` no longer deletes every `oauth-state:` key it
+      sees, and a single-account `auth logout` no longer does either (only
+      `--all` still does). Those entries hold *another* invocation's PKCE
+      verifier: dropping one makes a concurrent login fail at its callback
+      with nothing to match the returned `state` against. `login` now cleans
+      up after its own abandoned attempts instead — the timeout and
+      token-exchange-failure paths, since jacquard already deletes the state
+      on success. Residual leak, deliberately: a login killed outright leaves
+      its entry until `auth logout --all`. Now measured rather than
+      predicted, and it is bigger than "a few hundred bytes" suggested: this
+      machine's `sessions.json` reached 18,825 bytes against the 8,869 this
+      file records for two accounts, and 3 of its 6 top-level keys are
+      abandoned `oauth-state:` entries. Each carries a PKCE verifier and a
+      DPoP private key, so this is dead key material sitting beside live
+      credentials, not just dead weight — which is a better argument for
+      expiring them by age than the disk cost ever was.
+
+      Postscript, and the reason the measurement kept getting worse: the
+      cleanup this entry describes had never once run. `discard_auth_state`
+      takes the state from `state_param`, which reads the authorization URL,
+      and under PAR there is no `state` in the URL to read — so every call
+      returned at its first line. Six abandoned entries against three live
+      sessions by the time it was noticed, and not one `auth_state_deleted`
+      in four hundred logged events. Fixed under the `auth login` entries
+      above; expiring by age is still worth doing for the entries a killed
+      login leaves, which no amount of cleanup-after-yourself reaches
+- [x] **Most writes to `sessions.json` were neither atomic nor locked.**
+      `write_atomic` had two callers; everything else reached disk through
+      jacquard's `FileTokenStore::set_value`, which is `read_to_string` then
+      `std::fs::write` — a truncate in place of the live credential file.
+      Counted against this machine's own `oauth.jsonl`, over 4,536
+      invocations: 1,090 `session_upsert` against 12 `store_write`, and 1,010
+      of those upserts carried an unchanged access token. Shipped as
+      `auth::store::SessionStore`, a `ClientAuthStore` over `read_store` and
+      `write_atomic`, so there is now one pathway and every write lands by
+      rename. No format change and no migration, as predicted:
+      `jacquard::client::token` is a `pub mod` and `StoredSession` is
+      externally tagged, so the records are byte-for-byte the ones jacquard
+      wrote and an `atp:` key survives a write it is not part of. The file is
+      compact now rather than alternating with jacquard's pretty form; both
+      parse, and a pretty fixture is pinned in a test so no existing store
+      breaks.
+
+      The diagnosis of those 1,010 was wrong in a way worth recording. They
+      are not one-per-round-trip nonce bookkeeping: `OAuthClient::restore`
+      ends with `create_session` handing `SessionRegistry::set` the record it
+      has just read, so they are **one per authenticated invocation, writing
+      back bytes identical to the ones on disk**. So the answer is not a
+      cheaper write but no write — `SessionStore::put` compares the
+      serialized record against what is already at that key and returns
+      without touching the file. Confirmed against the real store: `auth
+      status` and `doctor` now leave `sessions.json` at the same inode and
+      the same checksum, where every such command used to rewrite it
+- [x] **The one pathway was atomic but still unlocked.** `SessionStore`
+      reached `write_store_at` directly rather than through the function that
+      requires a `config::lock::Guard` as a proof obligation, so jacquard's
+      writes — which is most of them — were atomic and took no lock, exactly
+      as they had before. A whole-file rewrite can drop another process's
+      session whatever prompted it, which is the thing the lock exists for.
+
+      The obvious fix self-deadlocks, as this entry said: `get_refreshed`
+      calls `upsert_session` and `delete_session` *inside* the guard
+      `lock_for_refresh` hands it, and `flock` is per process, so a lock
+      taken unconditionally in the write path waits out `WAIT` against this
+      process's own hold and then fails — on the one write that carries
+      freshly rotated tokens. So it is a re-entrancy story after all:
+      `take_reentrant` publishes the section's context in a `HOLDER`, and
+      `take_or_inherit` returns `Hold::Inherited` to a write already inside
+      it and `Hold::Owned` to everything else.
+
+      The hole this entry named — "the lock is already held" being a fact
+      about the *process* rather than the call chain — is closed by what
+      `HOLDER` stores: `tokio::task::try_id()`, so a second task writing
+      while a refresh is in flight does not match and takes the lock
+      properly. The `Option>` is deliberate twice over: the inner
+      `None` is a future polled by `block_on` rather than a spawned task,
+      which is where atgc's commands actually run, and matching `None`
+      against `None` is sound because there is one `block_on` context per
+      runtime and `put`'s read and write have no `.await` between them.
+      Both halves are pinned by tests, the inheriting one timed so that
+      "inherited" cannot pass as "waited thirty seconds and then inherited"
+- [x] Whether a nonce-only update takes the lock, settled: **yes, when there
+      is one — but the case that mattered no longer writes at all.** The
+      exclusion was never about the nonce; it is about the whole-file
+      rewrite, which can drop another process's session whatever prompted it,
+      so a write that changes a nonce is exactly as dangerous as one that
+      changes a token and gets the same treatment. What made the question
+      urgent was the measured 1,010, and those turn out to be no-op
+      write-backs rather than nonce updates, so they are elided instead of
+      locked. The cost argument the ledger worried about — a `flock` on every
+      PDS round trip — does not arise: what is left is one lock per write
+      that changes something, which is roughly one per login and one per
+      refresh
+- [x] `~/.config/atgc` is created at the umask default. `config_dir` calls
+      `create_dir_all`, so the directory is 0775 here while every file in it
+      is 0600; nothing is exposed only because `~/.config` happens to be 0700
+      and the primary group is private. The code relies on the parent's luck
+      rather than on its own choice. `DirBuilder::mode(0o700)` settles new
+      installs, and every existing user is already in the wide state, so it
+      wants a repair as well as a fix — both shipped, and the repair says so
+      on stderr rather than happening quietly under somebody's home directory
+- [x] A first-login window where the store was world-readable.
+      `FileTokenStore::try_new` wrote `{}` at the umask default and
+      `auth_store` chmodded to 0600 afterwards. The file is empty in that
+      window — but every later write truncated the same inode, so a local
+      process that opened a descriptor during it kept reading credentials
+      indefinitely. Closed as the predicted side effect of the entry above:
+      nothing creates the file eagerly any more, and each write lands on a
+      new inode, so a descriptor from that window no longer follows the file.
+      Pinned by a test asserting the inode changes between two writes and
+      that both are 0600 — the inode is the assertion that matters, since a
+      mode check alone would pass against an in-place write
+- [x] `XDG_CONFIG_HOME` is not read. `config/dir.rs` spells
+      `$HOME/.config/atgc` and nothing in the tree looks the variable up.
+      Three lines, and deliberately without `dirs`: its `config_dir()`
+      returns `~/Library/Application Support` on macOS, which would move
+      every existing user's state — a worse bug than the one being fixed. The
+      spec says a relative value is ignored, which is worth honouring rather
+      than joining. Shipped; a relative value is ignored out loud, and setting
+      the variable over an existing login is answered with where the accounts
+      still are rather than only with "no account is logged in"
+- [x] Nothing ever says the credential store has gone readable. `doctor`'s
+      session row checks liveness only, and `auth_store`'s chmod is
+      incidental repair on a path that `auth status` and `doctor` never take.
+      Shipped as `doctor`'s `config dir` row: the directory's mode and every
+      file's, whatever another local user can reach, and one `chmod go-rwx`
+      that covers all of it. Warn and never error, because a readable store
+      is a confidentiality problem and not a broken install
+- [x] `OpenOptions::create(true)` with `.mode(0o600)` silently ignores the
+      mode when the file already exists, so a file inherited at 0644 kept it
+      for good — and `ATGC_OAUTH_LOG` pointed at an existing file is a
+      supported way to arrange exactly that, for a log naming DIDs, working
+      directories and the local user. `logging/file.rs`'s `open_sink_at` now
+      calls `narrow_if_wide` on the descriptor it just opened: read the mode,
+      and `fchmod` away group and other only when they are set. Through the
+      fd rather than the path, so nothing swapped in between is what gets
+      chmodded; owner bits preserved, so an append-only 0200 log stays 0200;
+      and a file that could not be opened is left alone, there being no
+      descriptor to act on. Verified against a real 0644 file under
+      `ATGC_OAUTH_LOG`.
+      `config/lock.rs:219` is deliberately left alone — the lock file holds
+      nothing, atgc is the only thing that creates it, and the `config dir`
+      row now names it with a chmod if it is ever found wide. Doing it
+      properly is `create_new` and then a plain `open` on `AlreadyExists`,
+      which is two syscalls and a new race on a path taken for every lock
+- [x] `auth_store` chmodded `sessions.json` to 0600 as incidental repair, on
+      a path `auth status` and `doctor` never take — `auth_store` is the
+      OAuth client's entry point, and every command that only *reads* the
+      store names the file without going near it. The repair is now
+      `config::dir::narrow_file_if_wide`, beside the directory's own and in
+      the module every caller reaches, and it is called from `store_path`,
+      which nothing can skip: `auth status` on a 0644 store now says so and
+      narrows it. Three changes of shape came with the move: only when the
+      file is actually wide, rather than a chmod on every call; the owner's
+      own bits kept, so a deliberately read-only store does not gain a write
+      bit; and a refused chmod is a warning rather than a failed command,
+      since a store on a read-only mount should still be readable
diff --git a/plan/declined.md b/plan/declined.md
new file mode 100644
index 0000000..a0a5493
--- /dev/null
+++ b/plan/declined.md
@@ -0,0 +1,53 @@
+---
+id: declined
+title: What atgc is not building
+status: declined
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  None. An entry is finished when it is written down.
+---
+
+# declined
+
+Things somebody could reasonably expect this project to build, that it is not
+going to. Features only: deferred work stays in its own epic, and a technique
+that lost an argument stays beside the code and the measurement that killed it
+— the confidential OAuth client is in [sign-in](sign-in.md), the OS keyring in
+[credential-store](credential-store.md), and dynamic shell completion, a table
+crate and mdBook are each recorded in the epic that surveyed them.
+
+## A service of atgc's own
+
+No hosted callback, no rendezvous, no index, no relay. Everything here reads a
+PDS directly and degrades to "the index is out" rather than "the service is
+down". The moment logging in requires a host of ours to be answering, that
+property is gone for the one command every other command needs.
+
+It is written down because the pressure is real and recurring: a confidential
+client needs an HTTPS callback, pull numbers want a number-to-URI endpoint, and
+a service-wide lag measure wants a newest-indexed-record timestamp. Each is a
+credential-handling or availability-critical endpoint to secure, rate-limit and
+keep up, in exchange for something atgc can degrade around. Reopen only if
+Tangled itself grows the endpoint, in which case atgc calls it and still hosts
+nothing.
+
+## A verb for every lexicon method
+
+Tangled's lexicon is far larger than the part atgc has verbs for, and it keeps
+growing. `atgc api` is the answer and is deliberately the whole answer: a verb
+is a promise about flags, output shape, exit statuses and `--json` that has to
+be kept across releases, and most of the lexicon has no caller here worth that.
+A method graduates to a verb when something in this register needs it, not
+because it exists ([api](api.md)).
+
+## A general TODO file
+
+This directory replaced it, and there is deliberately no flat list any more.
+Something worth writing down belongs in an epic — and if it belongs to no epic,
+that is the useful signal rather than an excuse to start another list.
+
+## Done
+
+Nothing, and nothing is expected. An entry that gets reversed leaves this
+file and becomes an epic of its own.
diff --git a/plan/dependencies.md b/plan/dependencies.md
new file mode 100644
index 0000000..3847b6f
--- /dev/null
+++ b/plan/dependencies.md
@@ -0,0 +1,153 @@
+---
+id: dependencies
+title: What atgc ships is known, licensed, and not advised against
+status: open
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  cargo-deny passes with no ignored advisory that has a fix atgc could take.
+---
+
+# dependencies
+
+`cargo deny check` runs and passes, and the file records why each ignore is
+there and what would remove it — visible rather than silenced, and cargo-deny
+reports `advisory-not-detected` once an ignored id stops matching, so no ignore
+can outlive its advisory unnoticed.
+
+Two advisories stand, both under jacquard and neither fixable here, and the
+open entries below are mostly the record of *not* fixing them: the routes that
+look available (a vendored fork, dropping the `dns` feature, a
+`[patch.crates-io]` that is a silent no-op) are each worse than the advisory,
+and re-deriving that is a day's work every time.
+
+Three bugs found in jacquard are recorded here too, unreported. They are the
+reason `vendor/jacquard-oauth` exists, and landing them upstream is the same
+work as deleting it.
+
+## What it needs
+
+- [ ] No table crate, surveyed while sizing the entry above and recorded so
+      it is not surveyed twice. `comfy-table` and `tabled` both draw a
+      *table* — borders, headers, a layout engine — where every listing here
+      prints bare space-separated columns in the `gh` idiom, so either one
+      would be fought to make it render less; `comfy-table` is ANSI-aware
+      only behind `custom_styling`, which pulls `console`, and `tabled`'s
+      width machinery is a dependency tree of its own. `tabwriter` is the
+      right size and solves the wrong problem: elastic tabstops size a
+      column to its content, and these widths are fixed on purpose so that
+      two invocations line up. What is wanted is one `term::width` and one
+      `pad_to` beside it — the entry above, and about forty lines
+- [ ] Two RustSec advisories, both under jacquard and neither fixable here.
+      `hickory-proto 0.24.4` (RUSTSEC-2026-0119, O(n²) name compression, CPU
+      exhaustion) needs jacquard-identity to move off hickory-resolver 0.24 —
+      a semver-incompatible bump `cargo update` cannot make. `rsa 0.9.10`
+      (RUSTSEC-2023-0071, Marvin timing sidechannel) has no fixed version at
+      all; it arrives through jose-jwk, for a key type atgc's ES256 DPoP keys
+      never exercise. Both still stood on 2026-08-15, confirmed independently
+      by `cargo audit` and cargo-deny 0.20.2 against the same lockfile, and
+      both are now in deny.toml's `advisories.ignore` with the removal
+      condition for each — visible rather than silenced, and cargo-deny
+      reports `advisory-not-detected` once an ignored id stops matching, so
+      neither can outlive its advisory unnoticed. `cargo audit` adds a third
+      that cargo-deny does not: `atomic-polyfill 1.0.3` (RUSTSEC-2023-0089,
+      unmaintained), four levels down through heapless <- postcard <-
+      jacquard-common. That difference is deny.toml's `unmaintained =
+      "workspace"` working as intended, not a disagreement about facts
+- [ ] RUSTSEC-2026-0119 rechecked on 2026-08-17, and the answer is still no.
+      The blocker is one line upstream: jacquard-identity 0.12.1 declares
+      `hickory-resolver = "^0.24"`, and 0.12.1 is the newest release of every
+      jacquard crate — the crates.io index lists nothing above it for
+      jacquard, -identity, -oauth or -common, and the repo's last commits are
+      a month old and unrelated to DNS. 0.24.4 is the end of the 0.24 line,
+      there is no backport, and the advisory's `patched` list is exactly
+      `>= 0.26.1`. So `cargo update -p hickory-proto` locks 0 packages, and
+      `--precise 0.26.1` (or 0.25.2, on either hickory crate) fails outright
+      quoting the `^0.24` requirement. The part worth writing down is that
+      `[patch.crates-io]` aimed at hickory-resolver 0.26.1 is not an error
+      but a *silent no-op*: cargo printed no warning at all and
+      `cargo tree -i hickory-proto` still showed 0.24.4. Anyone who tries
+      that and does not re-run `cargo audit` will believe they fixed it.
+      Fixable when jacquard-identity requires hickory-resolver >= 0.26.1 and
+      jacquard depends on that release. No upstream issue tracks it — ten are
+      open and none mention hickory, DNS or RustSec — so the only thing to
+      watch is the version, and filing one upstream would be worth more than
+      another recheck here
+- [ ] Two real routes to RUSTSEC-2026-0119, both rejected on 2026-08-17 and
+      recorded so they are not rediscovered as bright ideas. Vendoring a
+      forked hickory-proto still numbered 0.24.4 would satisfy cargo, and the
+      upstream fix is genuinely small — `COMPRESSION_CANDIDATE_LIMIT = 64` in
+      `serialize/binary/encoder.rs`, `COMPRESSED_NAME_LIMIT = 120` in
+      `rr/domain/name.rs` — but it lands in an API rewritten between 0.24 and
+      0.26 (no_std `alloc`, `ProtoErrorKind` -> `ProtoError`, `EncodeMode` ->
+      `NameEncoding`), so it means hand-porting into a 46k-line DNS
+      wire-format crate this repo would then own, and a path-sourced fork is
+      invisible to `cargo audit` — silencing the tool rather than fixing the
+      code. The other route is dropping jacquard's `dns` feature, which drops
+      hickory outright; jacquard-identity then resolves `_atproto.`
+      TXT over DNS-over-HTTPS against a hardcoded `cloudflare-dns.com`. That
+      trades a DoS advisory for sending every handle lookup to one third
+      party and ignoring the system resolver, which is the wrong trade for an
+      ATProto CLI. Weighing against both: hickory-resolver 0.24.4 has no
+      non-test site that re-encodes a received message — `DnsResponse` keeps
+      the wire buffer it was handed — so the only `BinEncoder` run on atgc's
+      path is the single-question query it builds itself, which is not the
+      many-records encode the advisory needs. That lowers the practical
+      exposure; it does not clear the advisory, so the ignore stays
+- [ ] Drop the vendored jacquard-oauth once the rpc `?aud=*` serialization
+      fix lands upstream; report the bug (Cargo.toml [patch] has details)
+- [ ] Surveyed and deliberately left alone, recorded so nobody re-litigates
+      them: the two percent-encoders (not inverses, and documented at length
+      as to why), the four base64/base32/CID hand-rolls, `config/lock.rs` on
+      std's `File::try_lock`, the thirty-line word wrapper, a retry/backoff
+      crate, `rand`/`uuid` for the invocation id, `strsim`, shell-quoting and
+      glob crates, and the sixty-line loopback HTTP server.
+      `chrono` was on this list and has been taken off it deliberately, not
+      by oversight. The rejection was made against a cost that is not paid:
+      jacquard-common holds a `chrono::DateTime` inside every
+      `Datetime`, with the same feature set, so chrono is compiled into every
+      build of atgc regardless and declaring it adds no crate and no compile
+      time. What it buys is `term::column::day` printing a real UTC calendar
+      date instead of ten characters off the front of somebody else's local
+      stamp — a behaviour fix, and one with no cheaper spelling: `day` takes
+      a raw `&str` at every call site, and reaching through
+      `jacquard::common::deps::chrono` instead would couple atgc to another
+      crate's re-export surface, which it is free to withdraw
+- [ ] Report upstream to jacquard: `client_id` on a token request comes from
+      the caller's `ClientData` rather than from the session, and
+      `ClientSessionData` has no field to hold it, so no loopback client can
+      refresh a grant across process boundaries. `oauth/loopback.rs`'s own
+      helper looks to have the same bug. Not patched in `vendor/` on
+      purpose — see the note beside the patch in Cargo.toml
+- [ ] Report upstream to jacquard, second item: `JacquardResolver` takes no
+      DNS resolver. `new_dns` and `with_system_dns` both build hickory with
+      `ResolverConfig::default()`, which is Google's `8.8.8.8` — the latter's
+      name says otherwise, and hickory's own `from_system_conf` is what it
+      sounds like. The field is private, so a caller cannot supply one, and
+      atgc now sends handle lookups to Google rather than to the resolver the
+      machine is configured with. An `Option>`
+      argument would settle it. Wanted before anyone points atgc at a PDS on
+      a network with split-horizon DNS
+- [ ] Report upstream to jacquard, third item: the two places a
+      *server-returned* scope string is parsed — `callback_core` and
+      `ClientSessionData::update_with_tokens` — did it with
+      `Scopes::new().expect()`, so an authorization server extending the
+      scope vocabulary aborts every client built on the crate. Patched in
+      `vendor/` (unlike the second item) because the failure is not
+      hypothetical and lands mid-callback. The lossy parser is the shape to
+      offer upstream; whether it should also *report* what it dropped, rather
+      than leaving each caller to re-derive it from the raw string as atgc
+      does, is the part worth asking about
+
+## Done
+
+- [x] `cargo deny check` runs, and passes. cargo-deny 0.20.2 (prebuilt Linux
+      binary in ~/.local/bin, not a source build) parsed deny.toml on
+      2026-08-15 without complaint, so the version guesses in its header held.
+      Three findings, all now answered in the file: the two advisories above;
+      `tangled-lexicon` and `userinput-lexicon` counted as unlicensed
+      third-party code, since both are generated here and carry no `license`
+      key (`[licenses.private] ignore = true`, cargo-deny's own answer for an
+      unpublished workspace member); and the wildcard finding below. The
+      license allow list needed no change at all — no missing entry, no
+      unused one. Ten `multiple-versions` warnings, as expected
diff --git a/plan/docs.md b/plan/docs.md
new file mode 100644
index 0000000..01edfc6
--- /dev/null
+++ b/plan/docs.md
@@ -0,0 +1,45 @@
+---
+id: docs
+title: Only what cannot be discovered by running the tool or reading the code
+status: continuous
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  None. A page earns its place by holding something no other source can, or
+  it is deleted.
+---
+
+# docs
+
+Four pages under `docs/`: the model, the output contracts, the module layout
+and what earns a test. Flags are in `--help`, why a dependency is vendored is
+in `Cargo.toml`, what changed is in `git log`, what is enforced is in
+`prek.toml` — none of that is repeated, because a second copy is a second thing
+to be wrong.
+
+They are compiled into rustdoc through `src/docs.rs`, with
+`rustdocflags = -D warnings`, so a reference from prose into code that no
+longer resolves is a build failure. mdBook was rejected for being a second tool
+outside the toolchain pin that could not check exactly that.
+
+This register is under the same rule, and the entry below is the reason it
+exists in this shape rather than as one flat file.
+
+## Done
+
+- [x] Documentation: narrative pages in docs/ (architecture, pull requests,
+      accounts, sessions), compiled into rustdoc through src/docs.rs so prose
+      and generated reference are one build — `cargo docs`, aliased in
+      .cargo/config.toml, with rustdocflags = -D warnings making a
+      prose-to-code link that no longer resolves a build failure. mdBook was
+      rejected: a second tool outside the rust-toolchain.toml pin, and it
+      cannot check the links that rot
+- [x] Four entries in this file claimed work was undone that was done, which
+      before a 1.0 is worse than no entry at all: two were plain staleness
+      (the reserved-TLD list, which jacquard enforces and always has, and the
+      `?` promotion, which moved *into* `gather` rather than being aligned
+      the way its entry proposed), and two were damage from resolving rebase
+      conflicts in this file — one entry kept a `[ ]` box over a body
+      rewritten to describe the finished work, and one survived twice, once
+      in each state. Found by reading every `- [ ]` back against the code
+      rather than by trusting the boxes
diff --git a/plan/doctor.md b/plan/doctor.md
new file mode 100644
index 0000000..3ef4a38
--- /dev/null
+++ b/plan/doctor.md
@@ -0,0 +1,111 @@
+---
+id: doctor
+title: Whether the next command can work is one question
+status: open
+repos: [atgc]
+dependsOn: [sign-in, keys]
+exitCriterion: >
+  Every setup failure atgc can produce is explained by `doctor local` or
+  `doctor remote`, with a command to run beside it.
+---
+
+# doctor
+
+A diagnosis used to take seven commands, because every fact was computed by the
+command that needed it at the moment it needed it. `doctor` is assembly rather
+than new logic: it asks all of them at once and prints a row each.
+
+The split is the vocabulary git spent thirty years teaching. `local` is whether
+*you* are set up and every bad row hands you a command; `remote` is whether
+*they* are up and no row is about you. Each service is asked the question
+atgc's own use of it depends on rather than a uniform health probe — Tangled's
+appview implements `_health` in its source and the deployed build 404s it, so a
+uniform probe would report the same 404 for every service in every state.
+
+## What it needs
+
+- [ ] The measure is account-scoped, not service-wide: it proves only that
+      Bobbin has caught up to *your* last write, so an account that has not
+      opened a pull request in a fortnight learns nothing about the fortnight
+      since. The row prints that evidence's age rather than hiding it. A
+      service-wide measure wants a newest-indexed-record timestamp, and
+      `sh.tangled.search.query` still ranks by relevance with no way to ask
+      for the newest — `since`/`until` filter server-side but the page that
+      comes back is the most *relevant* within the window, so its newest hit
+      is a lower bound on freshness and not a measure of lag
+- [ ] `doctor`'s knot row asks the knot named on the repo record, so a repo
+      Bobbin has not indexed reads `n/a` — the record is addressed by an
+      owner and name only the index can supply. The redirect probe in
+      `clients/tangled/resolve.rs` already lands on the knot and could hand
+      it back, which would make that row independent of the index
+
+## Done
+
+- [x] `status tangled` — whether Tangled's services are answering, and what
+      they are running. The other half of `doctor`, which asks whether *you*
+      are set up and hands you a command per bad row; this asks whether
+      *they* are up, so no row is about you and none has a remedy
+- [x] Both halves under one word, as `doctor local` and `doctor remote`. The
+      pair was two top-level commands answering one question — "can what I am
+      about to do work" — from the two ends of it, and only the first word
+      said which end. `local`/`remote` is the vocabulary a reader arrives
+      with, git having spent thirty years teaching it. `doctor` is a container
+      now and runs nothing itself, which is how every other family here works;
+      being both a verb and a parent would have made it the only one
+- [x] One knot probe, and it is `remote`'s. Whether a knot answers is a fact
+      about the knot, so `local` names the knot its origin resolves to and
+      stops there — the row that judged it was never local. The `remote` row
+      also survives an index outage, which the one it replaced could not: it
+      reads the knot out of the repo's DID document rather than off the repo
+      record, whose address only Bobbin can supply
+- [x] `doctor`'s `remote` row renamed `origin`. One word cannot mean both the
+      git remote a row is about and the end of the wire a sibling command is
+      about, least of all in a tool whose readers arrive knowing the first
+      sense. `origin` is the more honest name anyway: the row is about that
+      one remote and says so on every line
+- [x] Each service asked the question atgc's own use of it depends on — the
+      appview's site, Bobbin's search, `sh.tangled.knot.version`, the PDS's
+      `_health` — rather than one health endpoint for all four. Tangled's
+      appview implements `_health` in its source and the deployed build
+      404s it, so a uniform probe would have reported the same 404 for every
+      service in every state; and a green row now means the thing atgc
+      actually needs works. The knot answers with its version and the
+      protocol capabilities it declares, which nothing else in atgc prints
+- [x] The knot for that row comes from the repo's own DID document rather
+      than from its `sh.tangled.repo` record, which is where `doctor` reads
+      it. That record sits in the owner's PDS at an address only Bobbin can
+      supply, so `doctor`'s route goes dark exactly when the index does —
+      and an index outage is one of the things somebody runs this to confirm
+- [x] One lag row, and it is `remote`'s — the same rule as the knot probe,
+      applied to the other fact about somebody else's service. `local` loses
+      its `index` row and `remote` gains one between `bobbin` and `knot`: the
+      other half of "is Bobbin working", and the failure that is silent rather
+      than loud, since a stalled ingest keeps the `bobbin` row green while it
+      answers about a world hours old
+- [x] Widened to the account in the move, which the move requires rather than
+      being a bonus taken along the way: the repo-scoped measure reads `n/a`
+      outside a checkout, and that is most of where `remote` is run. Bobbin's
+      `listPullsBy` for your DID against your own pull records — the pair
+      `pr list --all` already merges — with `lag_seconds` in `--json` for
+      something that alerts on a threshold
+- [x] It ignores the `--source bobbin` opt-in the old row honoured. `local`
+      answers as the rest of atgc would, so an index nothing reads cannot make
+      an answer wrong; in `remote` every row is about the service and none is
+      about your configuration, which is also the question somebody arrives
+      with — whether opting in is worth it, or why yesterday's run was wrong
+- [x] `doctor` — eight checks in one report: who else on this machine can
+      read the stored credentials, the acting account and its
+      session, the scopes its login predates, whether a push from this
+      machine can be authenticated, this checkout's git identity, which repo
+      `origin` resolves to and from which directory, whether that repo's knot
+      answers, and how far Bobbin trails your PDS. Assembly, not new logic:
+      every fact was already computed by the command that needed it, at the
+      moment it needed it, which is why a diagnosis took seven commands. No
+      row is a precondition for the report — outside a checkout the
+      repo-scoped rows are `n/a` and the rest still answer, and logged out it
+      is the other way round. Exits on the first *broken* row (a warning is
+      not one), carrying the status the command that row stands for would
+      have exited with, so no new exit code was needed. The first command
+      here to overlap independent network reads rather than awaiting them in
+      a row, which is what keeps a machine that can reach nothing to one
+      connect timeout instead of six
diff --git a/plan/http-bounds.md b/plan/http-bounds.md
new file mode 100644
index 0000000..9f3ee51
--- /dev/null
+++ b/plan/http-bounds.md
@@ -0,0 +1,111 @@
+---
+id: http-bounds
+title: No stranger's response costs atgc unbounded time or memory
+status: shipped
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  Every response atgc reads is bounded in bytes as it arrives and in the gap
+  between bytes, and a refusal names the host and the limit.
+---
+
+# http-bounds
+
+The hosts atgc reads from are not atgc's. A `did:web` in a pull URI names a
+hostname the author chose; a `did:plc` document's `serviceEndpoint` is equally
+theirs. So `atgc pr view at://did:web:evil.example/…` fetches that host's
+`.well-known/did.json` and then that host's `getRecord` response, and for a
+long time read whatever they sent.
+
+Bounds are enforced against bytes actually received, never against a header the
+sender controls or a `size` the record's author wrote: a hostile pull could
+declare `size: 100` and stream gigabytes. Measured against the flood, peak RSS
+went from 143 MB to 33 MB.
+
+One hole is left and is stated rather than papered over: a body read through
+jacquard's transport is buffered whole before atgc sees it.
+
+## What it needs
+
+- [ ] **A body read through jacquard's transport is buffered whole before
+      atgc sees it**, so `http::MAX_BODY` cannot be enforced as it arrives on
+      any authenticated request — every PDS write atgc makes, plus `atgc
+      api`'s `--host pds` arm. `HttpClient::send_http`'s whole contract is
+      `http::Response>`, and its associated error type is
+      `reqwest::Error`, which there is no way to build an `anyhow` refusal
+      out of; bounding it means either a fork of that trait's impl or an
+      upstream change. `http::refuse_if_oversize` closes the visible half —
+      an over-limit body is not handed to a caller or printed, and `api`'s
+      two arms answer identically — and does not stop the memory being
+      spent. That is the honest state of it and the reason this entry exists
+      rather than the cap quietly having an exception in it
+
+## Done
+
+- [x] One HTTP client constructor (src/clients/http.rs) with a 5s connect timeout and
+      a 30s read timeout, behind every request atgc makes. `reqwest`'s
+      defaults have no deadline at any layer, so a host that drops packets
+      rather than refusing them was bounded only by the local kernel's SYN
+      retry schedule. Read timeout rather than a total one on purpose: the
+      same client carries `pr create`'s patch blob, and capping a transfer by
+      total duration is a hidden ceiling on payload size
+- [x] Upload retries were classified by substring-matching the error's prose.
+      `images.rs`'s `transient` read any message containing `404` as a
+      permanent client error and everything else as worth retrying, so a byte
+      count, a port or a CID fragment carrying those digits abandoned an
+      upload, while a real 403 whose message did not spell them was tried
+      three times — `exit.rs`'s opening complaint, applied to a retry
+      decision. It now reads `AgentError::client_error()`'s typed status,
+      which covers all three shapes `process_response` produces: a status
+      other than 400 or 401 becomes `ClientErrorKind::Http`, a transport
+      failure becomes `Transport` with no status and is the one statusless
+      case worth retrying, and 400 and 401 are let through as the endpoint's
+      typed error with no `ClientError` attached, both permanent
+- [x] `Retry-After` was read nowhere in the crate, so a 429 was retried on the
+      fixed doubling schedule whatever the server asked for. The retry policy
+      now lives in `clients/http.rs` for the whole crate, and reads both
+      spellings the spec allows — delta-seconds and an HTTP-date — clamped to
+      `MAX_RETRY_AFTER`, thirty seconds, so a server asking for an hour cannot
+      hang a CLI. The blob upload is the one caller that still cannot see the
+      header: it goes through jacquard's `upload_blob`, and neither
+      `ClientError` nor `AgentError` keeps the response headers, so the value
+      is gone before atgc sees the failure. It runs the same budget, schedule
+      and status table as everything else, minus the header
+- [x] `pds::blob_bounded`'s Content-Length check was the whole bound, despite
+      its own comment calling it that — chunked transfer-encoding and HTTP/2
+      can both omit that header, and a pull's declared `size` is only ever
+      the author's own claim, written to their own PDS with whatever tooling
+      they like, never checked against what the CID actually serves. A
+      hostile pull could declare `size: 100` and stream gigabytes past `pr
+      diff`, `pr view` and `stack merge` alike, since `fetch_patch` and
+      `latest_round_patch` both trust the record before ever calling this.
+      Chunks are now counted as they arrive too, and reading stops the
+      instant the running total passes `max_bytes` — a bound enforced
+      against bytes actually received rather than a header the sender
+      controls
+- [x] **Every JSON response atgc reads is unbounded.** `resp.text()` or
+      `resp.json()` with no size cap, at ten sites across `atproto/pds.rs`,
+      `atproto/did.rs`, `tangled/knot.rs`, `web/pulls.rs` and the `repo`
+      family. `clients/http.rs` bounds time only — its own doc says
+      `READ_TIMEOUT` caps the gap *between* bytes of a response — so a server
+      that streams steadily never trips it, and `pds.rs`'s claim of "the
+      bounded request through `crate::clients::http`" is true of latency and
+      false of size. The hosts are not atgc's: a `did:web` in a pull URI
+      names a hostname the author chose, and a `did:plc` document's
+      `serviceEndpoint` is equally theirs, so `atgc pr view
+      at://did:web:evil.example/…` fetches `.well-known/did.json` from them
+      and reads whatever they send, then reads that host's `getRecord`
+      response the same way. Same reach through `pr diff`, `pr list` and
+      `repo clone /` for any repo whose owner or pull author is
+      hostile. The blob path was hardened for exactly this and has
+      `blob_bounded` and a `RunningTotal`, with a doc noting a
+      `Content-Length` is only as trustworthy as its sender; the JSON path
+      never got it. Now it does: `http::MAX_BODY` is 8 MiB and fixed,
+      `text_bounded`/`bytes_bounded` share the one streaming loop that
+      `blob_bounded` now calls rather than owning, and eleven call sites go
+      through it. A refusal names the host and the limit, hands back none of
+      what it read, and exits `6` — the status this module's other two
+      bounds already carry. Measured against the flood: peak RSS 143 MB
+      before, 33 MB after. `bobbin.rs:79` and `web/backfill.rs:147` are the
+      two reads still unbounded; another branch owned both files at the
+      time, and converting them is a two-line change each
diff --git a/plan/identity.md b/plan/identity.md
new file mode 100644
index 0000000..e1a9781
--- /dev/null
+++ b/plan/identity.md
@@ -0,0 +1,128 @@
+---
+id: identity
+title: One answer to what an identifier is, and who it names
+status: shipped
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  Every place that takes an account — a flag, a variable, a checkout's git
+  identity, a record's authority — routes through one classifier and one resolver.
+---
+
+# identity
+
+A handle, a DID, an email address and a typo all arrive as strings. Deciding
+which is which used to be a `did:` prefix test in one place and a `split('/')`
+in another, and the failures were quiet: an ordinary git email posted to a
+public handle resolver with the address in the query string, an environment
+variable naming nobody producing the advice for a variable naming somebody
+else.
+
+There is one classifier now (`src/lexicon/identity.rs`), the atproto grammar
+comes from jacquard rather than being restated here, and a handle resolves the
+four ways the spec defines rather than by asking Bluesky. The direction that is
+cached is DID to handle and only that — a handle is a hint somebody else can
+re-register, so seconds is the whole safe window.
+
+## What it needs
+
+- [ ] The commands that then fail do not all fail the same way. `key list`
+      and `auth token` name the variable and quote the classifier; `pr list`
+      falls back to reading no PDS and reports "no pull requests found", and
+      `issue list` says "not logged in, and no --author given". Both of those
+      are true and neither mentions `ATGC_ACCOUNT`, which is the thing to
+      change. The advice above is a warning at `auth switch`/`auth login`
+      time, not a fix for the commands themselves
+- [ ] Three fetchers for one DID document: `clients::atproto::did::did_doc`,
+      `cmd::repo::read::owner_doc` and jacquard's own `resolve_did_doc`. The
+      first two are now typed the same and differ only in whether a failure
+      is an `Option` or a `Result` with a sentence attached. Collapsing them
+      onto jacquard's resolver — which also validates that the document's
+      `id` matches the DID asked for, and which nothing here does — is the
+      next step and wants its own change
+
+## Done
+
+- [x] `auth login` resolves handles DNS-first, as the handle spec orders it.
+      jacquard's resolver has both steps in the right order but
+      `JacquardResolver::new` — which `OAuthClient::new` calls — leaves its
+      DNS field `None`, and `dns_txt` then returns an empty vector rather
+      than an error, so the step was a silent no-op and every handle went to
+      `https:///.well-known/atproto-did`. Found on a handle with a
+      good TXT record whose A record pointed at a parked IP that drops
+      packets: one DNS query away from resolving, 30s of stall on every login
+- [x] One identifier classifier (src/lexicon/identity.rs) behind every place an
+      account is named — `--account`, ATGC_ACCOUNT, `auth login/switch/
+      logout`, `repo list `, `pr status --author`, and a checkout's
+      `user.email`. DID syntax per the atproto spec rather than a `did:`
+      prefix test, `did:plc`'s 24 base32 characters, hostname-only
+      `did:web`, and handles by the handle spec. An ordinary git email is
+      named as one and refused locally instead of being sent to a public
+      handle resolver with the address in the query string
+- [x] DID methods parsed as a variable, not hardcoded: an unrecognized
+      method is still a valid DID and can still select an account (refusing
+      it would fall through to somebody else's), and only fetching its
+      document refuses, naming the method. `did:web` gained its first test
+      coverage and a real document fixture
+- [x] The disallowed-TLD list from the handle spec (.local, .arpa,
+      .invalid, .internal, .localhost, .example, .alt, .onion) is enforced,
+      and was already: `jacquard::types::string::Handle` refuses one, which
+      is why `explain_handle` carries a `DISALLOWED_TLDS` branch whose only
+      job is to *name* the reserved TLD — it exists precisely because that
+      is the one refusal `domain` knows nothing about. Verified:
+      `ATGC_ACCOUNT=a.localhost` is refused with ".localhost is reserved and
+      cannot be a handle".
+
+      The entry's own worry is the live behaviour, not a hypothetical:
+      "enforcing it would break anyone pointed at a local test PDS" is what
+      happens today. If that ever costs somebody something, the fix is an
+      escape hatch, not the enforcement — but nobody has asked
+- [x] `account::names_account` prefix-tested for `did:` and answered a
+      boolean, so a value the classifier rejects and a value naming somebody
+      else both came back `false` and both produced `EnvWins` — "something
+      else outranks you" — when only one of them is true. As the entry said,
+      routing it through `classify` alone changes no outcome; the fix is the
+      third answer. `classify_env` returns `Names` / `NamesAnother` /
+      `NamesNothing`, and `Advice::EnvUnusable` is the new variant:
+      "ATGC_ACCOUNT=me@example.com names no account, so nothing that needs
+      one will work until it is changed", with "or unset it" as a remedy that
+      is real here and nowhere else in this function. Two lines, for
+      `RepoWins`' reason. The classification is `lexicon::identity::classify`
+      so that what counts as an identifier here is what `resolve_spec` will
+      decide a moment later
+- [x] `auth switch` and `auth logout` named their positional field
+      `account`, which clap resolves to the same argument id as the global
+      `--account` selector — so `--account` silently landed in the
+      positional instead of choosing who to act as, and
+      `atgc auth logout --account ` failed to parse at all. Same bug
+      class already fixed once, for `repo list` and `key list`: renamed both
+      fields to `owner`, carrying the same explanatory comment
+- [x] The atproto vocabulary comes from jacquard rather than being restated.
+      `Did`/`Handle` carry the grammar and `identity.rs` explains a refusal in
+      atgc's own words afterwards; `AtUri` decides whether a string is a
+      record URI, which the old `split('/')` could not — it knew nothing about
+      NSIDs; `DidDocument` replaces indexing into a `serde_json::Value` by
+      hand. Breaking: the handle spec's reserved TLDs (`.local`, `.internal`,
+      …) are refused now, where this module deliberately allowed them. Such a
+      handle was never resolvable — the list exists because those names are
+      not globally unique — so what changed is where the refusal happens
+- [x] A handle resolves the four ways the spec defines — DNS TXT, well-known,
+      the account's PDS, then a public appview — instead of only asking
+      `public.api.bsky.app`. A self-hosted account whose appview has never
+      indexed it did not resolve here at all, and every handle anyone named
+      went to a third party. `jacquard::identity` was already compiled in:
+      `dns` is in jacquard's default features, so atgc had been shipping
+      `hickory-resolver` and never calling it. Demonstrated on this project's
+      own handle, which now resolves by DNS with zero requests to Bluesky
+- [x] One DID-to-handle resolver, `clients::atproto::handles`, in front of a
+      memo that lives exactly as long as the process. Four listings had their
+      own `buffer_unordered` over `handle_from_did_doc` — `pr list`, `issue
+      list`, `search` and a `view`'s comment thread — differing in the parts
+      nobody meant them to differ in and agreeing that each would re-fetch a
+      document the same invocation had already read. What is cached is only
+      DID to handle, only successes, only up to a cap, and never to disk: a
+      handle is a hint that can be re-registered by somebody else, so seconds
+      is the whole safe window. The other direction is not cached at all —
+      `resolve_handle` decides which account atgc acts as, and `repo
+      checkout` keeps asking the document itself because that handle is
+      written into a checkout's config
diff --git a/plan/indexes.md b/plan/indexes.md
new file mode 100644
index 0000000..6fa2504
--- /dev/null
+++ b/plan/indexes.md
@@ -0,0 +1,126 @@
+---
+id: indexes
+title: An answer says which index it came from, and how far behind that index is
+status: open
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  No listing presents an index's answer with the confidence of a PDS read,
+  and every index is opted into rather than assumed.
+---
+
+# indexes
+
+Bobbin is Tangled's index and it stalls chronically and service-wide — three
+outages in the five days to 2026-08-09, one of them about 29 hours. The failure
+that matters is not the outage: it is that a stalled index answers about a
+world hours old in the shape and with the confidence of a fresh answer. That
+produced a merged pull reported open, sixteen pulls reported as six, and three
+open pulls reported as branches with no pull.
+
+So the default is the PDS alone, and `--source` is a set you opt into. Your own
+records on your own repo are not half an answer — Bobbin honours a status
+record from the pull's author or the target repo's DID, and on your own repo
+you are both — so an index that disagrees there is behind, not better informed.
+
+The one deliberate exception is the refusal path: `pr merge`, `stack merge` and
+`stack resubmit` read every source, because a source that sees less cannot
+refuse more, and a stale row there costs an unnecessary refusal rather than a
+merge that should not have happened.
+
+## What it needs
+
+- [ ] **Delete `clients/tangled/web/backfill.rs`** — it is a scrape of HTML that core#707 says
+      will be replaced wholesale by the Svelte frontend, papering over
+      Bobbin ingest stalls (3 service-wide in the 5 days to 2026-08-09,
+      one ~29h; no upstream issue filed yet) that Tangled is actively
+      working on. When Bobbin's listings stop stalling — or the moment the
+      new frontend breaks the scrape — remove the module, the `gather`
+      block marked HACK in pr/read.rs, and the pub(crate) escapes on
+      review.rs's listing_url/numbered_titles/pull_uri_from_appview
+- [ ] Pull state read from one PDS can only ever be the account's own half of
+      it. Bobbin accepts a status record from the pull's author or from the
+      target repo's DID; the repo's records are not public (its DID document
+      lists a knot service and no PDS, and the knot serves no record
+      endpoints), so a merge performed by somebody else is invisible until
+      Bobbin catches up. Such pulls print state `?` rather than a guess
+- [ ] Bobbin does not hand back the issue record it indexed, and for one
+      collection it does not hand back a record at all. `listIssuesBy` rewrites
+      a pre-DID record's `repo` at-uri to the repo DID and drops the record's
+      own `repoDid`, under the record's real CID; `feed.listComments` rewrites
+      a legacy comment further — the body lifted into an object, the bare
+      `issue` at-uri turned into a `subject` strongRef whose `cid` is
+      `bafkqaaa`, the CID of no bytes — while leaving `$type` naming the
+      legacy collection. Both are pinned by paired captures of the same
+      records off both services. The pull half is not like this
+      (`the_indexed_copy_of_a_pull_is_the_same_record`), so `cmd::pr::read`'s
+      merge and `cmd::issue::read`'s merge do not rest on the same assumption
+      even though they are the same shape of code
+
+## Done
+
+- [x] `pr list` — your own pulls straight from your PDS merged with Bobbin's
+      sh.tangled.repo.listPulls (api.tangled.org, ATGC_BOBBIN to override);
+      --state/--limit/--remote and --source auto|pds|bobbin; tolerates old
+      record shapes. Works with Bobbin down, and says specifically what
+      Bobbin is missing when the two disagree
+- [x] **both indexes are opt-in and the default is the PDS alone.**
+      `--source` is now a *set* — pds (default), bobbin, web, auto, or names
+      joined by commas — with ATGC_USE_BOBBIN=1 / ATGC_USE_WEB=1 adding one
+      to the default. pds/bobbin/auto keep their old meanings, so no spelling
+      that worked before means something different now; what moved is what
+      you get by saying nothing. Forced by the failure mode rather than by
+      the stall rate: a stalled index answers about a world hours old in the
+      shape and with the confidence of a fresh answer, which produced a
+      merged pull reported open, 16 pulls reported as 6, and 3 open pulls
+      reported as branches with no pull. The cost is real — a repo listing is
+      author-scoped by default and says so — and is the smaller of the two
+- [x] the branch → pull resolution behind `pr diff`/`pr checkout` was pure
+      Bobbin with no PDS side at all, which is where "no pull request found
+      for branch X" came from for pulls that were open and one listRecords
+      away. All four branch-inferring commands now share
+      `read::branch_pull_record`
+- [x] the index-only reads honour the same opt-in: `search` refuses (it is
+      the one question with no second source even in principle), `repo view`
+      skips the star count onto the zero it already treats as unknown, and
+      two `doctor` rows read n/a naming the opt-in
+- [x] the exception, deliberate: `stack resubmit`, `stack merge` and
+      `pr merge` read a listing to find a reason to *refuse*, and there a
+      source that sees less cannot refuse more. They keep every source, as
+      `Source::EVERY`. A stale row costs an unnecessary refusal, never a
+      merge that should not have happened
+- [x] `pr list` — other contributors' pulls still come only from Bobbin, and
+      nothing in the stack offers a second *API*: every pull query in the
+      monorepo is a Bobbin route, and the knot mirror serves only git
+      objects. Worked around, not closed: when the merge proves Bobbin's
+      listing stale (or Bobbin is unreachable), `clients/tangled/web/backfill.rs` scrapes the
+      repo's tangled.org pull listings — a separate index that has stayed
+      current through every Bobbin stall observed — and fetches the missing
+      records from their authors' PDSes. Repo scope only, budgeted, and
+      announced in the staleness warning when it fires. Now reachable as
+      `--source web` in its own right rather than only inside `auto`; beside
+      Bobbin it still runs on evidence, and without Bobbin it is the only
+      cross-account source there is, so it runs unconditionally
+- [x] state `?` is now settled from the web index when it can be: the
+      backfill walk hands back the states it proves in passing (free), and
+      `pr view` runs a targeted title-narrowed, uri-confirmed lookup for
+      its one pull (bounded at 4 candidate pages, auto source only). Both
+      probe runs showed agents hedging open-vs-closed on `?` or fetching
+      the pull page by hand; now atgc does the honest version itself
+- [x] On your own repo it is not half of it — you are both authorities Bobbin
+      honours, so your records are the whole answer and an index that
+      disagrees is behind, not better informed. `pr list` printed `open` over
+      two pulls this account had closed; it now keeps its own status records
+      there, and reads them even when Bobbin answered for every pull
+- [x] The status walk is bounded by recency, not by a page count. A status
+      record cannot predate the pull it names and the listing is newest
+      first, so it stops once a page ends below the oldest pull on screen —
+      a page or two at any collection size. It read a fixed five pages
+      before, and past 500 status records dropped the oldest and warned, on
+      every `pr` command, that some states might read `?`
+- [x] The pull walk splits the same way. A screenful keeps the early stop and
+      the five-page cap; a caller concluding something from absence gets
+      neither. Past 500 pull records `stack create` refused outright on any
+      repo holding fewer than 100 of the newest 500, and — worse, because it
+      was silent — a repo with 100+ satisfied the early stop partway down the
+      collection and reported a *complete* listing missing everything older
diff --git a/plan/issues.md b/plan/issues.md
new file mode 100644
index 0000000..b2db079
--- /dev/null
+++ b/plan/issues.md
@@ -0,0 +1,206 @@
+---
+id: issues
+title: An issue is filed, found and closed from here
+status: open
+repos: [atgc]
+dependsOn: [sign-in]
+exitCriterion: >
+  File, list, read, comment on and close an issue on any repo, whoever owns
+  it, without a browser.
+---
+
+# issues
+
+An issue is a `sh.tangled.repo.issue` record in the PDS of whoever *filed* it,
+naming its repo by that repo's own DID, with its state in a separate
+newest-wins log of `sh.tangled.repo.issue.state` records. A pull request's
+shape minus the patches, so `cmd/issue/` is a read/write split from the start
+and borrows `pr`'s conventions rather than inventing new ones beside them.
+
+The lexicon and the appview disagree about one field and the appview wins:
+`body` is optional in the schema and refused when empty by the ingester, which
+drops the record without retry. atgc refuses at the narrower of the two, before
+the write.
+
+## What it needs
+
+- [ ] No issue *numbers*. The number is the appview's own id, in no record
+      written since repos got DIDs and in no XRPC response — the pre-DID shape
+      did carry it, as an `issueId` beside an `owner`, which
+      `tests/fixtures/issue_old_record.json` is a captured example of; that
+      spelling is gone and nothing indexes it. Exactly like a pull's, then;
+      atgc resolves a pull's by scraping the page, and the entry above about
+      that scrape says it is a bet on somebody else's HTML that has already
+      been seen to fail. Making a second bet of the same kind to ship a first
+      cut was not worth it, so `issue view 23` refuses and says why. Every
+      command takes the record key or the at:// URI instead
+- [ ] Residual, after the index landed: a state still reads `?` when Bobbin
+      has not indexed the issue and the author does not own the repo
+      that is most of them: a maintainer's close is a record in the
+      maintainer's PDS. Absence is read as `open` only when the issue's author
+      also owns the repo, and even then a *collaborator's* close is invisible
+      — the same gap the `pr close` entry above records, needing the same
+      knot ACL query to shut
+- [ ] No `mentions` or `references`, which the web UI sets and which ride
+      through an edit untouched in the generated `extra_data`. No labels
+      either — `sh.tangled.label.op` is unimplemented for every subject
+- [ ] The three writers left out of that are the ones whose record cannot be
+      complete before the network: `pr create`, `pr resubmit` and `stack
+      create` need a patch blob's CID, and `repo create` needs the `repoDid`
+      the knot mints. Validating a stand-in record would check the fields
+      the caller controls, which is most of them, at the cost of a shape
+      that is not the one sent. Worth it or not is an open question
+- [ ] `names_repo` compares `repo` to the repo DID exactly, so a repo-scoped
+      `issue list` reading only the PDS drops every pre-DID issue the account
+      filed against that repo — six of ten rows survive on the captured page,
+      and the index shows nine of the same ten because it rewrote them first.
+      So `--source bobbin` lists issues that the PDS half of the same command
+      cannot, on records the account itself wrote. Matching the at-uri too
+      means resolving it, and it names the repo *record*, whose key is not the
+      repo name and whose authority is the owner, not the repo — one lookup
+      per distinct at-uri, cached, is the shape of the fix. Left recorded
+      rather than done: the rows are wrong by omission today and would be
+      wrong by a network round trip tomorrow, and which is worse is a
+      judgement worth making deliberately
+
+## Done
+
+- [x] Vendored `sh.tangled.repo.issue`, `.issue.state` and `.issue.comment`
+      into `lexicons/` and regenerated the bindings, which moved the pinned
+      core commit from 74ce1a0 to 7ccb643. The six lexicons atgc already
+      writes are byte-identical across that range, checked file by file
+- [x] `issue create` — a record in your own PDS naming the repo by its DID,
+      so it needs no permission on the repo, exactly as `pr create` does not.
+      `--body-file` (and `-` for stdin) rather than an `$EDITOR` spawn
+- [x] A body is *required*, though `issue.json` marks it optional. The
+      appview's `Issue.Validate` refuses `issue body is empty` and its
+      ingester logs `failed to ingest record, dropping it without retry`, so
+      a title-only issue is accepted by the PDS, federates, and never appears
+      — the same silent shape as a comment in the deprecated collection,
+      arrived at from the other direction. `issue create` refuses it before
+      the write and `issue edit` will not empty one, since an update the
+      appview drops leaves it showing a paragraph the PDS no longer holds.
+      Pulls are exempt from the equivalent check when the patch is a
+      format-patch, which is why `pr create` needs no such rule
+- [x] `issue view` — by record key or at:// URI, read live from the author's
+      PDS. No branch fallback: an issue record names no branch, so there
+      would be nothing to guess from
+- [x] `issue list` — one account's issues off that account's PDS, scoped to
+      the checkout's repo unless `--all`, anybody's with `--author`, and no
+      session needed for either. Says on stderr every time that it is
+      author-scoped, so its rows cannot be read as the repo's
+- [x] `issue close` / `issue reopen` — an append of a TID-keyed state record
+      into the *acting* account's PDS, never a put or a delete, so a repo
+      owner can close an issue filed by somebody they cannot write to.
+      Refuses for an account that is neither the author nor the repo owner,
+      since the appview drops such a record after a knot ACL check
+- [x] `issue edit` — title and/or body of your own issue, via put_record with
+      the CID as `swapRecord`. Author-only, unlike close/reopen: the record
+      is in the author's PDS
+- [x] `issue comment` — **not** `sh.tangled.repo.issue.comment`, which is
+      deprecated and whose creates the appview ingests as a no-op; the live
+      record is `sh.tangled.feed.comment` with a `subject` strongRef carrying
+      the issue's CID. `pullRoundIdx` is left unset — the appview requires it
+      only when the subject is a pull. No standing check: `ingestComment`
+      performs no ACL lookup, so anyone may comment
+- [x] Every refusal in the family carries an exit status rather than the
+      unclassified `1` a `bail!` gives: `2` for a command line that names no
+      issue or no body, `3` for a bare record key with no session and no
+      `--author`, `4` for a close or an edit of somebody else's record. The
+      numbers are docs/output.md's, and `tests/support`'s `refused_with`
+      pins them — plain `refused` passes against every status, so a site
+      that loses its classification is invisible to it
+- [x] Cross-author listing: "every issue on this repo, whoever filed it".
+      Bobbin turns out to serve `sh.tangled.repo.listIssues` and
+      `listIssuesBy` — the same `subject` parameter, the same
+      `{uri, cid, value}` envelope and the same `state` and `commentCount`
+      beside it as `listPulls` — so the flag that "would describe a decision
+      nobody gets to make" is a real one now. `issue list --source` is
+      `pr list --source`'s, spelled the same and traded the same way: opt-in,
+      because the index is alpha and stalls. `Listed` and `State` were indeed
+      already the right shapes; what they gained is `comments`, `indexed` and
+      a per-row author, the last because a repo-scoped page spans accounts
+      and the subject's handle stopped being every row's
+- [x] Comments are counted. `commentCount` rides in the index's listing
+      envelope, so `issue list --json` has a `comments` field and the number
+      is real rather than null-on-every-row. Still not *shown*: reading the
+      comments themselves is a different job, a comment being a record in its
+      commenter's PDS
+- [x] A state nothing could settle read `?` on anything but your own repo,
+      which was most of them. The index carries the appview's own answer, so
+      a maintainer's close now settles a row that no read of the author's PDS
+      could see. Own records still outrank it: they came from this account's
+      own PDS and cannot be stale, so the index fills gaps rather than
+      overruling. `IssueState::from_label` reads the envelope's bare word
+      where `from_token` reads a record's whole NSID, and an unrecognised
+      word leaves the row unsettled and listable rather than crashing
+- [x] Comments are written and now read. Both halves were the cross-author
+      problem — a comment is a record in its commenter's PDS and nothing
+      enumerates who has commented — and both have the same answer:
+      `sh.tangled.feed.listComments`, which takes the *subject's* at-uri and
+      so serves a pull and an issue with one function. `issue view
+      --comments` and `pr view --comments` print the discussion, oldest
+      first, and `--json` carries it as `thread` (not `comments`, which on a
+      pull is already the count). It is an index read, so it is opt-in and
+      says what it is: "as far as Bobbin has indexed".
+
+      Two collections are read, not one. Tangled unified comments into
+      `sh.tangled.feed.comment` and the old `sh.tangled.repo.issue.comment`
+      is deprecated *for writing*; the index returns whatever was written, so
+      a thread older than the unification is mostly legacy records and a
+      reader that knew only the current shape would show it as empty. They
+      also spell their body differently — `body.text` against `body` — which
+      is what `Comment::read` hides
+- [x] No image blobs in issue bodies, though the lexicon has `blobs` and
+      `pr create` already knew how to upload them. `issue create`, `issue
+      edit` and `issue comment` now take the same path as their `pr` twins:
+      scanned before the network so a missing file refuses by name, uploaded
+      only once the write is really going to happen so a dry run leaves no
+      orphan blob, and the body rewritten to `blob+at://` so a later reader
+      and a later edit both see the spelling that still means something.
+      `issue edit` merges the blob list rather than replacing it, since an
+      earlier edit's images are still referenced by the parts of the body
+      this one did not touch, and treats an unchanged body naming a local
+      path as a change, because sending it is what uploads the file — the
+      clause `pr edit`'s `body_edit_is_a_change` already documents.
+      `cmd/pr/images.rs` moved to `cmd/images.rs` on the way: `stack/write.rs`
+      was already reaching it as `crate::cmd::pr::images`, which is the
+      sideways import rule 2 exists to stop
+- [x] `settle_states` walked the whole `sh.tangled.repo.issue.state`
+      collection once per row, and twice per row when the acting account was
+      not the author: thirty issues was thirty to sixty `listRecords` calls
+      where one walk would do, over pages that were very often the same
+      bytes. `state_events_for` now takes the whole page and answers a map
+      keyed by issue at-uri, so a listing is one walk of the author's PDS and
+      at most a second of the acting account's. The early stop compares
+      against the *oldest* key asked about rather than each row's, which is
+      the one thing the grouping had to change; `state_events` stays as the
+      one-issue wrapper `issue view` calls. The ownership lookup beside it
+      was already cached, and for the same reason
+- [x] `--state` is a clap `ValueEnum` on all three listings that take it —
+      `issue list`, `pr list`, `pr status` — so an unknown word is a refusal
+      naming the accepted ones (exit 2, clap's own) instead of an empty
+      listing. `issue list --state opne` used to print `no issues for 
+      on `, which does not even echo the word; `pr list --state opne`
+      printed "no opne pull requests". Both read as *there are none*, which
+      is the stale-index failure this tree opted out of, arriving from the
+      command line instead. `--help` gained the possible values as a side
+      effect. Input side only: what a record or an index *says* a state is
+      stays an untyped string, so a state Tangled adds after this build still
+      lists rather than crashing
+- [x] `issue edit --dry-run` reported what it would write without running the
+      record past `LexiconSchema::validate`, so a dry run could say "would
+      work" for a record the lexicon refuses. It was not two commands but
+      four — `issue create`, `issue edit`, `key add` and `repo edit` all
+      built the record *after* the dry-run return, so `validate` could only
+      ever run on the real one. All four now build and validate above it.
+      The demonstration is `repo edit --description <201 chars> --dry-run`,
+      which used to print "dry run; nothing sent" and exit 0.
+
+      The two `issue` writers validate *twice*, which is what embedding
+      images costs: the check before the dry-run return sees the record as
+      the caller wrote it, and a second one after the upload sees the two
+      fields it could not — `body`, rewritten to `blob+at://`, and `blobs`.
+      Neither may be minted by a dry run, a blob nothing references being one
+      the PDS may collect. The second check is skipped when there were no
+      images, the record then being byte for byte the one already validated
diff --git a/plan/keys.md b/plan/keys.md
new file mode 100644
index 0000000..0cbdd89
--- /dev/null
+++ b/plan/keys.md
@@ -0,0 +1,48 @@
+---
+id: keys
+title: A push from this machine is authorized before it is attempted
+status: shipped
+repos: [atgc]
+dependsOn: [sign-in]
+exitCriterion: >
+  atgc knows which registered key this machine holds and refuses a push it
+  cannot authenticate, rather than failing at the knot.
+---
+
+# keys
+
+A knot authorizes `git push` by SSH key, registered as a `sh.tangled.publicKey`
+record in the pushing account's PDS. Nothing reads back whether a knot has
+ingested one, so the only confirmation available is a push that works — which
+is why every command that will need a key checks for one first.
+
+The scope-gap detection here is more general than keys and lives here because
+this is where it was found: a session older than a scope atgc now asks for is
+refused by the PDS as a 403 about record creation, which names neither the
+scope nor the login that would fix it.
+
+## What it needs
+
+- [ ] Tangled also writes an `ssh -T` greeting per key; nothing reads it back,
+      so there is no way to confirm from here that a knot has ingested a
+      newly added record other than trying a push
+
+## Done
+
+- [x] `key add` / `list` / `delete` — sh.tangled.publicKey records, which
+      are what a knot authorizes a push against. `add` defaults to the one
+      public key in ~/.ssh, takes a path or the key line, redirects a private
+      key path to the `.pub` beside it and refuses when there is none, and is
+      idempotent against a key already on file. `delete` takes a record key,
+      a name, a fingerprint or a path, and refuses an ambiguous one rather
+      than picking. `list` says which registered key is on this machine
+- [x] Scope-gap detection, since both `key add` and `pr comment` are refused
+      by a session older than the scope they need and the PDS says so as a
+      403 about record creation. `auth status` lists, per account, the scopes
+      atgc now asks for that the login does not carry, named by the command
+      each one costs; the three writes on those two collections refuse before
+      writing, and say `would fail:` under `--dry-run`. Found a real one: a
+      logged-in account here predates `sh.tangled.feed.comment`
+- [x] Keyed by the whole scope token rather than by collection, so an `rpc:`
+      or `include:` gap names its command too — `repo delete-branch` and
+      `report` were listed as bare NSIDs nobody could act on
diff --git a/plan/lexicons.md b/plan/lexicons.md
new file mode 100644
index 0000000..2cfded8
--- /dev/null
+++ b/plan/lexicons.md
@@ -0,0 +1,194 @@
+---
+id: lexicons
+title: Record shapes come from the schema, not from reading somebody's JSON
+status: open
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  Every record atgc writes is a generated type from a vendored lexicon,
+  validated locally before it is sent.
+---
+
+# lexicons
+
+Every `sh.tangled.*` and `app.userinput.*` schema atgc writes against is
+vendored under `lexicons/` and generated into Rust by jacquard-lexicon codegen,
+pinned to the commit or the at://-uri and CID it was fetched at. That replaced
+hand-written models, and the migration found real divergences rather than being
+a mechanical type swap — a nested `$type` on a union-collapsed object, a
+`target.repoDid` the current lexicon has dropped.
+
+The read paths deliberately stay on `serde_json::Value`, and that is a finding
+rather than a backlog item: the generated types are lexicon-*faithful*, every
+record atgc reads was written by somebody else, and the shapes in the wild
+predate the schema. Typing a read path is therefore either less tolerant than
+what is there, or exactly what is there with a discarded parse in front of it.
+
+What holds that claim honest is the fixtures: captured records, taken off live
+services, pinned against which generated type accepts them and which refuses
+them, so a regeneration against a tightened schema fails in a test rather than
+in somebody's listing.
+
+## What it needs
+
+- [ ] **The read paths stay on `serde_json::Value`, and that is a finding
+      rather than a backlog item.** The generated bindings under
+      `vendor/tangled-lexicon/` are lexicon-*faithful*: a property the schema
+      marks required is a non-`Option` field, so a record missing it fails to
+      deserialize whole rather than arriving with that one field empty. Every
+      `sh.tangled.*` record atgc reads was written by somebody else, and the
+      shapes in the wild predate the schema:
+
+      - a pre-rounds `sh.tangled.repo.pull` carries an inline `patch` and has
+        neither `rounds` nor `target`, both required, so `Pull` refuses it
+        outright — while `pr list`, `pr view`, `pr diff` and `pr merge` all
+        still work on one;
+      - a legacy `sh.tangled.repo.issue.comment` names its subject as a bare
+        `issue` at-uri and its body as a plain string, where
+        `sh.tangled.feed.comment` takes a `subject` strongRef and a markdown
+        object, so neither type reads the other's record
+        (`clients::tangled::comments` reads both shapes);
+      - `sh.tangled.repo.issue` types `repo` as a DID and the appview still
+        ingests records carrying an at-uri there, which `FetchedIssue::
+        repo_did` answers `None` for — where `Issue` would refuse the record.
+
+      So typing a read path is either *less* tolerant than what is there, or
+      exactly what is there with a discarded parse in front of it. The
+      generated types earn their keep on the write side, where atgc controls
+      the bytes and a schema-invalid record is a bug — `cmd/*/write.rs`,
+      `cmd/key.rs` and `fetch_own_pull` already use them, and `fetch_own_pull`
+      says in as many words that it is "the caller that wants the lot".
+      `lexicon::tangled`'s `generated_types_against_live_records` pins which
+      captured shapes each generated type accepts and which it refuses, so a
+      regeneration against a tightened schema fails there instead of in
+      somebody's listing
+- [ ] The generated record types do not check their own `$type` on the way
+      in. A legacy comment as the index rewrites it deserializes cleanly into
+      `FeedComment` although its `$type` still says
+      `sh.tangled.repo.issue.comment`, and reserializing it emits `$type`
+      twice — the container's tag and the one `extra_data` carried in. Both
+      are pinned in `a_legacy_issue_comment_is_not_the_record_the_index_hands
+      _back`. Nothing reads records that way today, so this is a note about
+      what a typed read path would not buy: the collection would still have to
+      be checked by hand
+
+## Done
+
+- [x] The issue family is captured now: eight files under `tests/fixtures/`,
+      taken off live PDSes and off Bobbin by unauthenticated requests, run
+      through the generated types in `lexicon::tangled` and through the read
+      path's own helpers in `cmd/issue/fixtures.rs`. What they pin, in the
+      order the prose claimed it:
+
+      - **`repo` holds an at-uri in the wild.** 268 of 339 records surveyed
+        across eight accounts do, and four of the ten on the captured page.
+        `Issue` refuses every one; `FetchedIssue::repo_did` answers `None`,
+        as its doc comment says, and now on bytes rather than on a
+        description of them.
+      - **`createdAt` is required and often absent or empty.** 99 of the 339
+        carry no usable stamp — 12 without the property at all, 87 with the
+        empty string — and 82 of 130 `.issue.state` records have none either,
+        two of them naming no issue at all. `state_event` reads them and lets
+        them lose on the sort, which was the claim.
+      - **`body` is optional and always there.** Every one of the 339 has
+        one, which is the appview's `issue body is empty` refusal seen from
+        the record side, and the reason `issue create` refuses a title-only
+        issue before the write.
+      - **The number is in a record after all.** The pre-DID shape carries
+        `issueId` beside an `owner`, neither of them ever in the lexicon.
+        Nothing written since has it, so `issue view 23` refusing still
+        stands — but "in no record" was too strong, and the captured record
+        is `issue_old_record.json`.
+      - **A close/reopen/close chain is three records seven seconds wide.**
+        Captured live; `newest_state` settles it the same way whichever order
+        the records arrive in
+- [x] jacquard-lexicon codegen from vendored sh.tangled lexicons, like Bobbin
+      does, instead of hand-written models — proven first on the
+      userinput.app board's lexicon (`.claude/report`'s branch), then
+      applied to all of `src/lexicon/tangled.rs` as a six-PR stack, lowest-risk
+      first. `lexicon/tangled.rs` now holds only NSID constants and the domain glue
+      no generated type could carry (`PullState`, `pull_target`) — every
+      record shape comes from `vendor/tangled-lexicon/`. Landed as
+      `claude/tangled-lexicon`, real divergences found and fixed along the
+      way (nested `$type` on a union-collapsed object, `target.repoDid`
+      needing `extra_data`), not just a mechanical type swap:
+  - [x] Vendor the seven lexicons in use (`sh.tangled.repo.pull`, `.pull
+        .status`, `.repo`, `.publicKey`, `.feed.comment`, `.markup.markdown`,
+        `com.atproto.repo.strongRef`) under `lexicons/`, pinned to the
+        commit they were fetched at (`lexicons.json`) — Tangled's lexicons
+        are not published as `com.atproto.lexicon.schema` records the way
+        userinput.app's are, so provenance is a git commit rather than an
+        at://-uri+CID. `vendor/tangled-lexicon/` carries the generated Rust,
+        `scripts/vendor-tangled-lexicons.sh` refreshes both
+  - [x] `PublicKey` (`src/key.rs`) — the real lexicon marks `name` required,
+        matching the hand-written struct exactly (no divergence to carry);
+        `key`'s 4096-char limit is now enforced by generated `validate()`,
+        which the hand-written version never checked at all
+  - [x] `FeedComment`/`Markdown` (`src/pr/write.rs`'s `comment()`) — one real
+        divergence found and fixed: every live comment record's nested
+        `body` carries `$type`, confirmed against this account's own
+        records, but jacquard-lexicon's codegen only tags `$type` on
+        top-level records, not on an object collapsed out of a
+        single-variant ref-union. `markdown_plain`/`markdown_with_blobs`
+        inject it by hand through `extra_data` rather than leaving it off —
+        would have been a silent wire-format regression otherwise.
+        `StrongRef` here uses the real, generated
+        `com.atproto.repo.strongRef` (required `cid`), unlike userinput.app's
+        board records: `subject.cid` is always populated from the pull's own
+        CID, so there's no absent-cid case to preserve
+  - [x] `Repo` (`src/repo.rs`) — first one with a real read-modify-write
+        (`repo edit`); the generated `extra_data` catch-all survives
+        `record::put`'s `swapRecord` (same three fixture tests as before,
+        ported and passing against the generated type). `website`/`source`
+        generate as `UriValue`, not a bare string, so `repo edit`'s existing
+        text-diffing helpers now stage through local `Option` and
+        convert at the boundary rather than being rewritten themselves.
+        `description`'s 140-grapheme cap and 1-grapheme floor (an *empty*
+        description is invalid, not merely absent) and `topics`' 50-item cap
+        are now enforced by generated `validate()` before every write —
+        neither was checked at all before this migration. `validate()`
+        originally ran before the `--dry-run` check, so a description over
+        the cap failed a dry run that every other write command promises
+        never fails; moved to run after, matching `repo create`/`pr create`
+  - [x] `Pull`/`Round`/`Source`/`Target` (`src/pr/write.rs`,
+        `src/stack/write.rs`) — the largest of the six: four
+        read-modify-write sites across two precondition mechanisms
+        (`record::put`'s per-record `swapRecord` in `pr resubmit`/`pr edit`,
+        `record::batch`'s one whole-repo `swap_commit` in `stack resubmit`),
+        both proven against the real fixtures the hand-written catch-all
+        was pinned against. `review.rs` stays on its own untyped reader on
+        purpose — confirmed live: a lexicon-faithful `Pull` refuses the same
+        pre-`rounds` fixture the hand-written one did, same as predicted.
+        `mentions`/`references` are now named, typed fields
+        (`Vec>`/`Vec>`) instead of living only in a
+        catch-all. `target.repoDid`: confirmed the current lexicon really
+        has dropped it (`target` now has only `repo`/`branch`), so
+        `crate::lexicon::tangled::pull_target` writes it through `extra_data` instead
+        — behavior preserved, just relocated. `Blob`/`BlobRef` conversion at
+        every `pull.blobs`/`images::merge_blobs` boundary, same pattern as
+        `Markdown.blobs` in the FeedComment migration. `pr resubmit` and
+        `stack resubmit`'s relink-only case (`Slot::Keep { relink: true }`)
+        pushed their `Op::Update` with no `.validate()` call at all — the
+        other two mutate-and-write paths (`pr edit`, `stack resubmit`'s
+        `Slot::Update`) always had one; both now do too. Debug-log dumps of
+        a read-then-mutated record (every `swapRecord`/`Op::Update` site)
+        could show `$type` twice — the generated container tag re-emits it
+        unconditionally and `extra_data`'s flatten doesn't strip a `$type` a
+        live read carried in — harmless on the wire (both writes agree, and
+        `record::put`/`create_record` collapse it through a real map either
+        way) but read as corrupted JSON in `--debug` output; `crate::debug::
+        pretty` now routes every such dump through `serde_json::Value` first
+        to collapse it before printing
+  - [x] `PullStatus`/`PullState` (`src/pr/write.rs`'s `set_state`,
+        `src/stack/write.rs`'s `run_merge`) — `pr/read.rs`'s `latest_states`
+        and `list_statuses` never touched the hand-written struct in the
+        first place (both read status records as untyped JSON already), so
+        this was the smallest of the six once found. `PullState` itself
+        stays hand-written on purpose — `token()`/`label()` are atgc's own
+        convenience, not something a lexicon generates — but now converts
+        through the generated `StatusStatus` enum's `Other(S)` catch-all
+        rather than a bare `String`; a new test pins that an unrecognized
+        token still deserializes (into `Other`, never a parse error) and
+        that `PullState::from_token` then names no state for it, which is
+        the exact contract `state_of` and `latest_states` depend on to skip
+        a variant this build doesn't know rather than crash on it
diff --git a/plan/logs.md b/plan/logs.md
new file mode 100644
index 0000000..21a344b
--- /dev/null
+++ b/plan/logs.md
@@ -0,0 +1,177 @@
+---
+id: logs
+title: What atgc did to somebody else's service is on disk afterwards
+status: open
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  Every request atgc makes — to an authorization server, a PDS, a knot or a
+  git subprocess — is recorded with no credential in it, and readable back by
+  invocation.
+---
+
+# logs
+
+Three logs exist: `oauth.jsonl`, `pds.jsonl` and `git.jsonl`. Each is written at
+the transport rather than at the call site, so no caller can forget to log and
+none can be added without being logged, and each is read back by one generic
+renderer.
+
+They exist because of an incident. A session was destroyed three times before
+anything was recording, and the third time the log was running: bsky.social
+refused the refresh in its own words, under a `client_id` fingerprint with no
+redirect URI, and jacquard deleted the session on the spot. `atgc logs oauth
+--incident` reproduces the whole of it.
+
+The invariant is that a credential appears only as a fingerprint. That is a
+type — `Fp` — everywhere it can be, and one audited function where it cannot,
+which is an argv.
+
+## What it needs
+
+- [ ] The XRPC error paths quote their errors to the user the way the OAuth
+      ones did — `knot.rs` and `record.rs` interpolate `{e}` into `anyhow!`
+      text with no scrubber. A PDS or knot error body is not known to echo
+      credentials the way a token endpoint's does, and `--debug` covers the
+      dumps either way now, so this was left rather than widened into a
+      change nobody could review
+- [ ] A knot write log. Knot calls — `repo create`, `delete-branch`,
+      `default-branch`, `merge` — are the mutations `logs pds` deliberately
+      does not cover, because they are not the PDS. Same shape, different
+      service, and the transport wrapper is already the place to put it
+- [ ] `logs git` probes HEAD and nothing else, so a command that moves some
+      other ref — `push`, or an `update-ref` on a branch that is not checked
+      out — records an unchanged HEAD. The row says "unchanged" rather than
+      nothing, which is honest, but the ref a command actually named is
+      knowable for `update-ref` and `branch -f` and is not read. Worth doing
+      the day something moves a ref that is not the current branch
+- [ ] `logs git` cannot see a `pr diff` pager or an `atgc report` editor:
+      `cmd/pr/review.rs` spawns `sh -c` for the pager git itself would use,
+      which is not a git subprocess and so is not this log's business, but it
+      *is* a subprocess atgc starts and nothing records it. Either widen this
+      log's name or leave it — recorded so the gap is a decision
+- [ ] An index-read log. Bobbin and the web index lag chronically and
+      service-wide, and every conversation about it starts by reconstructing
+      what a listing returned and when. Recording the answers turns "the
+      index is behind" from a recurring guess into a timestamped
+      observation, and would let `pr list` say how stale its own answer is
+- [ ] Unify the logs, once there are four. Three of them exist now — OAuth,
+      PDS writes, git subprocesses — and the knot write log is the one left.
+      Three things are still per-log that should not be: nothing correlates
+      them beyond a shared `inv` — a write refused for authentication and the
+      token refusal explaining it are two lines in two files, joined by eye;
+      each rotates and is capped on its own, so a busy log can age out the
+      one that would have explained it, and `git.jsonl` is the noisiest of
+      the three by an order of magnitude, which makes that likelier than it
+      was; and there is no way to ask a question of all of them at once. The
+      shape to aim at is `atgc logs` with no subcommand reading every log
+      into one invocation-grouped stream, `--since` and `--inv` spanning all
+      of them, and one rotation budget shared out rather than four
+      independent ones. Worth doing when the fourth log lands, not before —
+      three is still few enough to open three terminals
+- [ ] `crate::debug`'s dumps are still topic-less — one `[debug]` firehose
+      gated by the global ceiling. Tagging them by subsystem would let
+      `ATGC_LOG=pds=debug` mean the PDS wire traffic specifically
+
+## Done
+
+- [x] Append-only OAuth event log at ~/.config/atgc/oauth.jsonl, mode 0600 —
+      typed events for every token request (with the client_id actually
+      sent), refusal (with the OAuth error code and a scrubbed body), store
+      read/write, session get/upsert/delete, prune and logout, stamped with a
+      per-invocation id. Written as one bounded write(2) under O_APPEND so
+      concurrent invocations cannot tear a line. Secrets appear only as
+      SHA-256 prefixes
+- [x] `auth log` — read oauth.jsonl back. Grouped by invocation by default,
+      `--interleave` for strict time order across processes, `-f` to follow
+      (through a rotation, not into a deleted inode). Filters by invocation,
+      event kind (`token_*`), account DID and time; `--failures` for the
+      short version; `--json` passes the raw lines through so the `jq`
+      workflow still composes. `--incident` does the comparison the next item
+      asks for. Deserializes into the writer's own event enum, so a new event
+      variant is a compile error here rather than a silently unrendered line
+- [x] `logs oauth` — the OAuth log reader, moved out of `auth log` and given
+      a command group of its own, because the OAuth log is about to have
+      siblings and a second reader hanging off `auth` would have named the
+      wrong thing twice. The reader module moved with it, `authlog.rs` to
+      `logs/oauth.rs`
+- [x] `auth log` removed outright. It survived one release as a hidden alias
+      that printed the new spelling, on the same terms as bare `atgc login`,
+      and that was the wrong trade: `atgc login` is the first thing a new
+      user types and worth protecting, while the log reader's whole audience
+      is people already reading their own machine's output, who can see
+      `logs oauth` in `atgc logs --help`. The alias cost a variant, a wrapper
+      struct and a note on stderr to save them a word
+- [x] `logs pds` — every write atgc sends to a PDS, in `pds.jsonl`. One line
+      leaving and one coming back, with the collection, record key, the
+      swapRecord/swapCommit precondition and the CID the record landed at; an
+      `applyWrites` lists its ops so a stack reconcile is legible one op at a
+      time. Observed at the transport like the OAuth log, so no call site can
+      forget to log and none can be added without being logged. Record
+      content is never written — only a fingerprint of it
+- [x] The reader is generic over its event type (`logs/render.rs`), so every
+      command is one rendering rather than several that resemble each other,
+      and the writers share `logging/file.rs`: one envelope, one size cap, one
+      rotation, one invocation id across all the files
+- [x] `logs git` — every git subprocess atgc starts, in `git.jsonl`. One line
+      leaving and one coming back, with the argv, the `-C` directory, the
+      exit status, git's stderr on a failure, and — for an explicit list of
+      ref-moving subcommands — where HEAD stood before and after. That list
+      is the design: probing HEAD costs a subprocess, and asking either side
+      of every `rev-parse` would double what atgc spawns to record nothing.
+      Every git process now starts from one `Run` builder in
+      `clients/git/run.rs`, so `patch.rs`'s stdin runner and `review.rs`'s
+      are covered by construction; `cmd/report.rs`'s `git --version` was the
+      last one outside it and now calls `run::version()`
+- [x] Credentials never reach `git.jsonl`: a remote URL's whole userinfo, a
+      `-c key=value` value and a `git config` value all become an
+      eight-character fingerprint, the last by an allowlist (`user.name`,
+      `user.email`) so an unknown key is redacted rather than trusted. Stdin
+      is a length and the environment is variable names only. An argv is the
+      one field in any of these logs that cannot be typed `Fp`, so the
+      redaction is one function with one test rather than a type
+- [x] `--since` no longer panics on a non-ASCII value. `render.rs` took the
+      duration's unit with `split_at(len - 1)`, a byte index, so `--since 2д`
+      landed off a char boundary and aborted in core — exit 101 where the
+      ASCII path had always exited 2 with the sentence naming the spellings
+      that do work. It reached every `logs` subcommand on both `--since` and
+      `--until`, and `search` through the same shared parser. The unit is the
+      last `char` now, so an unreadable value is a usage error whatever
+      alphabet it is written in. The truncated verbatim duplicate of the
+      function's own doc comment is gone with it, and `render.rs` has its
+      first tests — 1,262 lines carrying none, which is not unrelated to how
+      this survived
+- [x] `logs pds` loses a column whenever colour is on. Three of the four
+      `action` values were painted and the fourth was a bare string, and the
+      `{:<8}` ran after the paint, so on a tty the padding counted SGR bytes
+      and applied none — `update` rows and the rest disagreed by two columns.
+      Correct under `NO_COLOR` and in a pipe, which is why the tests passed.
+      Fixed with `term::column::pad_to` along with every other fixed column;
+      the two `logs oauth` sites that were right only because `short_inv`
+      happens to return exactly eight characters now go through `pad_start`,
+      so they are right by construction rather than by luck
+- [x] A token endpoint's error body reaches the terminal unscrubbed. The rule
+      is stated in `logging/oauth.rs` and applied to `oauth.jsonl` alone:
+      `auth.rs` dumps `{e:#?}` to stderr under `--debug` on the same value
+      the next line passes through `scrub_text` on its way to the log, and
+      two `bail!` sites interpolate `{e}` into text a person sees with no
+      `--debug` at all. jacquard's `RequestError::HttpStatusWithBody` embeds
+      the response body in `Display` and `OAuthError::Request` is
+      `#[error(transparent)]`, so it arrives whole. Making `dump_err` itself
+      scrub makes the guarantee structural rather than per-caller, which is
+      what `Fp` already does for the log
+- [x] One `oauth.jsonl` event is written unscrubbed: `AuthorizeFailed.error`
+      is clipped but not passed through `scrub_text`, where both its siblings
+      are. Narrow — a public client's PAR body holds no long-lived credential
+      — but it is a hole in the one invariant that file exists to keep, and
+      the `Fp` type cannot cover a plain `String` field
+- [x] `scrub_text` under-matches, and its doc claims the opposite. It redacts
+      quoted values only: traced against `access_token=abc123`, the `=` and
+      the alphabetic run are skipped, the scan halts on a digit that is not a
+      quote, and the value survives. Form-encoded shapes reach `Debug`
+      through `RequestError`'s `url` field. Blank an unquoted value to the
+      next `&`, whitespace, `,` or `}` — and fix the sentence that says it
+      over-matches rather than under-matches
+- [x] `--debug` logs one raw `oauth-state` nonce, where every other site
+      fingerprints it through `redact_key`
+- [x] --debug / ATGC_DEBUG=1 — HTTP traffic, token claims, full error bodies
diff --git a/plan/milestones.md b/plan/milestones.md
new file mode 100644
index 0000000..e88d317
--- /dev/null
+++ b/plan/milestones.md
@@ -0,0 +1,53 @@
+# Milestones
+
+An overall guide to where atgc has got to. Epics cross milestone boundaries
+freely, so nothing here links to one.
+
+Releases are cut from the commits by `git cliff` into `cargo release` and are
+frequent and small; a milestone is a release worth naming, not a version
+number. The pre-1.0 line bumps the minor for a breaking change, so the
+version says nothing about how far along this list anything is.
+
+## M0: A record with a patch in it (shipped, 2026-08-06, v0.1.0)
+
+A loopback OAuth login against a real PDS, and `pr create` writing a pull
+request as a record in the account's own repository. One account, one repo,
+one verb, and the proof that a forge can be driven entirely through somebody
+else's data server.
+
+## M1: The pull request lifecycle (shipped, 2026-08-07)
+
+List, view, diff, check out, comment, edit, close, reopen, resubmit and merge.
+Several accounts at once. Numbers resolved against the appview so a pull can be
+named the way people name it. The point at which atgc replaced the website for
+its own development, which is how the rest of this list got found.
+
+## M2: Stacked pull requests (shipped, 2026-08-09, v0.15.0)
+
+A branch cut into a chain of dependent pulls, reconciled by change-id after a
+rewrite, moved through, and merged bottom-up — the workflow this project
+actually uses, and the one Tangled's own UI can only partly express. Two
+adversarial rounds against damaged chains and awkward git shapes landed with
+it.
+
+## M3: The rest of the forge (in progress)
+
+Issues, repos, branches, SSH keys, search, and `atgc api` for everything the
+lexicon has that has no verb. Mostly shipped; what is left is the parts that
+need an index answer nobody serves — issue numbers, cross-author state, fork
+based pulls.
+
+## M4: Answers you can trust and check (in progress)
+
+Every index is opted into rather than assumed, every listing says what it could
+not see, `doctor` explains a broken setup in one command, three logs record
+what atgc did to somebody else's service, and eight exit statuses say which
+kind of failure happened. This is the milestone the project is on: the last
+release was 2026-08-09 and the work since is nearly all here.
+
+## M5: 1.0.0
+
+CI on a spindle, publication to crates.io, and prebuilt binaries attached to a
+tag. Deliberately one milestone rather than three: each needs credentials and a
+signup, each is hard to get right, and doing them piecemeal leaves three
+half-configured pipelines.
diff --git a/plan/module-layout.md b/plan/module-layout.md
new file mode 100644
index 0000000..80eb70f
--- /dev/null
+++ b/plan/module-layout.md
@@ -0,0 +1,178 @@
+---
+id: module-layout
+title: Where code goes is a compile error, not a convention
+status: shipped
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  Nothing outside cmd/ imports cmd/, no module reaches sideways for a
+  neighbour's helper, and the two rules visibility cannot express have tests.
+---
+
+# module-layout
+
+Six folders under `src/`: `cmd/` for the command families, `clients/` for
+everything that talks to a counterpart (git included), `lexicon/` for the pure
+record and identifier conventions, and `config/`, `logging/` and `term/` for
+the rest. `docs/module-layout.md` is the contract, and it landed first so the
+moves could be read against it.
+
+The rules are enforced where they can be. Everything in `cmd/` is `pub(super)`,
+`pub(in crate::cmd)` or — for the entry points `main.rs` dispatches through —
+`pub(crate)`; no bare `pub` is left. The two rules visibility cannot express,
+because `clients` has to be `pub(crate)` for `cmd/` to call it, are pinned by
+`tests/module_layout.rs` instead. Both had been violated in the tree at some
+point, and both were found by grep, which is the argument for the test.
+
+The three-copies-of-a-body-reader entry this file used to carry is gone rather
+than restated: `term/body.rs` is that function, and what is left at the three
+call sites are named wrappers whose doc comments say what each one's nested
+`Option` means.
+
+## What it needs
+
+- [ ] One backwards walk over a subject's records, not two.
+      `cmd/issue/read.rs`'s `state_events` and `cmd/pr/write.rs`'s
+      `list_statuses` are the same function: page `listRecords` newest-first,
+      keep the records naming this subject, and stop once a page ends below
+      the subject's own rkey, because a record about a thing cannot predate
+      it. They differ in the NSID, the subject field (`issue`/`pull`), the
+      state field (`state`/`status`), the struct built, and a 50-page cap
+      spelled twice under two names. The comparison is strict in both, for the
+      same unobvious reason — Tangled's backfill wrote state records under the
+      *subject's* own key and those must still be seen — and a rule that
+      subtle stated twice is a rule that will be fixed once
+- [ ] Not to be merged with it: `newest_state` and `state_of` look like the
+      same pair and are deliberately not. The issue half orders on a parsed
+      instant because it merges the author's records with the acting account's
+      and two accounts write `…Z` and `…+03:00`; the pull half reads one PDS
+      and orders on the string. Written down because the two are a page apart
+      and the obvious cleanup is wrong — see `StateEvent::instant`, which
+      argues it. If the pull half ever merges a second account's statuses it
+      needs the instant compare, and that is the change to make, not the
+      sharing
+- [ ] Two doc comments in the read half are attached to the wrong item, both
+      found by the split and both deliberately moved as they were rather than
+      fixed inside a move. `append_backfilled`'s paragraph — "Splice scraped
+      pulls into a merged listing", which describes an append and says why
+      `indexed` stays false — sits on top of `apply_backfill_states`, whose
+      own doc runs on underneath it in the same block, so the function it
+      describes has none. And in the tests, "The observation this whole
+      change is built on, as it stood on" is a half-sentence stranded above
+      `builds_a_repo_url_from_the_label_it_already_had`, which it says
+      nothing about; it is a fragment of the `-- the staleness evidence --`
+      section's opening, and the separator now sits in `sources.rs` with the
+      tests it heads while the fragment went to `labels.rs` with the test it
+      is stuck to
+
+## Done
+
+- [x] `html/` — a folder for the documents atgc serves a browser, sibling to
+      `term/`, and `art.rs` at the root for the field of DNA both media draw.
+      The OAuth callback pages left `clients/atproto/oauth/`, where markup and
+      SVG sat in the middle of a module about tokens; the field left
+      `cmd/about.rs`, which is how a *client* came to import a **command**
+      module for `field_html` — the one edge in the tree running against
+      docs/module-layout.md. `respond_html` stays with the client: the line
+      falls at the `TcpStream`, since building a page opens nothing and
+      writing one to a socket is talking to a counterpart. A move and not a
+      rewrite: `atgc about` is byte-identical across 16 frame sizes, and the
+      two renderings now share `Canvas::rows` rather than keeping a
+      `rposition` call each
+- [x] The module layout is enforced rather than described. Everything in
+      `cmd/` is now `pub(super)` (119), `pub(in crate::cmd)` (41) or —
+      for the entry points `main.rs` dispatches through, the clap types
+      reachable from them, and the JSON shapes
+      docs/output.md links to by path — `pub(crate)` (125).
+      No bare `pub` is left. That
+      makes "nothing outside `cmd/` imports `cmd/`" a compile error for
+      everything except those entry points, which is as far as visibility can
+      go inside one crate
+- [x] `tests/module_layout.rs` for the two rules visibility cannot express —
+      `lexicon/` importing `clients/`, and a client asking `config/` who we
+      are. Neither is expressible because `clients` has to be `pub(crate)`
+      for `cmd/` to call it, and `pub(crate)` is visible to `lexicon/` too;
+      only a workspace split would do it. Both were violated in the tree at
+      some point and both were found by grep, which is the argument for the
+      test
+- [x] The one edge that pointed the wrong way: `clients/atproto/oauth/
+      pages.rs` called `cmd::about::field_html`. It was named as the single
+      allowed exception in `tests/module_layout.rs`, and the `html/`+`art.rs`
+      move deleted both the call and the allowlist entry — the page and the
+      field of DNA now live in `html/` and `art.rs`, and `pages.rs` is gone.
+      Nothing outside `cmd/` imports `cmd/` any more, in code or in the
+      allowlist
+- [x] `auth.rs` split into `clients/atproto/oauth/{client,sessions,store,
+      login}.rs` and `cmd/auth.rs`, which is the piece the folder move
+      deliberately left behind. A pure move: no printed string and no step of
+      the OAuth flow changed. The seam is the `config/` constraint rather
+      than the verbs — `login` and `agent_for_did` read
+      `crate::config::account` to pick a DID and so stay in `cmd/`, while
+      everything they call takes that DID as an argument
+- [x] `cmd/pr/read.rs` was past the split rule at 4,287 lines, and the seam
+      was not the verbs. It is a directory now, along the line the module doc
+      had been drawing all along. `sources.rs` is the source reconciliation —
+      `Source`, `Reach`, `pds_pulls`, `merge`, `missing_from_bobbin`,
+      `warn_stale`, `gather`, `settle_own_open` — which is to say deciding
+      what the set of pulls *is*; `mod.rs` is the arguments, the two listing
+      verbs, `pr view` and the `--json` shapes, which is deciding how to say
+      it. A third file fell out rather than being planned: `labels.rs`, the
+      handle, repo-name and appview-number lookups, which neither half owns
+      and both need — the columns want them and so does the backfill, for a
+      repo's web root. `Listed` is the currency between all three, so there
+      is no arrangement without a back edge; three files at least name what
+      each one is. Re-exports keep every `crate::cmd::pr::read::…` path other
+      modules already use, so nothing outside the directory moved, and the
+      only visibility that widened is private → `pub(super)` on the items a
+      listing reads off a gathered row. The three entries this was said to be
+      waiting behind did not block it in the end: the concurrency and
+      ordering ones landed, and the `"?"` sentinel one is a change to
+      `State`, `label()` and `known_state`, which now sit closer together
+      than they did
+- [x] `record::put` — one `putRecord` with a `swapRecord` precondition, for
+      every command that edits a record rather than creating one. `pr
+      resubmit` and `pr edit` had the only copy, inside `pr/write.rs`, and
+      `repo edit` needed the same thing; jacquard's own helper hardcodes
+      `swapRecord: None` and cannot express the precondition at all
+- [x] One module per remote, instead of a URL built wherever a command needed
+      one: `crate::pds` owns the `{pds}/xrpc/…` reads — getRecord,
+      listRecords and its paging, blob downloads — and `crate::bobbin` owns
+      the appview's base URL and its three endpoints. `pr`, `repo`, `review`
+      and `ssh` all went through their own copy before, so a paging or
+      error-shape fix landed in one caller and not the rest.
+      docs/architecture.md says which commands reach which
+- [x] `pr` split into a directory at 3,141 lines: `pr/read.rs` for `list`,
+      `status` and `view`, `pr/write.rs` for `create`, `resubmit`, `edit`,
+      `close`, `reopen` and `comment`. A move rather than a refactor — the two
+      halves share one function between them, and `pr/mod.rs` re-exports the
+      same names at the same paths, so `main.rs` did not change
+- [x] Six folders under `src/` instead of thirty siblings: `cmd/` for the
+      command families, `clients/` for everything that talks to a counterpart
+      (git included), `lexicon/` for the pure record and identifier
+      conventions, and `config/`, `logging/` and `term/` for the rest.
+      docs/module-layout.md is the contract, and it lands first so the moves
+      can be read against it. Landing as a stack, one folder per PR, with the
+      splits that fall out of it: `auth.rs` four ways, `review.rs`'s appview
+      scrape out to `clients/tangled/web/`, every git invocation under
+      `clients/git/`, `repo.rs` into read/write/branch/checkout, and clap
+      inlined into `cmd/` so the 31 mirror arg structs go away. Landed as a
+      fifteen-pull stack. Two pieces are deliberately left: `auth.rs` still
+      holds both the OAuth client and the `auth` verbs, and wants splitting
+      into `clients/atproto/oauth/{client,session}.rs` and `cmd/auth.rs`; and
+      `write_git_identity` stayed in `cmd/repo/checkout.rs` as policy over
+      the `clients/git/config.rs` primitives rather than moving wholesale
+- [x] `cmd/issue/read.rs` reached sideways for `cmd::pr::read::ellipsize`,
+      which is the shape rule 2 in docs/module-layout.md exists to stop. Done
+      since: it and `day` live in `term/column.rs`, and `issue`, `repo`,
+      `search` and `pr` all import them from there
+- [x] `new_body`/`comment_body` in `cmd/issue/write.rs` were the third
+      near-copy of "a body from a flag, a file, or stdin" in the tree, after
+      `cmd/pr/write.rs` and `cmd/report.rs` — three copies serving six
+      commands. Now `term/body.rs`: `read` for the clearable case, whose
+      nested `Option` is what lets an edit tell "leave it alone" from "clear
+      it", and `required` for a comment, which has no second meaning for
+      empty. Below `cmd/` because rule 2 says so, and beside `term/noinput`
+      because both are about what is fed to a terminal. The one real
+      difference between the copies was `report` trimming its result, which
+      is kept at that caller with the reason written down: a report body is
+      prose on a public board, while a pull body's indentation is content
diff --git a/plan/order.txt b/plan/order.txt
new file mode 100644
index 0000000..b5f191a
--- /dev/null
+++ b/plan/order.txt
@@ -0,0 +1,57 @@
+# The order the epics read in, top to bottom, wherever they appear in
+# README.md's tables. scripts/gen-plan-readme.py sorts each table by this list
+# and puts anything the list does not name at the end of its table,
+# alphabetically.
+#
+# There is no `order` field in an epic's frontmatter and there is not going to
+# be one: a rank in every file has to be rewritten across thirty files whenever
+# one moves, so two branches inserting an epic conflict over a number neither
+# cares about. One list has one line to move, and being listed here is optional
+# — a new epic needs no line at all. Only put an id here when where it sits is
+# worth saying to somebody reading down the list.
+#
+# It says nothing about status. An epic that ships moves table on its own and
+# keeps its place in whatever table it lands in.
+
+# The account, and what it costs to hold one.
+sign-in
+credential-store
+identity
+
+# The forge, roughly in the order the commands were built.
+pull-requests
+branch-pulls
+pull-numbers
+review
+stacks
+issues
+repos
+keys
+search
+indexes
+
+# Knowing what happened.
+doctor
+logs
+api
+report
+
+# CI, and everything downstream of a tag.
+pipelines
+secrets
+artifacts
+publishing
+
+# The parts of the tree that are nobody's feature.
+configuration
+http-bounds
+module-layout
+lexicons
+testing
+dependencies
+toolchain
+
+# Never finished.
+output
+docs
+brand
diff --git a/plan/output.md b/plan/output.md
new file mode 100644
index 0000000..8e27995
--- /dev/null
+++ b/plan/output.md
@@ -0,0 +1,304 @@
+---
+id: output
+title: Every command's output obeys the same three rules
+status: continuous
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  None. A command that prints something new obeys them or it does not ship.
+---
+
+# output
+
+Three rules, in `docs/output.md`: stdout is the answer and everything else is
+on stderr; `--json` prints one value on stdout with the decoration off; and an
+exit status means something specific.
+
+It is continuous because it is a contract on every command rather than a
+feature of any of them. The entries below are almost all the same defect
+arriving from a different direction — a note on stdout that breaks a `jq`
+pipeline, a padded column that counted SGR bytes, an empty listing that reads
+as *there are none* when the real answer was that the flag was misspelled, a
+refusal that exits the unclassified `1` with its real answer sitting in a
+field.
+
+The last of those is `src/exit.rs`: eight statuses, carried inside the error
+type rather than beside it, so the five failures that want different next moves
+say which they are.
+
+## What it needs
+
+- [ ] None of the four listings has an upper bound except `search`, whose
+      1000 is Bobbin's own ceiling. `--limit 0` refusing now reads as "0 is
+      not in 1..=4294967295", which is accurate and ugly. A ceiling would fix
+      the message and invent a policy at the same time: nothing in the PDS
+      protocol caps these, and refusing a value that works today to tidy an
+      error message is the wrong trade without a caller who wants it
+- [ ] `-v` has no messages yet: the ladder has room for a verbose rung
+      between `step` and `debug`, and nothing emits at it. Add it when there
+      is a line that wants it, not before
+- [ ] The knot funnel in `clients/tangled/knot.rs` is the one left. `Refused`
+      is a typed error carrying the knot's HTTP status and its `AccessControl`
+      tag, and `exit::classify` only downcasts `Coded`, so every refusal
+      through it — `pr merge`, `stack merge`, `repo delete-branch` — still
+      exits `1` with the answer sitting in a field. Two shapes work and the
+      choice is real: give `Refused` an `exit` field and teach `classify` to
+      ask it, or convert at the call sites the way `repo/` now does. The
+      first is less code and widens what `classify` knows about; the second
+      keeps exit.rs's "one error carries the code" rule intact
+- [ ] The `"?"` sentinel is round-tripped through three modules while
+      docs/output.md says unknown is `null`, never `"?"`. `State` is a proper
+      enum, `label()` flattens `Unknown` to the string, `StackRow.state` and
+      `stack::state_of` carry it as a `String`, and `known_state` at the far
+      end re-derives the type by comparing against `"?"` again. What should
+      *not* change with it: `State::Known(String)` holds whatever word the
+      index returned, which may be one this build has never heard of, and
+      typing it would silently drop a future state
+
+## Done
+
+- [x] Output channels — one rule, on every flag: stdout is the answer,
+      everything else is on stderr. Warnings, notes and progress lines all
+      go through `crate::term::say` at a level (`warn`/`note`/`step`/`debug`) and
+      under a topic (`account`, `config`, `ssh`, `git`, `index`, `knot`,
+      `pds`, …), so `-q`/`-qq` and `ATGC_LOG=note,pds=debug` can turn them
+      down without touching the answer or the exit code. Replaces the old
+      `jsonout::say!`, which moved notes to stderr only under `--json` and
+      so left the same prose in the middle of every other pipeline; `--debug`
+      is now the loudest rung of the same ladder. The bug that prompted it:
+      `logs pds --json` printed two sentences of prose on stdout when no log
+      file existed yet, which is a `jq` parse error rather than an empty
+      stream. See docs/output.md
+- [x] "N scope(s) granted since this login" said the opposite of what it
+      meant, in all three places it was printed: `doctor local`'s scopes row,
+      `auth status`'s per-account lines, and `scope_gap`'s own doc comment.
+      Nothing was granted — these are the scopes *this build asks for* that
+      the session does not carry, which is why the row is a failure rather
+      than a note. The agent notes made it worse by telling an agent to
+      ignore the message "which is just advisory", when a missing scope fails
+      `doctor local` and refuses the command it names; they now separate it
+      from the expired access token, which really is advisory
+- [x] `--limit 0` parsed on `pr list`, `issue list` and `repo list` and then
+      printed "no open pull requests" / "no issues for " — an empty
+      listing that reads as *there are none*, which is the stale-index
+      failure this tree opted out of, arriving from the command line instead.
+      Exactly the `--state opne` bug wearing a different hat, and closed the
+      same way: a `value_parser` floor of 1, so it is clap's own refusal
+      naming the range, exit 2. `search` has carried that floor since it
+      shipped, which is how the other three were found
+- [x] `atgc logs oauth --limit 20` was "unexpected argument '--limit' found".
+      `-n`/`--lines` is `tail(1)`'s spelling and is the right one for a log,
+      but `--limit` is what every listing in the tool takes and what a caller
+      arrives here having learned, so it is a `visible_alias` now. No floor
+      on that one: 0 is meaningful for a log, being the head lines and the
+      summary with no events between them
+- [x] Exit statuses that mean something — `src/exit.rs`, eight of them, with
+      `main` returning `ExitCode` instead of `anyhow::Result`. Every failure
+      atgc has ever had exited 1, so a caller could tell that something went
+      wrong and nothing else; the five that want different next moves (no
+      session, denied, not found, unreachable, conflict) now say which. The
+      code rides inside one error type rather than as a marker beside it,
+      because both other shapes break the message — above it `to_string()`
+      goes empty, below it `{e:#}` gains a trailing `": "`, and `pr read`,
+      `stack write` and `backfill` all print errors with `{e:#}` today.
+      Unclassified stays 1, so this is additive. `Unreachable` is mostly
+      inferred rather than tagged: every request goes through one reqwest
+      client, and a connect failure or timeout says so about itself
+- [x] Errors print as `error: ` on stderr with stdout left empty,
+      matching `say`'s `warning:`/`note:` labels; `--debug` prints anyhow's
+      full multi-line form, which makes that flag's "full error details"
+      true of errors and not only of HTTP bodies
+- [x] Running atgc outside a checkout is a real error rather than git's
+      plumbing. `remote_url`, `current_branch` and `repo_root` forwarded
+      git's own `fatal: not a git repository (or any parent up to mount
+      point /)` with an unclassified 1 behind it, naming neither the
+      directory nor a way out. Four cases now, all `Usage`: no repository, no
+      remote of that name (listing the ones the checkout does have), a
+      detached HEAD, and a repo with no commits — the last of which used to
+      leak `ambiguous argument 'HEAD'`, because `rev-parse --abbrev-ref` fails
+      on an unborn ref before the detached check can run. Each names the
+      directory atgc looked in, which is the whole diagnosis when a shell or
+      an agent harness is standing somewhere other than its operator believes
+- [x] More sites worth classifying, done for `pr`, `repo` and the identifier
+      parser. `pr view 99999` and `pr diff 99999` exited the unclassified `1`
+      for a pull that does not exist, and the arm they actually take is not
+      the status check — tangled.org answers `/pulls/99999` with a 200 and a
+      page carrying no record URI, so "there is no such pull" is decided by
+      finding nothing in the HTML. That is `5` now, as is a branch with no
+      pull. `2` covers the command lines that name nothing: a pull or repo
+      reference that does not parse, a `--source` or `--round` value that
+      means nothing, `--from` without `--interdiff`, a `--website` with no
+      scheme, a `--spindle` that is a URL. `4` covers the account being
+      wrong: a round, an edit or a close against somebody else's record, and
+      an `--ssh` clone with no registered key, which is the status `doctor`
+      already gave that case. The knot funnel in `repo/mod.rs` reads the
+      status rather than assuming `Denied`, since these calls also answer
+      "not here" and "already there"
+- [x] One HTTP-status-to-exit table, not four. `exit::from_status` is it, and
+      `exit_for` kept only the XRPC-error-name layer on top, since a name like
+      `RecordNotFound` is more specific than the `400` it arrives with.
+      `knot_exit` is gone; `web/pulls.rs` and `repo/read.rs` call the table
+      instead of matching inline. Two arms moved as a result, both toward the
+      table and both worth naming: a knot answering `400`, `405` or `422` is
+      now `2` rather than `1` — XRPC puts most refusals there and a request a
+      knot will not accept is a command line to fix — and a `502` from the
+      appview's pull page is now `6` rather than `1`
+- [x] The two readers that authenticate nothing take `from_public_status`, the
+      same table with the four session-shaped answers removed, rather than a
+      subset written by hand. A DID document from plc.directory and a pull's
+      page from the appview are public GETs, and `4` on one of them would tell
+      the reader to try a different account when the request never carried
+      one. Derived from `from_status` and not a second table, so a row added
+      there is considered here too; the sweep test pins that every status
+      answers either the table's answer or `1`
+- [x] `ellipsize` and `day` are `term::column`, not `cmd::pr::read`. Neither is
+      about pull requests: `issue list` and `search` were reaching in as
+      `crate::cmd::pr::read::ellipsize`, `repo list` had the cut open-coded
+      with the off-by-one done by hand, and `day` — ten characters off an
+      RFC 3339 stamp — existed five times over as the same one-line body.
+      `cmd/pr/mod.rs` argued there was nothing to put in a shared module
+      because neither had a caller outside the read half; that is why the
+      paragraph making the argument moved too
+- [x] `report`'s `error:` label takes `style::BAD`, through the same
+      `say::label` the `warning:` and `note:` prefixes go through rather than a
+      second copy of the gate. That gate is stderr's — `is_terminal`,
+      `NO_COLOR`, `TERM=dumb`, and no `--json` veto — so `atgc … --json` still
+      colours the label above the document it did not touch
+- [x] One style vocabulary, on `anstyle` — `term/style.rs` holds the six
+      styles anything painted is painted in, and the one `paint`. Three
+      modules had each grown the same `format!("\x1b[{code}m{text}\x1b[0m")`
+      over a stringly-typed SGR parameter: `say`'s prefixes, `logs`' `Palette`
+      and `repo view`'s bold heading. Nothing was wrong with any of them,
+      which is the problem with three copies — `93` was the warning colour in
+      two of them by coincidence, and a fourth caller had nowhere to look it
+      up. anstyle was already in the tree via clap, so it costs no dependency,
+      and the bytes are unchanged: `logs oauth` and `logs pds` render
+      byte-identically against a read-only fixture, across all six styles
+- [x] SIGPIPE restored to its default disposition in `main`, so a reader that
+      stops reading ends atgc quietly instead of panicking. Rust sets it to
+      `SIG_IGN` before `main` and `println!` panics on the resulting `EPIPE`,
+      which made `atgc completion zsh | head` exit 101 with a Rust panic from
+      inside `clap_complete` — a failure no `Result` returned to `main` could
+      have caught, and one that applied to all ~240 `println!` sites. Through
+      the `sigpipe` crate because the call is `signal(2)` and this crate
+      forbids `unsafe`; it adds eight lines and no transitive dependency,
+      libc already being in the tree. `tests/broken_pipe.rs` drives the real
+      binary and fails without the fix. One consequence, stated rather than
+      discovered later: quitting the pager early during `pr diff` now ends
+      atgc with SIGPIPE where the write error used to be swallowed, which is
+      where git ends too, and needs a patch past the 64 KiB pipe buffer to
+      reach at all
+- [x] `hyperlink.rs`'s OSC-8 gate said in its own comment that it matched
+      the colour gate `about.rs`, `repo.rs`, `review.rs` and `authlog.rs`
+      each carried a copy of, `TERM=dumb` included — but none of the four
+      actually checked `TERM`, so a dumb terminal suppressed links while
+      still receiving raw ANSI colour underneath them. Factored the
+      three-fact decision (a real terminal, `NO_COLOR` unset, `TERM` not
+      `dumb`) into one pure function, `hyperlink::escapes_wanted`, and
+      pointed every call site at it, so the comment is true instead of
+      aspirational and there is one place left to get the policy right
+- [x] `--help`'s command list is split into three headed groups — meta and
+      identity, repository operations, other — instead of one flat column.
+      clap cannot do this itself (`help_heading` is for arguments, and
+      `subcommand_help_heading` renames the one section), so `help.rs`
+      renders the section from the built command tree and hands it to the
+      root as `before_help`, with a template that drops clap's own list.
+      Only the grouping is written down; names and abouts still come out of
+      the tree, and a test holds that the groups cover it exactly
+- [x] shell completions (clap_complete) — `atgc completion ` writes a
+      static script to stdout for bash/zsh/fish/powershell/elvish; generated
+      from the binary, never checked in. Dynamic completion was rejected:
+      it routes every TAB through a process spawn, and the values worth
+      completing are behind Bobbin
+- [x] Two exit statuses docs/output.md promises that no code produces — and
+      the entry was wrong in both directions, which is why it contradicted
+      itself: it claimed nothing produced `4` or `7` while also saying the
+      sites "already hold the status when they refuse". Driven against the
+      real binary, both were already reachable before this branch. `4`:
+      `atgc doctor local` exits it on a scope gap or an unregistered push key,
+      `repo default-branch` on a repo you do not own exits it through
+      `repo/mod.rs`'s `own_repo`, and `tests/issue_flows.rs` had been pinning
+      it since the issue family was classified. `7`: hold
+      `~/.config/atgc/.lock` with `flock` and any command that writes account
+      state exits it from `config/lock.rs` after its 30-second wait, and
+      `atgc api` maps an `InvalidSwap` to it. What was actually missing was
+      narrower than "the status": `record.rs`'s `swap_advice` and
+      `batch_advice` built bare `anyhow!`s, so the one refusal that means
+      *re-read and try again* was indistinguishable from the ones that mean
+      *this will fail identically forever*. Now `7`, with both unit tests
+      pinning the classification in both directions. The knot half is still
+      open and has its own entry above: `knot.rs`'s `Refused` holds an HTTP
+      status and an `AccessControl` tag that `exit::classify` never looks at
+- [x] `day()` parses. `term::column::day` took the first ten characters of a
+      raw stamp, so a record written at `+03:00` printed the *writer's* local
+      date and two rows of one listing could disagree by a day about the same
+      instant. It is `DateTime::parse_from_rfc3339` now, rendered in UTC —
+      the only choice that makes two rows comparable, which is what a column
+      is for, and the rule is written down in docs/output.md. Visible
+      consequence, intended: a record written at `+03:00` just before
+      midnight prints the previous day. Anything that will not parse still
+      falls back to the first ten characters, so an empty `createdAt` still
+      comes back empty and a malformed record still lists.
+      `chrono` is a direct dependency for it, which reopens the line in the
+      surveyed-and-left-alone entry below on purpose; the reasoning is
+      recorded there and in Cargo.toml. One correction to what this entry
+      used to say: `Datetime` *does* hand back its inner value, through
+      `impl AsRef>` — the inherent methods are
+      only `as_str`/`timestamp*`, which is what the survey saw. It does not
+      help here either way, because `day` takes a raw `&str` from
+      `serde_json` at every call site and jacquard's own `ISO8601_REGEX` is
+      stricter than RFC 3339 (it rejects `-10:00`, among others), so routing
+      through it would narrow what a listing can render.
+      Still worth a jacquard report: `Datetime` could offer a `date` or
+      `format` of its own, and the `AsRef` impl deserves a mention on the
+      type rather than only in three method docs
+- [x] Fixed columns pad by cells now, not by `char`. `term::column::width`
+      answers "how many terminal cells will this print in", with `pad_to` and
+      `pad_start` beside it, and every `{:N}` over a string in the
+      tree goes through them. It fixes two defects at once: an emoji or CJK
+      title was half the cells it should be and shoved every column to its
+      right, and padding applied *after* painting counted the SGR bytes and
+      so applied none — which is what `logs pds` was losing a column to. So
+      `width` discounts escapes rather than only measuring characters: CSI
+      for colour, and OSC 8 for the hyperlinks where a whole URL sits in the
+      string and draws as nothing. `unicode_width` alone gets that wrong in
+      the same direction the `char` count did.
+      The truncation *unit* is unchanged and stays chosen: `ellipsize` counts
+      chars, a test pins the emoji case, and a second test now pins that the
+      two units are allowed to disagree.
+      `unicode-width` is taken at 0.1, the copy miette already pulls in, so
+      it costs no crate; 0.2 would have put a second one in the graph
+- [x] `pr list --json` / `pr view --json` — machine-readable output, the
+      first any read command has grown besides `auth log --json`. Emits the
+      derived view (state, resolved handle, round count, appview number),
+      not the raw record: a pull's state and number are not fields on the
+      record at all, so printing the record back would omit the entire
+      reason to run the command. Unknown reads as `null`, never the `?` the
+      terminal columns print. Colour, hyperlinks and ellipsizing are off
+      unconditionally, not just off a tty. Field set is versioned like every
+      other flag (CONTRIBUTING.md); see docs/output.md
+- [x] `pr status --json` — the `pr list` row plus the repo it targets,
+      flattened in rather than nested, so a caller widening from one repo
+      to every repo reads `.state` and not `.pull.state`. A repo neither
+      Bobbin nor the appview can name reads `null` and keeps its DID: the
+      truncated DID the column falls back to is a display string
+- [x] `stack view --json`, `repo view --json`, `repo list --json` — done.
+      `stack view`'s members carry rkey, number and round count, which
+      `pr view --json`'s `stack.members` deliberately does not (there the
+      chain is a cross-reference; numbering it would be one appview sweep
+      per member). `repo view` counts come in pairs — `branches`/
+      `branches_exact` — because a full knot page means "or more", and
+      `stars` is null rather than 0 since Bobbin answers 0 for a repo it
+      never indexed
+- [x] `--json` on every other command: `auth status`, `key list`, and the
+      whole writing surface (`pr create/resubmit/edit/close/reopen/merge/
+      comment`, `stack create/resubmit/merge`, `repo create/clone/
+      configure/edit/default-branch/delete-branch`, `key add/delete`,
+      `report`). Writers all carry `dry_run`, and leave the identifiers a
+      dry run did not mint `null` rather than guessing a record key the PDS
+      chooses; the ones that can succeed writing nothing say so in
+      `changed`/`added`/`merged` instead of by omission, since both cases
+      exit 0. The shared rules — one value on stdout, notes on stderr,
+      decoration off at `hyperlink::stdout_escapes_wanted`, errors left as
+      plain text — live in `crate::term::jsonout` and in
+      docs/output.md
diff --git a/plan/pipelines.md b/plan/pipelines.md
new file mode 100644
index 0000000..e9a2c17
--- /dev/null
+++ b/plan/pipelines.md
@@ -0,0 +1,48 @@
+---
+id: pipelines
+title: CI runs, and its runs are readable from here
+status: open
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  This repo's workflow has run on a spindle, and `atgc run` reads runs and
+  logs back.
+---
+
+# pipelines
+
+`.tangled/workflows/ci.yml` exists and no runner has ever executed it, so its
+schema is unproven and so is the assumption that the nixpkgs toolchain agrees
+with `rust-toolchain.toml`'s pin about fmt and clippy.
+
+Attaching a spindle is the gate for three other things: the integration suite
+has never run anywhere but a developer's machine, [artifacts](artifacts.md)
+needs somewhere to build a binary, and [secrets](secrets.md) has nothing to be
+a secret for. It is deliberately part of the 1.0.0 arc rather than a step on
+the way to it — see [publishing](publishing.md).
+
+## What it needs
+
+- [ ] Attach a spindle to this repo. `.tangled/workflows/ci.yml` exists but is
+      unverified — no runner has ever executed it, so its schema is unproven.
+      It also assumes the nixpkgs cargo/rustc attrs resolve, and that the
+      nixpkgs toolchain is close enough to rust-toolchain.toml's pin that fmt
+      and clippy agree with a local run. Deferred to the 1.0.0 arc under
+      Infrastructure, alongside crates.io publishing and release binaries
+- [ ] `run list` / `run view` — pipeline runs and workflow logs over spindle
+      XRPC (v1.16+)
+- [ ] `run watch` — poll a run until it completes
+- [ ] `run rerun` / `run cancel` — spindle retry/cancel
+      (rpc:sh.tangled.ci.triggerPipeline / cancelPipeline)
+- [ ] `run trigger` — kick off a workflow manually
+- [ ] `pr checks` — pipeline status for the PR's latest round
+- [ ] No spindle is attached, so the integration suite has never run anywhere
+      but a developer's machine. It needs only a Rust toolchain, `git` and
+      loopback — no secrets, no network — so it should go green on the first
+      CI run that happens. Until one does, that is unproven
+
+## Done
+
+- [x] Clear the pre-existing `cargo fmt --check` and clippy findings so the
+      pipeline's fmt and `-D warnings` gates can go green (`prek run
+      --all-files` is clean; the pipeline itself still hasn't run once)
diff --git a/plan/publishing.md b/plan/publishing.md
new file mode 100644
index 0000000..2ceb60a
--- /dev/null
+++ b/plan/publishing.md
@@ -0,0 +1,83 @@
+---
+id: publishing
+title: atgc is installable by somebody who did not build it
+status: open
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  A version is cut from the commits, tagged, and published somewhere a
+  stranger can install it from.
+---
+
+# publishing
+
+Half of this works. Conventional Commits are enforced at commit time, the
+version is computed from them by `git cliff` into `cargo release`, the bump
+writes the current release into the README, and a test fails if that line and
+Cargo.toml ever disagree. Bumping is a maintainer act on main rather than
+something a PR does, because several branches are open at once and every one of
+them would otherwise edit the same `version =` line.
+
+The other half is blocked on a decision and a signup rather than on code:
+crates.io refuses the two generated path dependencies, and where the built docs
+go is unsettled — a real domain is on the table, not just a Tangled static
+site.
+
+## What it needs
+
+- [ ] Publish the built docs somewhere. target/doc is a self-contained static
+      tree with no external requests, and Tangled hosts static sites, but
+      building it on a push needs a spindle this repo does not have. Where it
+      goes is also unsettled: a real domain (atgc.gg or similar) is on the
+      table, not just a Tangled static site
+- [ ] atgc cannot be published to crates.io as it stands, and cargo-deny is
+      what said so: `tangled-lexicon` and `userinput-lexicon` are path
+      dependencies with no version requirement, which is a wildcard, and
+      `allow-wildcard-paths` is only consulted for a crate that is itself
+      `publish = false`. atgc is not, so `bans` failed. The finding is real —
+      crates.io disallows path dependencies, and both crates are
+      `publish = false`, so `cargo publish` would refuse for the same reason
+      one step later. Left as a finding rather than fixed: the choices are to
+      publish the two generated crates, to inline them, or to say atgc is not
+      publishable, and none of those belongs in a hygiene pass. `wildcards`
+      is `"warn"` meanwhile, so it is printed on every run instead of failing
+      every run; restore `"deny"` with the fix
+- [ ] The 1.0.0 arc: CI (the spindle entry under pipelines), publishing to
+      crates.io, and the release binaries above are deliberately deferred and
+      meant to land together after 1.0.0 ships. Each one needs credentials and
+      a signup, each is hard to get right, and doing them piecemeal leaves
+      three half-configured pipelines
+
+## Done
+
+- [x] `--version` says which build, not just which release — the commit on the
+      version line, then target triple, profile and rustc under it. `-V` stays
+      one bare line for scripts. `build.rs` records the four while they are
+      still knowable and `src/build_info.rs` is the only reader; every value
+      can be empty and an empty one drops its line, because a crates.io
+      tarball carries no `.git` and `commit unknown` would be worse than no
+      commit line in exactly the build this is groundwork for. No dirty flag
+      and no build timestamp, both on purpose — see build.rs for why each
+      would have been a field that lies
+- [x] Conventional Commits enforced at commit time by `committed` as a
+      commit-msg hook, and the version in Cargo.toml computed from them —
+      `git cliff --bumped-version` into `cargo release` (cliff.toml,
+      committed.toml, release.toml). Bumped on main as a maintainer act, not
+      inside a PR: several branches are open at once and every one of them
+      would otherwise edit the same `version =` line. Breaking changes bump
+      the minor while pre-1.0, overriding git-cliff's jump to 1.0.0. No longer
+      unexercised: v0.2.0, v0.2.1 and v0.3.0 were all cut this way, through
+      scripts/release.sh
+- [x] The bump also writes which release is current into the README, on an
+      italic line under the lockup linking the tag on Tangled. A
+      `pre-release-replacements` rule in release.toml rewrites the block
+      between two HTML comment markers, so it lands in the `chore(release):`
+      commit rather than trailing it; `tests/release_metadata.rs` fails if
+      that line and Cargo.toml ever disagree. The markers, rather than a
+      match on the prose, because the line is meant to grow — see the
+      release-artifacts entry above, which is what it should point at once
+      there is a built binary to point at
+- [x] Push the `v0.1.0` epoch tag. Annotated, on 6d16818 — the last commit
+      before Conventional Commits start — so the first bump counts every
+      conforming commit. It is on origin, and the bumps since have counted
+      from it
diff --git a/plan/pull-numbers.md b/plan/pull-numbers.md
new file mode 100644
index 0000000..a3babb1
--- /dev/null
+++ b/plan/pull-numbers.md
@@ -0,0 +1,114 @@
+---
+id: pull-numbers
+title: A pull's number comes from an API, not from a page
+status: open
+repos: [atgc]
+dependsOn: [pull-requests]
+exitCriterion: >
+  Number-to-record and record-to-number both resolve without parsing the
+  appview's HTML.
+---
+
+# pull-numbers
+
+`/pulls/23` is `pulls.pull_id`, allocated from a per-repo counter inside the
+appview's own database. It is in no record, in no Bobbin response and in no
+lexicon — and it is the only name anybody uses for a pull request.
+
+So atgc fetches the page a number names and reads the record URI out of it, and
+sweeps the paged listings to put a `#` on a row. Both directions work,
+neither is an API, and both have already been seen to fail on a page whose diff
+quotes other pulls. Everything under Done is the work of making a scrape either
+correct or absent, rather than confidently wrong; everything open is what to
+delete the day a lexicon carries the number.
+
+## What it needs
+
+- [ ] `pr diff`/`pr checkout` resolve a pull *number* by fetching the
+      appview's HTML page and reading the record URI out of it, because the
+      number is in no record and in no Bobbin response. It reads the page's
+      `data-aturi` record-identity widget first and falls back to requiring
+      every `sh.tangled.repo.pull` URI on the page to agree, so it refuses
+      rather than guesses — but it is still a scrape of a page that is not an
+      API, and pull 36 already showed the fallback failing on a page whose
+      diff quotes other pulls. Replace it if the appview exposes
+      number-to-URI
+- [ ] The reverse scrape — record to number, for `pr create`, `pr view` and
+      `pr list` — reads the same pages and is the same bet on the appview's
+      markup, now including `?offset=` meaning what it appears to mean.
+      Replace both if a lexicon ever carries `pull_id`. A repo with more than
+      four pages of one state still loses numbers for the oldest of them,
+      which is a ceiling atgc chose rather than one the appview imposes
+
+## Done
+
+- [x] Tangled pull *numbers* (`/pulls/23`) are not resolvable by any *query*.
+      The number is `pulls.pull_id`, allocated from a per-repo
+      `repo_pull_seqs` counter inside the appview's own database, and no
+      lexicon accepts or returns it. Every command takes one anyway, by
+      fetching the page the number names and reading its `data-aturi`. The
+      writes refused until they didn't: `pr close 23`, `pr reopen 23`,
+      `pr edit 23` and `pr resubmit --pr 23` now go through the same resolver
+      as `pr diff`, since opening the exact page a number names is the check a
+      person does by hand, and all of them print the pull before writing.
+      `pr close` and `pr reopen` grew a `--remote`, read only for a number,
+      because a number is only unique inside one repo
+- [x] `pr view` — link to the PR itself instead of the repo's pulls page;
+      the pull number isn't in the record, so it comes from the appview, by
+      reading its pull listings and confirming the candidate's `data-aturi`
+      against the record. `pr create` and `pr resubmit` print the same link
+      for the pull they just wrote. Bobbin is not consulted and has no
+      numbers to be behind on, so this works while its index is stalled —
+      which is when someone is most likely to be looking for it
+- [x] `pr list` — a `#` column, from one sweep of the same listings for
+      the whole page rather than a confirmation per row: thirty pull pages at
+      up to 2.5 MB each is not a listing's budget. The rows are joined to the
+      listings on their titles, and a row is numbered where the title is
+      unique on both sides
+- [x] The rows that join leaves blank — pulls sharing a name, like this
+      repo's two "Flesh out .gitignore" — are then confirmed a page at a
+      time, capped at six and fetched together. A confirmation overrides the
+      join where they disagree, since it matched an at-URI on the pull's own
+      page against a guess that matched a title on a listing
+- [x] **that join was unsound and printed wrong numbers.** Two pulls sharing
+      a title swapped numbers between consecutive `pr list` runs (#194 and
+      #197, 2026-08-14). The walk stopped as soon as every row had a guess,
+      so "this title is unique" was asserted over a subset chosen for ending
+      early and a twin past that edge could not be seen; and the confirm pass
+      shortlisted only rows the join left *blank*, so a wrongly placed row was
+      the one row never checked. `walk_listings` now reports `Coverage`, a
+      join may be printed only under `Complete` — every listing read to the
+      end — and anything else is a hypothesis that is confirmed or left blank.
+      `number_for_record`, the one-pull path, always worked this way
+- [x] reading the listings out is the cheap half, which is why the fix is
+      affordable: a listing page is ~53 KB and states thirty numbers, a pull's
+      page is up to 2.5 MB and states one. Exhausting all three listings for
+      this repo's 165 pulls costs about 1.1 MB — less than confirming a single
+      row — so the walk budget went to 16 pages while the confirm budget
+      stayed at six. Measured end to end it came out *faster* than the four-
+      page walk it replaced, which had been spending its budget on 2.5 MB
+      pages to patch up a guess
+- [x] The listings are paged, so the newest thirty per state was never the
+      limit: `?offset=30` is the second page and an offset past the end
+      renders empty. Walked until the rows are placed, four pages per state
+      at most. Before this, every pull of this repo's under #24 was
+      unnumberable and `pr view` on one linked to `/pulls`
+- [x] The `/pulls` fallback says it is the fallback. A link to the listing
+      works as well as a link to the pull, so nothing about the URL told you
+      it had widened to the whole repo
+- [x] `pr create` and `pr resubmit` wait and ask again — 0.8s, then 2s —
+      because the number of a pull written a second ago is missing for the
+      one reason that fixes itself: the appview has not ingested it yet. Both
+      pulls that introduced this printed a bare `/pulls` link for exactly
+      that reason. `pr view` does not wait, having nothing to wait for
+- [x] `--json`'s `url` is the pull's own page or `null`, never the listing.
+      The note the text views print underneath has nowhere to go in an
+      object, and a link a caller cannot tell has widened is the same trap
+      one column further in. `pr view`, `stack view`, `pr create`,
+      `pr resubmit`
+- [x] `pr status` — a `#` column too. It spans repos and a number is only
+      unique inside one, so it is `pr list`'s sweep once per repo on the page,
+      with the repos run concurrently: one round trip rather than one each. No
+      flag, and no lookup that was not already being made — the repo label it
+      prints, `@owner/name`, is the URL its listings hang off. A repo whose
+      label could not be resolved has no listings to read and keeps blank rows
diff --git a/plan/pull-requests.md b/plan/pull-requests.md
new file mode 100644
index 0000000..64141c8
--- /dev/null
+++ b/plan/pull-requests.md
@@ -0,0 +1,252 @@
+---
+id: pull-requests
+title: A pull request's whole life runs from the terminal
+status: open
+repos: [atgc]
+dependsOn: [sign-in]
+exitCriterion: >
+  Open, read, list, edit, comment on, close, reopen and merge a pull request
+  without opening a browser.
+---
+
+# pull-requests
+
+A pull request is a record in the **author's** PDS carrying its own gzipped
+patches, not a branch on a server. Opening one needs no permission on the
+target repo, pushing a branch never updates one, and its state is a separate
+newest-wins log of status records rather than a field. `docs/architecture.md`
+is the long version; every surprise in this epic follows from it.
+
+What is here is the verbs and the listings. The three things that are their own
+line of work sit beside it: publishing the branch a pull claims is
+[branch-pulls](branch-pulls.md), the appview number that is in no record is
+[pull-numbers](pull-numbers.md), and reading somebody else's pull is
+[review](review.md).
+
+## What it needs
+
+- [ ] `resolve` refuses `//` rather than resolving it.
+      The knot serves that path, but answers it directly instead of
+      redirecting, so there is no repo DID to read out of a `Location`
+      header and nothing left to follow. It is not a remote Tangled hands
+      out and `repo clone` won't parse one either, so this is a loud error
+      where it used to be a silently wrong DID. Fixing it properly means
+      asking the knot or the appview what repo that path names
+- [ ] Attach commit bodies to the PR body, which used to ride on the end of
+      the entry above and is unrelated to any of it
+- [ ] `pr resubmit --target` / `pr edit --target` — retarget a pull in
+      place. The target is a field of a record in your own PDS, so this is
+      a plain `putRecord` and is what the stranded pull above actually
+      wanted; the reason it is not here yet is downstream, not local.
+      Tangled's own UI has no retarget, so how the appview renders a pull
+      whose earlier rounds are patches against a base they never applied to
+      is unproven. Wants a live test first, and probably has to write the
+      retarget and a fresh round as one operation, since a retarget without
+      one leaves the record naming a base its patches do not fit
+- [ ] `status pr` — the other half: PRs targeting your repos, split from the
+      ones you authored (was `pr status`; see the `status` section)
+- [ ] `pr close` / `pr reopen` act for the pull's author or the target repo's
+      owner only. The Go appview also honors any collaborator with
+      `repo:push`, but Bobbin has no ACL and honors only author-or-owner, so a
+      collaborator's close would show on tangled.org and be invisible in every
+      `pr list`. Needs a knot ACL query to do properly
+- [ ] `pr close` / `pr reopen` keep the author-or-owner rule, which is a
+      different trade: nothing refuses that write, so a collaborator's status
+      record would land in their PDS, show on tangled.org and be dropped by
+      Bobbin. Trying is free for a merge, where a knot answers; here it would
+      be a write that silently means nothing
+- [ ] `status` — cross-repo overview: your open PRs and mentions. The name is
+      free now and the group exists (see the `status` section); this is the
+      bare `atgc status`, which today prints the group's help. `status pr`
+      already covers the authored-PR half, so what is left is the mentions
+
+## Done
+
+- [x] `pr create` — patch-based PRs with --dry-run; DID resolved from the
+      remote's git redirects
+- [x] `resolve` returns the *repo's* DID, not the owner's. It used to take
+      the first `did:` segment in the path, so a remote spelled
+      `tangled.org//` handed back the owner's account DID and
+      every `pr` command silently asked the appview about the wrong subject.
+      The rule now is that a DID naming a repo by itself is the repo's and a
+      DID with a repo name after it is an account's — verified against the
+      live appview and knot1 for all four path shapes. `repo clone`'s
+      "discard a DID equal to the owner" workaround is gone with it
+- [x] the "no commits on " refusal from `pr create`/`resubmit`
+      now says the submitted branch is the checked-out one and to check
+      the right branch out — the situation behind nearly every hit,
+      confirmed by four fresh-context agents reaching it from `main`
+- [x] a target branch that stops existing, at both ends. `pr create` warns
+      when the target is the source branch of a pull that is not merged or
+      closed — the shape that cannot survive its own base, since Tangled
+      merges by rebasing and the branch goes with it — off the listing the
+      stack warning already reads. `pr resubmit` asks `git ls-remote`
+      whether the target is still there and refuses when it is definitively
+      not; `refs/remotes//` outlives the branch it mirrors,
+      so the round used to be built on a stale tip and quietly re-contain
+      commits that had already landed. Only an empty answer from the remote
+      refuses, never an unreachable one. Reported by an agent that hit it
+      for real: a pull opened against an open stack's branch, stranded when
+      the stack merged, recoverable only by closing and reopening
+- [x] `pr resubmit ` — the last verb that would not take its pull
+      positionally, and so the last place the family's shape broke. Twelve
+      `pr` verbs and seven `issue` verbs against one `--pr `, printed by
+      `atgc agent` two lines above `pr edit 23`, which is where the two
+      spellings were most likely to be read as a distinction that means
+      something. `--pr` stays and is not deprecated: it was this verb's only
+      spelling for its whole life, so it is in scripts and in older notes,
+      and adding a spelling now costs nothing where removing one after 1.0
+      would be a breaking change. Naming the pull twice is refused, and
+      naming it neither way is now this command's own message rather than
+      clap's report of a missing flag
+- [x] `pr view ` — accept the same four pull spellings as its
+      siblings; it was the one command in the family that refused a
+      positional, and both fresh-context test agents typed `pr view 2`
+      at it. An unlisted-but-named pull renders from its record
+- [x] `pr view` — print the current branch's PR: state, title, author, body
+      and one line per round. Matches source.branch and stops there.
+      --web opens the repo's pulls page
+- [x] It used to fall back to your newest PR on the repo, which since atgc's
+      own PRs carried no source was the path they always took. From a second
+      worktree that reported another branch's merged PR in the format of a
+      right answer, and the `uri:` it printed was what got pasted into `pr
+      close`. `pr diff`, `pr comment` and `pr checkout` guessed the same way
+      and no longer do
+- [x] `pr status` — your PRs across repos, read from the subject's own PDS
+      rather than Bobbin: every record it wants lives in one repository, so
+      no index can be behind on it. Bobbin is still asked for states written
+      by other people and for comment counts, but is no longer required.
+      Target repo shown as owner/name, falling back to the appview's redirect
+      when Bobbin has not indexed the repo; --state/--limit, --author and
+      --source
+- [x] `pr comment` — **not** `sh.tangled.repo.pull.comment`, which is deprecated and whose creates the appview ingests as a no-op; the live record is `sh.tangled.feed.comment`, with a `subject` strongRef and a zero-based `pullRoundIdx` that is required when the subject is a pull (atgc's `--round` stays one-based, matching `pr diff`). No standing check: unlike pull statuses, neither the appview nor Bobbin filters who may comment
+- [x] `pr close` / `pr reopen` — state is a *log* of separate
+      `sh.tangled.repo.pull.status` records, not a field on the pull, so both
+      are an append of a new TID-keyed record and never a put or a delete
+      (verified against the appview's `stateWinner`, which orders by
+      `createdAt` desc then at-uri desc, and against its own web handlers,
+      which create a fresh record for close, reopen and merge alike). The
+      record goes in the *acting* account's PDS, so this works on a pull filed
+      against your repo by somebody else. Reads the current state from the
+      PDSes directly rather than from Bobbin, so a no-op is a no-op even while
+      the index is behind, and refuses to close or reopen a merged pull, which
+      would hide the merge
+- [x] `pr edit` — title and/or body of your own PR, via put_record on the
+      record itself, leaving rounds untouched. `--body-file` (and `-` for
+      stdin) rather than an `$EDITOR` spawn, which would hang under an agent.
+      Author-only, unlike close/reopen: the pull record is in the author's PDS
+- [x] Images in PR bodies — `pr create` (which grew `--body-file` for it) and
+      `pr edit` upload each local path a body's `![…](…)` names as an image/*
+      blob (parallel, retries with backoff), rewrite it to the `blob+at://`
+      URI Tangled's renderer resolves, and list it in the record's `blobs`
+      array so the PDS keeps it. Relative paths never render on a pull page
+      (only READMEs get `/raw/` rewriting), so a path that names no file is
+      refused, not published. Stack bodies (from commit messages) get the
+      same treatment in `stack create` and `stack resubmit`, with new blobs
+      merged into a rewritten member's existing anchors by CID. `pr comment`
+      too: `sh.tangled.markup.markdown` turned out to carry its own `blobs`
+      array on the same image/*-at-1MB terms, so a comment body anchors
+      images exactly as a pull body does
+- [x] `pr resubmit` and `pr edit` send the record's CID as `putRecord`'s
+      `swapRecord` precondition, so two concurrent read-modify-writes no
+      longer both succeed with one silently discarding the other's change.
+      jacquard's `put_record` helper hardcodes `swapRecord: None`, so the
+      request is built by hand — worth reporting upstream alongside the
+      `client_id` bug
+- [x] `pr resubmit --pr` did not parse its argument at all — it took the last
+      `/`-separated segment and called it a record key, so a number reached
+      the PDS as the key `67` and came back "record not found" with no
+      explanation, and somebody else's at-URI reached it as their key against
+      *your* PDS. Now classified like every other reference, and a pull that
+      is not yours is refused by name rather than by 404
+- [x] `pr resubmit` — append a round (gzip patch) to an existing PR
+- [x] `pr merge` — knot XRPC (`sh.tangled.repo.merge`, `mergeCheck` first),
+      authorized by a service-auth token the PDS mints per call
+      (`crate::knot::xrpc`, the shared spelling of the dance `repo create`
+      grew inline). `repo_facts` finds the knot to call, off the owner's own
+      PDS or off the repo DID's document. After the
+      knot merges, a merged status record is written per landed pull — the
+      knot moved the branch, and without the records every listing keeps
+      calling the pulls open. A stacked pull is refused toward `stack merge`
+- [x] `pr merge`/`stack merge` are not owner-only after all, and the ACL
+      query this entry wanted is not needed to find that out. A knot
+      authorizes `sh.tangled.repo.merge` with `IsPushAllowed(actor, repoDid)`
+      and routes by `repo`, ignoring the `did`/`name` pair unless it is too
+      old for the `repo-did-input` capability — so the merge can simply be
+      sent, as tangled.org sends it, under the account merging rather than
+      the owner. The owner-record lookup that doubled as the check is now a
+      source of those two legacy fields, and the knot is found in the repo
+      DID's own document when this account holds no record for it. A refusal
+      comes back tagged `AccessControl` and is printed as what it is
+- [x] `pr list` and `pr list --all` resolved their columns one round trip at
+      a time: one `handle_from_did_doc` per unique author DID and one
+      `repo_name` per unique repo DID, both in plain `for` loops, so a page
+      from fifteen authors was fifteen serial DID-document fetches, and the
+      `--all` half worse — `repo_name` is up to three requests of its own.
+      Both are now `buffer_unordered` at `LABEL_CONCURRENCY`, the spelling
+      `images.rs` already used, joining `numbers_across_repos`'s `JoinSet`
+      two hundred lines away in the same file. The dedupe the loops did by
+      hand is `unique`, kept because one lookup per row would undo the point
+- [x] `newest` ordered records by comparing `createdAt` as a string, while
+      `Listed::instant` two hundred lines above exists precisely because that
+      does not work: PDS records stamp UTC and an index's come back in
+      whatever offset the writer used, and `…Z` and `…+03:00` do not sort
+      lexically against each other. It now parses, like the sort key it sits
+      beside, and breaks a tie on the raw string so a set with no usable
+      stamps still answers the same way twice. The reachable symptom was `pr
+      view` on a branch with more than one pull behind it, which matches
+      against the *merged* listing and so sees both formats at once. The
+      sibling comparisons in `pr`'s `state_of` and `latest_states` are fine
+      and stay — they read one PDS, so every stamp shares a writer and a
+      format; `cmd/issue/read.rs`'s `newest_state` is not covered by that
+      exemption, merging two accounts' records, and already parsed
+- [x] `status pr` — your pull requests across every repo, moved out from
+      under `pr`. `pr list` is a repo's pull requests and this is an
+      account's, across all of them; that is the largest fact about either
+      listing and neither word carried it while the two were sibling verbs.
+      So the scope went into the first word, where it is read first: `atgc pr
+      …` is this repo, `atgc status …` is not. A surface move and nothing
+      else — same flags, same table, same `--json` rows — with the
+      implementation left in `cmd/pr/read.rs` beside the two listings it
+      shares a gather, a state filter and the `#` sweep with. It also
+      frees the name the cross-repo overview in `misc` has wanted since
+      before this command existed, which was the other half of the problem:
+      the planned `atgc status` could not be added while a verb one level
+      down was called that
+- [x] Dissolved, and the group with it. `status` was a container built to put
+      scope in the first word, and once `tangled` left for `doctor` it held
+      one verb — a family of one, whose whole justification was being a
+      family. The listing went back to `pr` as `pr list --all`, where the
+      scope is a flag among the other filters rather than a second verb whose
+      name carries nothing. `pr status` and `status pr` are both gone; there
+      is one spelling
+- [x] `--author` requires `--all`. Widening is per account and not per repo,
+      the records being read from a PDS: a pull lives in the PDS of whoever
+      wrote it, so "every repo" is answerable for one account and there is no
+      listing of everyone's pulls everywhere for an author to narrow. A flag
+      that silently meant something else in the narrow case would be worse
+      than a refusal that says so
+- [x] `pr list --author` without `--all`: this repo, narrowed to one
+      account. It needed no new gather — a repo listing's complete half was
+      always one account's PDS, so naming somebody else only changes which
+      DID is read — plus a post-filter for the index sources, which have
+      every author in them. The work that was actually in it was the prose:
+      four sentences about completeness said "your PDS" from a time when
+      there was only one answer, and a listing of somebody else's pulls that
+      says "your PDS" is the same class of wrong answer as a stale index.
+      `Whose` in `cmd/pr/read/sources.rs` is those four sentences' one
+      source of truth, and `--author` naming the selected account resolves
+      back to `Mine` so it reads like the bare listing
+- [x] `browse` — open the repo's tangled.org page, with an optional section
+      path (`atgc browse pulls`); --no-open only prints the URL
+- [x] `browse --pr` — the current branch's PR instead of a hand-typed
+      section path. `pr view`'s resolution exactly, shared rather than
+      copied: the same gather, the same `source.branch` match, the same
+      refusal to fall back to a newer pull on another branch, so the page it
+      opens is the pull `pr view` would print or the same error saying why
+      there isn't one. Conflicts with a section argument by declaration
+      rather than by precedence
+- [x] `browse --json` — the URL as data, with the pull's at:// URI beside it
+      when `--pr` resolved one, and whether a browser was actually opened.
+      The one command whose whole output was a URL was the one that could
+      not be read by a script without parsing stdout
diff --git a/plan/report.md b/plan/report.md
new file mode 100644
index 0000000..bb2b969
--- /dev/null
+++ b/plan/report.md
@@ -0,0 +1,97 @@
+---
+id: report
+title: Feedback reaches a public board without leaving the terminal
+status: open
+repos: [atgc]
+dependsOn: [sign-in]
+exitCriterion: >
+  A bug or a feature request is filed against the atgc board, with its
+  diagnostics, in one command.
+---
+
+# report
+
+`atgc report` writes an `app.userinput.discussion` into the reporter's own PDS
+pointing at a board's space record — the same shape userinput.app's web client
+writes, recovered from its published lexicons rather than from reading the
+minified bundle. Only the board's address is compiled in; its name and the
+kinds it accepts are read off the record on every run.
+
+The diagnostics block is fixed and printed before the write, and
+`--no-diagnostics` drops it. It names software and one public host and nothing
+else, which is the privacy rule it is written to.
+
+## What it needs
+
+- [ ] Generate `#image` too and attach a screenshot to a report (`images`,
+      max 4, png/jpeg/webp/gif, 1 MB) — widen `scripts/vendor-userinput-
+      lexicons.sh`'s codegen input past `discussion.json`/`upvote.json` once
+      this lands, so the size/mime limits come from the same generated
+      `validate()` rather than a second hand copy. Declare the blob's
+      content type on upload — a PDS that cannot sniff it records `*/*` and
+      the lexicon then rejects the record — and upload before the write,
+      since an unreferenced blob is collected
+- [ ] Nothing reads the board back: `report` files and prints a URL, and
+      whether the report is *there* is a question only the web answers. A
+      constellation-backed `report list` would close that, at the cost of a
+      dependency on an index the filing path deliberately does not need
+- [ ] Lift `crate::lexicon::userinput` out into a reusable crate once the shape
+      settles — it is deliberately self-contained (jacquard types only) and
+      carries the conventions recovered from the web client: vote rkey = the
+      subject's rkey, write-time autolinking, self-upvote on post
+- [ ] `report reply` / vote on an existing report would fit inside authBasic
+      if wanted; nothing asks for them yet
+
+## Done
+
+- [x] `report` — an `app.userinput.discussion` in the reporter's own PDS,
+      pointing at a board's space record; kind validated against the kinds
+      the board declares (`{label, value}` objects on the live board, bare
+      strings tolerated), fixed diagnostics lines unless --no-diagnostics,
+      the whole record printed before the write, --dry-run stops there.
+      Self-upvote keyed by the report's own rkey, matching the web client
+- [x] `include:app.userinput.authBasic` in SCOPES — the third re-login. The
+      authorization server expands a permission set before minting the token,
+      so the granted scope never carries the string asked for. Found in
+      lance.blue/headquarters, which shipped a literal check and had it
+      refuse every write
+- [x] Check the set anyway, by its members: `PERMISSION_SETS` pairs the scope
+      with the collections the vendored `permission-set` record names, so a
+      grant holding any of them counts as a grant of the set. `report`
+      refuses before it reads the board or composes the record, `auth status`
+      lists the set as `atgc report` will be refused without it, and a stale
+      login sees the login command instead of a 403 about record creation
+- [x] Generate the userinput records with jacquard-lexicon codegen from the
+      published schemas rather than hand-reading the minified web client:
+      every `app.userinput.*` schema is vendored under `lexicons/` with its
+      at://-uri and CID pinned in the unified `lexicons.json` (`scripts/
+      vendor-userinput-lexicons.sh`, the way headquarters vendors
+      blue.lance.camo), and both records `report` writes —
+      `app.userinput.discussion` and `app.userinput.upvote` — are turned
+      into Rust in `vendor/userinput-lexicon/`, a path-dependency crate
+      alongside `vendor/tangled-lexicon`. `Discussion` and `Upvote` in
+      `src/lexicon/userinput.rs` are now the generated types directly, built by the
+      free functions `new_discussion`/inline construction rather than
+      wrapper structs (Rust's orphan rule blocks an inherent `Discussion::
+      new`, the same shape as `crate::lexicon::tangled::pull_target` on the
+      sh.tangled.* side). `new_discussion` calls the generated `validate()`
+      before returning, so title 600/300, body 20000/10000 and tags max 8
+      (the limits hand-reading the web client only guessed at) refuse
+      locally instead of at the PDS's 400. `StrongRef` stays hand-written on
+      purpose: `com.atproto.repo.strongRef` requires `cid`, but a
+      discussion's `space` sometimes has none even though a vote's
+      `subject` always does, and jacquard-codegen resolves a ref the same
+      way for every field that names it — vendoring strongRef would make
+      both required at once. `space`/`subject` stay the untyped `Data` a
+      ref resolves to unvendored, built from `StrongRef` by
+      `StrongRef::to_data`
+- [x] The atgc board, baked into `report::DEFAULT_BOARD` — bug, feature and
+      question, at userinput.app/s/did:plc:nlzmjyfv6loqtxyzvdcznwgf/3msrnb776772b.
+      Only the address is compiled in; the name and kinds are read off the
+      record on every run. Creating a space is not in authBasic, so making
+      the board was a web-UI act and moving it would be another
+- [x] `report`'s diagnostics gained the same version line, and its platform is
+      now the target triple rather than `OS (ARCH)`, which said
+      `linux (x86_64)` for a glibc and a musl build alike. Still names of
+      software and one public host, so the privacy rule the block is written
+      to is unchanged
diff --git a/plan/repos.md b/plan/repos.md
new file mode 100644
index 0000000..1ae1538
--- /dev/null
+++ b/plan/repos.md
@@ -0,0 +1,149 @@
+---
+id: repos
+title: A repo is created, configured and taken apart from here
+status: open
+repos: [atgc]
+dependsOn: [keys]
+exitCriterion: >
+  Create, clone, configure, describe and delete a repo and its branches,
+  including from a directory that is not yet a repository.
+---
+
+# repos
+
+A repo has a DID of its own, and it is not its owner's. Its record lives in the
+owner's PDS; its git data lives on a knot, which authorizes an XRPC call by the
+caller's push access rather than by ownership. So every `repo` command is two
+writes to two services in a forced order — `create` asks the knot before it
+writes the record, `delete` deletes the record before it asks the knot — and
+neither service enforces its half.
+
+That order is the epic's whole subject. The rest is refusing early: an account
+with no registered key cannot push, so `repo create` refuses before the knot
+call rather than leaving a repo behind that nobody can push to.
+
+## What it needs
+
+- [ ] `repo create` — knot discovery (default from your membership records
+      instead of hardcoded knot1.tangled.sh); confirm before creating on a
+      knot you've never used
+- [ ] `repo edit --labels`, once something in atgc creates the
+      `sh.tangled.label.definition` records the field points at
+- [ ] `repo fork` — create fork on your knot; `repo sync` via forkStatus/forkSync
+- [ ] `repo default-branch` still sends the change when the same
+      `getDefaultBranch` read fails, and that is deliberate: the read is only
+      there to print `old -> new`, and setting a default onto the wrong
+      branch is fixed by setting it again. Left as it is on purpose, recorded
+      so the next reader does not "fix" it into a refusal
+
+## Done
+
+- [x] `repo create` — from an existing checkout: knot XRPC create, repo
+      record, remote add, push (proven: created and pushed atgc itself)
+- [x] `repo create` — pick the push SSH identity by matching registered
+      sh.tangled.publicKey records; pin via per-repo core.sshCommand
+- [x] `repo create` refuses an account with no usable key instead of warning,
+      before the knot call rather than after it: a repo record and a knot-side
+      repo left behind by a push that could never work is worse than no repo.
+      `--no-push` downgrades it to a warning. `repo configure` fails the same
+      way (after writing the git identity, which needs no key), and `repo
+      clone` still falls back to https but says pushes from that checkout
+      will fail. `--ssh` with no key is a refusal: there is no anonymous ssh
+- [x] `repo create` — from nothing, not only from a checkout: an unborn HEAD
+      names the default branch instead of failing "commit something first",
+      and a directory that is no repository at all gets `git init` before the
+      remote. Nothing local happens until the knot and the record have both
+      agreed, so a refused name leaves no half-made checkout behind
+- [x] `repo list` — the owner's sh.tangled.repo records straight from their
+      PDS, newest first; defaults to the active account, or takes a handle/DID
+      plus --limit. Was Bobbin's listRepos, which answered "no repos" for an
+      account that had one
+- [x] `repo view` — one repo's record off the owner's PDS, plus branches,
+      tags, languages and the default branch read live off its knot; the repo
+      and its owner each named by handle *and* DID on one line. Bobbin is asked
+      for the star count and nothing else — pull and issue counts get compared
+      against a list, and it lags, so those are left out rather than stale
+- [x] `repo clone` — owner/name, at-uri or Tangled URL; ssh when a registered
+      key matches, else https; writes the `[user]` identity into the checkout
+- [x] `repo configure` — write the `[user]` identity and pin core.sshCommand
+      in the current checkout, for the selected account; skips rule 3 of
+      account precedence since it writes the value rule 3 reads. Repairs a
+      `repo clone` interrupted after git finished but before the identity was
+      written, which is otherwise unrecoverable
+- [x] `repo edit` — description, topics, website, spindle on the repo record.
+      A read-modify-write over somebody else's schema, so it goes through
+      `record::put` with the CID the read returned: Tangled's web UI edits the
+      same record, and two writers with no precondition lose one of the two
+      edits silently. `knot` is deliberately not editable — rewriting it moves
+      no repo and only makes every clone URL wrong. An empty value clears a
+      field rather than writing an empty string
+- [x] `repo delete` — the record off the owner's PDS first, then the git data
+      over rpc:sh.tangled.repo.delete. That order is the knot's, not a
+      preference: it reserves deleting-past-a-live-record for its admins, so
+      the record deletion is what authorizes the cleanup. The record delete
+      carries the CID the read returned, same precondition as `repo edit`'s
+      put. The repo must be named — the one repo command that never falls
+      back to the checkout's remote, because guessing is how the wrong repo
+      gets deleted. Its rpc scope is the third one-time re-login, the first
+      that was held back on purpose until its command existed
+- [x] `repo default-branch` — rpc:sh.tangled.repo.setDefaultBranch. A knot
+      mutation, not a record write, so it needs a repo read only to learn the
+      knot and mint the record's own at-uri, which is the `repo` shape the
+      procedure's vendored lexicon declares. The scope has been in `SCOPES`
+      since before this command did, so no re-login is needed
+- [x] `repo delete-branch` — rpc:sh.tangled.repo.deleteBranch. The third
+      scope this tool has ever added after its command shipped rather than
+      before — `deleteBranch` was held back as destructive on purpose, and
+      this is the re-login that costs. Refuses to delete the repo's own
+      default branch client-side, since the knot's lexicon documents no
+      named error for that case and there is no acceptable way to find out
+      by trying it. Does not check for open pulls targeting the branch —
+      that would mean trusting Bobbin's index for a destructive gate, and
+      `repo view` already declines pull counts over exactly how stale it
+      gets
+- [x] The whole `repo` family bypasses `clients/endpoints.rs`. Ten sites
+      built `https://{knot}/xrpc/…` or `https://tangled.org/…` by hand — in
+      `repo/write.rs`, `repo/read.rs` and `repo/branch.rs` — where
+      `clients/tangled/knot.rs` and the four `fn appview()` wrappers go
+      through the module that exists to be the one place a hostname is
+      spelled. One of the ten moved traffic: `write.rs`'s `knot_procedure`,
+      which is `repo create`'s and `repo delete`'s only knot call, so
+      `ATGC_KNOT` did not reach atgc's two irreversible commands and a test
+      of either would have posted to a real knot. The other nine were the
+      printed `url:`, which said tangled.org whatever `ATGC_APPVIEW` was set
+      to — including on the line directly under a `view:` link that honoured
+      it. Both are closed: the knot call goes through
+      `endpoints::knot`, and one `repo::repo_url` builder replaced the nine
+      (ten, with `clone`'s, which was already correct). `tests/repo_flows.rs`
+      now drives `create`, `delete` and `default-branch` against the mock.
+      Left deliberately: the four `git@tangled.org:` SSH remotes, in
+      `read.rs`, `write.rs` and `checkout.rs`. An SSH host cannot be derived
+      from an HTTP base URL, so moving those means a sixth compiled-in name
+      and a sixth override, which is a new public interface rather than a
+      cleanup — and no test can clone over SSH from a mock anyway
+- [x] `repo delete-branch` fails open on its only guard. The one check is
+      whether this is the repo's default branch, and when the
+      `getDefaultBranch` read fails, or the record carries no `repoDid`, it
+      warns and deletes anyway. The read is an unauthenticated GET and the
+      delete an authenticated XRPC to the same knot, so the first failing
+      while the second succeeds is ordinary — a 5xx from a proxy, a slow
+      response, a renamed query. The command's own doc says that deleting a
+      real default branch on a real knot is not an acceptable way to find out
+      whether the knot refuses it, and that there is nothing to read a
+      mistake back from; failing open contradicts both. The neighbouring
+      decision *not* to check for open pulls is sound and does not transfer:
+      that one reads an index that lags and can be wrong in both directions,
+      this one reads the knot and can only be wrong in one. There was a third
+      way through that the survey did not name — a `getDefaultBranch` that
+      answers 200 with no branch name in it, which is what a proxy's error
+      page and a renamed field both look like from here — and it is closed
+      too. `--force` is the override, and it does not reach a knot that names
+      the branch: no flag on this command means "yes, delete the default"
+- [x] `repo delete` and `repo create` still reach a real knot under
+      `ATGC_KNOT` — only `knot_query` moved onto `clients/endpoints.rs`, for
+      `delete-branch`'s guard. Closed with the entry above. Worth recording
+      what the tests it unblocked actually found worth asserting, since it is
+      not the URL: both commands are two writes in a forced order — `create`
+      asks the knot before it writes the record, `delete` deletes the record
+      before it asks the knot — and neither service enforces its half. The
+      journal in `tests/support` is the only thing that can see the sequence
diff --git a/plan/review.md b/plan/review.md
new file mode 100644
index 0000000..93799c9
--- /dev/null
+++ b/plan/review.md
@@ -0,0 +1,89 @@
+---
+id: review
+title: Somebody else's pull can be read, compared and run here
+status: open
+repos: [atgc]
+dependsOn: [pull-requests]
+exitCriterion: >
+  Any round's patch is readable and applicable from a checkout, with no
+  session, and two rounds can be compared.
+---
+
+# review
+
+A round's patch is a public blob in the author's PDS, so reading one needs no
+account at all — verified against an empty `HOME`. That makes reviewing the
+cheapest thing atgc does and the one most worth getting exactly right: a diff
+that is subtly not the pull's, or a checkout of a branch that merely shares the
+pull's source-branch name, is worse than no command.
+
+Hence the corroboration in `pr checkout`: a branch-based fetch is compared as
+text against the round's own patch hunks before it is trusted, and a mismatch
+refuses and names `--patch` as the way past it.
+
+## What it needs
+
+- [ ] `pr diff --interdiff` cannot compare rounds of a PR that has already
+      been merged into the branch it targeted, because neither round applies
+      to anything on that branch any more. Pull request 23 is the worked
+      example. The rounds' real bases are commits the branch never stood at,
+      so nothing short of the author's original refs would find them
+- [ ] `pr checkout` for a branch-based PR whose `source.repo` is a *different*
+      repo falls back to the patch. Fetching it means turning a repo DID into
+      a clone URL, which needs the knot from Bobbin's repo lookup
+
+## Done
+
+- [x] `pr diff` — print a round's patch, straight out of the author's PDS.
+      `--round N` picks one, the latest by default. Needs no session: the
+      record and the blob are both public reads, verified against an empty
+      `HOME`. Takes a pull number, a record key, `/`, an
+      at-URI or a Tangled URL, and falls back to the current branch's PR.
+      Bounded: declared blob size checked before the fetch, decompression
+      capped by `--max-bytes`. Paged through `git var GIT_PAGER`, and bare
+      on a pipe so `atgc pr diff 23 | git am` works
+- [x] `pr diff --interdiff` — `git range-diff` between two rounds, built by
+      applying both to one base in a scratch `git worktree`. When the target
+      branch has moved it searches back along first-parent history for a
+      commit that takes both, confirming by applying rather than by probe;
+      when no such commit exists it prints nothing and says so, since a
+      partly applied series would produce a confidently wrong answer.
+      `--from N` picks the left-hand round, `--base` overrides the search
+- [x] `pr checkout` — `git am --3way` onto `pr//-r`, or
+      a fetch of the source branch when the PR is branch-based and the
+      branch is in the target repo. Refuses a dirty tree and an existing
+      branch; `--branch` overrides the name and it is checked with `git
+      check-ref-format`. A stopped `git am` is left stopped, with the four
+      ways out printed
+- [x] `pr checkout --worktree ` puts all of that in a `git worktree`
+      created at that path instead of in the checkout it was run from, which
+      is the shape this project actually works in — contributors and agents
+      live in `.claude/worktrees/`, and a reviewer with work in progress had
+      to stash it to look at anybody's pull request. The dirty-tree refusal
+      does not apply there and is not made: `git worktree add` does not touch
+      the caller's tree, and the fetch and corroboration that still run in it
+      read refs and objects rather than the tree. `--branch` composes, naming
+      the branch the worktree is put on. A path that already holds something,
+      or that is inside a *different* repo, is refused before the branch is
+      created — git checks the path only afterwards, and leaves the branch
+      behind. `.git/config` is shared with the original checkout unless
+      `extensions.worktreeConfig` is set, so the identity and
+      `core.sshCommand` carry over with nothing written twice (verified, not
+      assumed). `--json` reports the branch, the worktree path and which of
+      the branch and patch paths ran
+- [x] `pr checkout` now corroborates a branch-based fetch before trusting it:
+      `git diff --no-color base...FETCH_HEAD` is compared, as text, against
+      the diff hunks pulled out of the round's own patch (mail envelope and
+      `format-patch`'s trailing version signature stripped, the way `atgc
+      stack resubmit` already had to before its own no-op check). Content
+      rather than shas or commit counts, because a rebase changes both
+      without changing what the branch does. A mismatch refuses rather than
+      checking out a branch that merely shares the pull's source-branch name
+      — stale, force-pushed, or simply somebody else's `claude/prek` — and
+      names `--patch` as the way past it: the record's own copy, not
+      whatever the name currently resolves to. No new flag for the opposite
+      case, trusting a branch the check refused; plain `git fetch` and `git
+      checkout -b` already do that, uncorroborated, same as ever. Two commits
+      editing the very same lines is the one series shape the comparison
+      does not reproduce faithfully, and is rare enough not to be worth a
+      scratch worktree and a real `git am` just to rule out
diff --git a/plan/search.md b/plan/search.md
new file mode 100644
index 0000000..2405cf5
--- /dev/null
+++ b/plan/search.md
@@ -0,0 +1,63 @@
+---
+id: search
+title: The index's full-text search is a command
+status: open
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  A query over sh.tangled.search.query runs from a checkout, scoped or
+  widened, with its one-index caveat stated on every run.
+---
+
+# search
+
+`atgc search` is the one question in the stack with no second source. Every
+other listing merges a PDS read with an index; there is no full-text index in a
+PDS, so a stalled Bobbin here means a recent record is simply unfindable, and
+saying so is all atgc can do about it.
+
+The lexicon is deliberately not vendored: `value` and `score` are both typed
+`unknown`, so codegen has nothing to generate and the captured fixture is the
+stronger pin.
+
+## What it needs
+
+- [ ] No `--cursor`. Bobbin sends one exactly when the page came back full,
+      which `search` reports as "raise `--limit`" since the ceiling is 1000 in
+      a single request. Revisit if a caller ever wants to walk past that, or
+      if the cursor stops being an offset
+- [ ] Hits are not linked to their web pages. A pull's or issue's
+      `tangled.org` URL needs its appview number, which is in no record and no
+      Bobbin response — one listing sweep per repo on the page, for a listing
+      that spans repos. The `at://` URI every row prints is what `pr view`,
+      `pr diff` and `pr checkout` take, so nothing is unreachable, only
+      unclickable
+- [ ] The table still shows no author at all, only the at-uri whose authority
+      is the DID. A handle column would be the natural place for the field
+      `--json` now carries, and there is no room: the row is already nsid,
+      date, a 54-character excerpt and an uncut uri
+- [ ] `repo_did` gets no such treatment. A repo label is up to three round
+      trips (`cmd/pr/read.rs`'s `repo_name`), and a page of hits spans repos
+      the way it spans accounts, so the same cap would bite far sooner
+
+## Done
+
+- [x] `atgc search` over `sh.tangled.search.query` — Bobbin's full-text index,
+      the one question in the stack with no second source, so there is nothing
+      to merge and nothing to warn about beyond the index lag that makes a
+      recent record unfindable. Scoped to this checkout's repo by default and
+      widened by `--all`; `--nsid`, `--author`, `--since`/`--until` and
+      `--limit` narrow it; `--json` prints the derived view. A public read
+      with no session and no new scope. The lexicon is deliberately not
+      vendored: `value` and `score` are both typed `unknown`, so codegen has
+      nothing to generate and the fixture is the stronger pin — it is what
+      caught that a comment's `body` is a `sh.tangled.markup.markdown` object
+      rather than a string
+- [x] The author was a DID and nothing else, while every other listing
+      resolves a handle. `--json` now carries `author_handle` beside
+      `author_did`, resolved per unique account, eight at a time. The wait
+      this was avoiding is real at the top of the range — `--limit` goes to
+      1000 — so it is capped: over `AUTHOR_HANDLE_CAP` (50) distinct authors
+      on a page the column is dropped for the whole page and a note on stderr
+      says so, since a silent null would read as "these accounts have no
+      handles". The table is unchanged; it has no author column to put one in
diff --git a/plan/secrets.md b/plan/secrets.md
new file mode 100644
index 0000000..f33c9c0
--- /dev/null
+++ b/plan/secrets.md
@@ -0,0 +1,26 @@
+---
+id: secrets
+title: A CI secret is set from the terminal, not from a web form
+status: blocked
+repos: [atgc]
+dependsOn: [pipelines]
+exitCriterion: >
+  A secret is set, listed and removed over the knot's own procedures.
+---
+
+# secrets
+
+Nothing here is written, and it cannot usefully be until a spindle is attached
+and something needs a credential. The scopes are among the ones held back on
+purpose: destructive or account-wide permissions are added when their command
+ships, since every addition is a re-login for every account
+([sign-in](sign-in.md)).
+
+## What it needs
+
+- [ ] `secret set` / `list` / `delete` — rpc:sh.tangled.repo.addSecret /
+      listSecrets / removeSecret
+
+## Done
+
+Nothing yet. Everything this epic wants is above.
diff --git a/plan/sign-in.md b/plan/sign-in.md
new file mode 100644
index 0000000..dc0945c
--- /dev/null
+++ b/plan/sign-in.md
@@ -0,0 +1,252 @@
+---
+id: sign-in
+title: A person acts as their own ATProto account, from a terminal
+status: shipped
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  atgc holds a real grant on a real PDS, several accounts at once, and a
+  lapsed one is repaired without a re-login wherever the protocol allows it.
+---
+
+# sign-in
+
+atgc is a public loopback OAuth client. It opens a consent page in a browser,
+takes the code back on 127.0.0.1, and from then on acts as that account
+against its own PDS. Everything else in this register rests on that: a pull
+request, an issue and an SSH key are all records atgc writes into the account's
+own repository, so there is no second authority to ask.
+
+The grant is deliberately wider than what has shipped. Every scope atgc will
+ever need was asked for at once, so that a new command is not a new consent
+page — and the four times that rule was broken are recorded below, because each
+one cost every existing user a re-login.
+
+Where the credentials then live, and what it takes to not lose them, is
+[credential-store](credential-store.md). Who a handle or a DID names is
+[identity](identity.md). What the login writes down about itself is
+[logs](logs.md).
+
+## What it needs
+
+- [ ] **The confidential client was researched and rejected**, written down
+      here so nobody re-proposes it. It would have bought real things: a
+      2-year session with 3-month refresh tokens instead of the public
+      client's two weeks (`oauth-constants.ts`, `SESSION_LIFETIME_EXTENDED`
+      / `REFRESH_LIFETIME_EXTENDED` — so the claim this entry used to make
+      was correct), and the word "atgc" on the PDS consent page instead of
+      "an application on your device", which a localhost client cannot have
+      at any price.
+
+      It is rejected on a rule, not on effort. The reference authorization
+      server refuses `application_type: "native"` together with
+      `private_key_jwt` (`oauth-provider/src/client/client-manager.ts`,
+      citing RFC 8252 §8.4), and loopback and private-use-scheme redirect
+      URIs are permitted *only* to native clients. Chain the two and a
+      confidential client is a `"web"` client, every one of whose redirect
+      URIs must be `https://`. **A confidential atgc cannot have a loopback
+      callback.**
+
+      So the plan this entry used to describe — static client metadata and a
+      jwks on lance.blue — is necessary and nowhere near sufficient. It also
+      needs a *live HTTPS callback service* to receive the authorization
+      code and hand it back to a CLI that may be headless, over SSH or
+      behind NAT: either paste-back, or a stateful rendezvous that holds
+      authorization codes and is therefore a credential-handling endpoint to
+      secure, rate-limit and keep up. **atgc is not taking a hosted runtime
+      dependency to log in.** Everything else here reads a PDS directly and
+      degrades to "the index is out" rather than "the service is down", and
+      a login that requires lance.blue to be answering trades that away for
+      a longer token.
+
+      The second reason stands even if the first were lifted. The private
+      key must sit wherever the CLI runs, because the CLI mints a client
+      assertion on every refresh — so it is either shipped with the binary,
+      which makes it not a secret and hands everyone a key that impersonates
+      "atgc" to every PDS, or generated per user, in which case the consent
+      page shows their name and not ours, which was the point. Proposal 0010
+      (client-assertion-backend) is the named escape hatch upstream and
+      targets browser SPAs; it does not answer this for a CLI.
+
+      Two findings worth keeping out of the wreckage. jacquard already
+      supports confidential clients end to end and would need no patching —
+      `keyset.rs` (`generate_es256`, `public_jwks`), `atproto.rs`
+      (`AuthMethod::PrivateKeyJwt`, inline `jwks`) and `request.rs`
+      (`build_auth` mints an RFC 7523 assertion). And the cheaper goal is
+      still open: if the complaint is re-authorizing every fortnight, make a
+      lapsed session pleasant — `auth refresh` exists, and a clear prompt at
+      the moment one lapses costs hours and adds no service. Revisit only if
+      the spec permits a confidential client a loopback redirect
+- [ ] `auth logout` still does not revoke, recorded above as deliberate and
+      worth revisiting now the session cap is measured at two weeks: the
+      revocation endpoint is stored and the vendored crate's
+      `OAuthSession::logout` calls it, so this is a request rather than a
+      design problem. A logout that leaves a two-week credential live on a
+      machine somebody has finished with is the case against
+- [ ] Narrow the `blob:*/*` scope. Patch blobs are application/gzip and body
+      images are image/*; the scope grants every MIME type, which is still
+      wider than the two families atgc actually uploads
+- [ ] Add held-back scopes when their commands ship: secrets,
+      collaborator/membership changes. publicKey shipped with `key`, and
+      cost the second re-login — holding it back was the wrong call, since
+      registering a key is what makes a *newly added account* able to do
+      anything at all. deleteBranch shipped with `repo delete-branch` and
+      cost the third, repo.delete with `repo delete` and the fourth; unlike
+      publicKey, holding those two back was fine — both are destructive and
+      nothing else in atgc needed them sooner
+
+## Done
+
+- [x] Loopback OAuth login with granular scopes (jacquard)
+- [x] Identity page on the OAuth callback; @-prefixed handles; real handle
+      in login and `auth status` output
+- [x] That page now lands on `about`'s field of DNA, prerendered into it by
+      the process that serves it, with the profile's display name, handle and
+      avatar escaped on the way in rather than trusted. The art is one `
`
+      and the animation is an opacity breath and a drifting highlight, both
+      off under `prefers-reduced-motion`
+- [x] Prune stale sessions on login — now scoped to the DID logging in, so
+      repeat logins of one account collapse to a single grant and other
+      accounts are untouched (the any-hint resume it originally fixed is
+      gone; every resume names its DID)
+- [x] Preemptive scopes for the non-destructive roadmap (single consent).
+      Broken once, by `pr comment`: the list asked for the deprecated
+      `sh.tangled.repo.pull.comment` and not the `sh.tangled.feed.comment`
+      that replaced it, so a session holding every other scope still got a
+      403 from the PDS on the first real comment. Adding it changed the
+      client_id, which is a re-login for everyone; the legacy entry stays
+      beside it, since deleting a legacy comment still needs it
+- [x] Several accounts at once, keyed by DID: an accounts.json registry
+      beside jacquard's session store, `--account` / `ATGC_ACCOUNT` and a
+      checkout's own `user.email` DID feeding one precedence chain. Login
+      moved to `atgc auth login`; bare `atgc login` stays as a hidden alias
+- [x] `auth login` is additive and stops there — it no longer moves the
+      active pointer, so authorizing a second account cannot silently change
+      who atgc acts as. It still sets the pointer when there is none, so a
+      first login survives a second one. Instead of switching it prints what
+      would actually make that account active, worked out from the state at
+      the time: a switch, or the env var / checkout identity that outranks
+      one. Nothing is printed when the account is already the active one
+- [x] `auth logout` — log out one account (the active one, or a named
+      handle/DID), `--all` for every account. Local only: the tokens are
+      deleted, not revoked, and expire on their own
+- [x] `auth refresh` — exchange the active account's access token when it
+      has run out or is close to it; a healthy session is left alone
+- [x] `auth status` — every account, the active one marked with what
+      selected it, plus PDS, scopes and session expiry for that one
+      (replaced `whoami`, which no longer exists). An account whose token
+      lapsed still lists — expired is not logged out
+- [x] `auth switch` — move the persisted active-account pointer; says so
+      when something more specific outranks it anyway. That check used to
+      cover the checkout's own .git/config and nothing else, so an exported
+      ATGC_ACCOUNT went unmentioned; it now shares one routine with
+      `auth login` and covers both
+- [x] `auth token` — print the active account's access token for scripting
+      (refreshed first; DPoP-bound, so a bare Bearer header won't do)
+- [x] `auth login` says what it is waiting for. Discovery — handle to DID to
+      PDS to authorization server — is several requests to hosts atgc has
+      never contacted, and it used to run in complete silence, so a slow one
+      was a dead terminal that reads as a hang. It now prints the handle it is
+      resolving, times the step under `--debug`, and writes an
+      `authorize_ready` with the elapsed ms. That last one is what makes the
+      log readable after the fact: an `authorize_start` alone now means
+      discovery is still running, where before it could equally mean the
+      browser was never used
+- [x] `auth login` stopped printing the authorization URL at people. It is a
+      few hundred characters of PAR request, wrapped over several lines,
+      burying every other line of the login; now a terminal that renders
+      OSC 8 gets the site's name and a "click here" link instead, and only a
+      pipe, a redirect or an older terminal sees the URL — there it is the
+      only way through. The line also says whether a browser actually opened,
+      and offers the link either way, since an opener exiting zero is not
+      proof anyone is looking at a consent page. A `browser_open` event
+      records the same thing for the log
+- [x] Settle the session-destruction incident from the log. **Hypothesis 1,
+      confirmed by the authorization server in its own words.** A session was
+      destroyed a third time, during an `atgc pr create`, and this time the
+      log was running. bsky.social refused the refresh with
+      `invalid_grant` / "Token was not issued to this client", presented
+      under `client_id` fingerprint `cddefa6e` with no redirect URI, and
+      jacquard deleted the session on the spot. No two invocations were alive
+      at the same time and the refresh token had been read only sequentially,
+      minutes apart, so hypothesis 2 is ruled out for this failure.
+      `atgc auth log --incident` on `~/.config/atgc/oauth.jsonl` reproduces
+      all of it
+- [x] Fix what that proved: `auth login` records the `client_id` its grant
+      was issued to in `accounts.json`, and every later resume presents that
+      exact string instead of building a fresh one with no redirect URI.
+      Verbatim rather than rebuilt from the port, so a future `SCOPES` edit
+      cannot invalidate a live grant either. Sessions granted before this
+      cannot be repaired — the port is gone — and need one more login, which
+      the refresh error now says by name. Proven as far as it can be without
+      waiting: a resume given the recorded value reproduces `ddd93b67`, the
+      client the incident's grant was issued to, where building it fresh
+      still gives the refused `cddefa6e`. **A successful refresh an hour
+      after login has still not been observed**, because that is the only
+      way to observe it
+- [x] `auth login` survives a granted scope it cannot parse. bsky.social
+      answered a login with `repo:` — a repo scope with no collection, so an
+      empty NSID — and the vendored crate's `Scopes::new().expect()` took the
+      process down inside the callback: exit 101, nothing on the wire, and a
+      browser holding a connection error because the loopback server had died
+      with it. A grant is the server's answer and its vocabulary is the
+      server's to extend, so `Scopes::parse_lossy` now keeps every token it
+      can read and hands back the ones it cannot; strict parsing still
+      applies to the scope string atgc composes. The refresh path had the
+      same `expect`, one command deeper
+- [x] The browser gets the failure, not a shrug. The callback page said
+      "return to the terminal for details", which is a second lookup for
+      something atgc already knows and was wrong outright when the process
+      never reached the terminal. It now carries the error itself, escaped
+      and scrubbed, on the same card as the success page — and the token
+      exchange runs on a task of its own, so a panic in it is a failed login
+      with the panic's own file and line on both screens and status 1,
+      rather than a dead port and two screens of nothing
+- [x] `token_granted` records the scope the grant came back with. The
+      request half was already logged as `authorize_start`'s `scopes` and the
+      answer was not, so the word that aborted a login existed only in a
+      response body this log deliberately never writes down. The one field of
+      a success body worth the exception — it is not a credential, it is the
+      list of what the credentials may do. `auth login` also names any
+      granted scope word it cannot read, once, at the moment it arrives
+- [x] A denied consent and a callback with no code printed one sentence
+      between them. The redirect carries `error` and `error_description` and
+      `callback_query` dropped both, so "authorization was denied or the
+      callback had no code" was a guess covering states the server had named
+      precisely. `callback_query` now returns a `Callback` struct carrying
+      both, and `refusal` splits three ways: `access_denied` is "you declined
+      the authorization request" and not a fault, a server error leads with
+      the server's own `error_description` and keeps the code beside it for a
+      bug report, and a redirect with neither a code nor an error says it
+      should not happen — which it should not, and which the old sentence hid
+      among the ordinary refusals. The OAuth log keeps the machine-readable
+      token instead of the prose. Driven end to end against a real
+      bsky.social authorization with a hand-made callback
+- [x] Any local process could end a login. The loopback listener bailed on
+      the first callback without a `code`, so anything able to reach an
+      ephemeral port on 127.0.0.1 could make `auth login` unusable by
+      scanning. A nuisance rather than a hijack — `state`, PKCE and `iss` are
+      all checked in the vendored client and the listener is loopback-only.
+      It now answers a request that does not carry this login's `state`, logs
+      it as `ignored`, and goes back to accepting until the five-minute
+      timeout. A *denial* carrying the right state still ends the login at
+      once: the person pressed Deny and should not sit through the wait.
+      Verified against a real bsky.social authorization, three scans and then
+      the genuine article
+- [x] **The login had no way to know its own `state`.** `state_param` reads it
+      out of the authorization URL, and under PAR — which every server atgc
+      has met supports — the parameters go in the POST body and the URL is
+      `client_id` plus a `request_uri` and nothing else. So it answered `None`
+      on every real login, which is why the entry above had nothing to
+      discriminate on and why `discard_auth_state` returned immediately every
+      single time. `state_written_by` diffs the `oauth-state:` keys either
+      side of `start_auth`: jacquard files the PKCE verifier and the DPoP key
+      under `oauth-state:`, so this login's state is the key that was
+      not there a moment ago. Two appearing at once is not attributed to
+      either — leaking an entry costs a few hundred bytes, deleting another
+      process's is a login that fails at its callback. `state_param` stays as
+      the fallback for a server with no PAR endpoint
+- [x] The third exit from the login wait had no cleanup. A timeout and a
+      failed token exchange both called `discard_auth_state`; a callback that
+      arrived and was unusable — a denied consent — returned straight through
+      the `?`. Now all three do
diff --git a/plan/stacks.md b/plan/stacks.md
new file mode 100644
index 0000000..389a7ff
--- /dev/null
+++ b/plan/stacks.md
@@ -0,0 +1,372 @@
+---
+id: stacks
+title: A change that is several pull requests stays one chain
+status: open
+repos: [atgc]
+dependsOn: [pull-requests, branch-pulls]
+exitCriterion: >
+  A branch is cut into dependent pulls, rewritten, reconciled and merged
+  bottom-up, and no rewrite loses a pull's number, rounds or review comments.
+---
+
+# stacks
+
+A Tangled stack is a chain of ordinary pull records, each `dependentOn` the
+pull beneath it. A member is a contiguous *run* of commits — nothing in the
+protocol ties one to a single commit — cut at the marks `atgc stack mark`
+records, and one per commit on a branch with none. Identity across rewrites is
+a `Change-Id:` mail header per commit in the round's patch. There is no
+stackId, no stack XRPC: the appview indexes the chain off the firehose and
+rejects would-be DAGs at ingest.
+
+Design settled 2026-08-09: `stack` commands bail with a pointer when the
+situation is not stacked; `pr` commands keep working on stack members and warn
+where the stack command is better. Revised 2026-08-24: one-pull-per-commit was
+atgc's shape, not Tangled's, and it made agents file five pull requests for one
+change.
+
+The reconcile is the whole risk. It plans a fate per member — keep, round,
+create, delete, relink — and writes them as one `applyWrites`, because a chain
+built a record at a time passes through a state the appview refuses. Two
+adversarial rounds against injected damage and against ordinary git shapes are
+under Done, and they are the reason the refusals are as loud as they are.
+
+## What it needs
+
+- [ ] A member *above* an amended commit gains a round even though its diff
+      is identical: `git format-patch` puts the commit sha in a patch's first
+      line, a rebase gives every commit above the rewrite a new sha, and the
+      reconcile compares patch bytes. Defensible — the patch does now apply
+      to a different base — and pinned by
+      `amending_one_commit_appends_a_round_to_the_record_carrying_its_change_id`
+      so a decision to change it is a deliberate one. Whether Tangled's own
+      resubmit agrees is unchecked
+- [ ] `stack create` still plans *after* the rewrite, so the refusals that
+      come out of planning — an empty patch, a commit message naming an image
+      that left the disk, two commits sharing a change-id — still arrive with
+      the branch already moved. They are annotated with the old tip rather
+      than hoisted, which is the cheap half of the fix. The expensive half is
+      what `--dry-run` already does: plan on the pre-rewrite commits with the
+      ids the rewrite would mint, refuse there, and rewrite last. `resubmit`
+      got the one refusal that mattered (the `--prune` offer) moved up; the
+      rest are the same shape and the same work
+- [ ] Nothing tests what a failed *blob upload* or a refused `applyWrites`
+      leaves behind, which after the fix above are the only two things that
+      can still fail with the branch rewritten. `note_rewrite` covers them by
+      construction; a test would pin the wording
+
+## Done
+
+- [x] Foundations: `dependentOn` named on the pull record (was preserved
+      opaquely in `Pull::extra`), per-commit git helpers (`commits_since`,
+      `format_patch_one`, `change_id` — jj commit header first, else
+      Gerrit-style trailer), and `record::batch` over
+      `com.atproto.repo.applyWrites` — atomic so a reconcile cannot
+      half-land, and one firehose event so a reorder never passes through
+      the transient two-pulls-on-one-parent state the appview rejects
+- [x] `stack create` — one pull per commit of `base..HEAD`, single-commit
+      patches each carrying a `Change-Id:` mail header atgc injects (checked
+      empirically: `git format-patch` does not surface jj's commit header,
+      so injection is the only path), chained bottom-up with TID rkeys
+      minted client-side, one applyWrites. Refuses a single-commit range
+      (that is `pr create`), a branch whose pulls already exist, and commits
+      with no change-id. `--add-change-ids` is the announced rewrite: pure
+      `commit-tree` (trees and authorship byte-for-byte, no conflict
+      possible), untouched prefix keeps its shas and jj headers, existing
+      ids are restated as trailers, and the reflog records the old tip.
+      Proven live 2026-08-09 against the atgc-stack-lab scratch repo: three
+      records in one applyWrites, ids minted by the rewrite, appview
+      indexed and numbered the chain
+- [x] `--add-change-ids` refuses when `HEAD` is not built on the base any
+      more. The rewrite is `commit-tree`, which reuses each commit's tree and
+      only changes its parent, so reparenting onto a base the tree never saw
+      turned every patch into a deletion of whatever that base had gained.
+      Observed 2026-08-13 on 0.15.0: a branch based on `55c4df0` was
+      submitted after `origin/main` had moved to `248281f`, and the rewrite
+      produced commits *parented on* `248281f` but carrying the pre-merge
+      trees, so every patch in the stack carried `src/exit.rs | 298 ----` and
+      the stack would have reverted a merged PR on landing. A real rebase
+      cannot do this — none of the commits touched that file, so replaying
+      them on a parent that has it keeps it — so this is tree-preserving
+      reparenting rather than a replay. Silent: the only tell was one patch
+      being five times its own dry-run size. Reported by an agent that hit it
+      for real (pull 183). Fixed in d04a596: `rewrite_with_change_ids`
+      (src/clients/git/patch.rs) bails unless `base` is an ancestor of `HEAD`,
+      and names the rebase that fixes it. Of the two options, refusing is
+      settled — rebasing on the caller's behalf was rejected because
+      `--add-change-ids` is announced as a rewrite that cannot conflict and a
+      rebase can, so the step that might stop and ask stays the caller's to
+      run. Covered by
+      `adding_change_ids_on_a_stale_base_never_produces_a_revert`
+- [x] `stack create`'s summary marks the change-id column as abbreviated. The
+      Change-Id trailer is a full 40-hex string and the summary printed only
+      the first 8, which reads like the whole id: nine characters of a
+      forty-one character value in a fixed column reads as the whole value,
+      and somebody rebuilding a branch by hand retyped what they saw. The
+      commits then matched no pull, `stack resubmit` reported "N open pull(s)
+      match no commit on the branch any more" and correctly offered
+      `--prune`, which would have deleted four pull records and every review
+      comment on them. Of the two options — print the whole id, or say it is
+      abbreviated — the second shipped: `ellipsize_change_id`
+      (src/cmd/stack/write.rs) appends `…` when it cuts, and its doc comment
+      says the ellipsis is the whole point of the function. `--json` carries
+      it whole, for a caller that needs the value rather than recognizes it
+- [x] A member holds as many commits as its change needs. One pull per commit
+      was read off jj's workflow and written into the docs as though the
+      protocol required it; it does not, and the cost was agents filing a
+      pull request per commit for changes that were one change. A member's
+      patch is now a mailbox with a `Change-Id:` header per commit
+      (`with_change_id_headers`), and the reconcile matches a record to a run
+      when any of the run's ids is one the record already claimed, so an
+      amend anywhere inside a member stays inside it. Every stack written
+      before this reconciles unchanged
+- [x] Where the members end is *marks*: local branches recorded per branch in
+      `branch..atgcMark`, placed by `atgc stack mark ` (named
+      after the commit it lands on) and read back by every stack write. The
+      first cut inferred them — any branch whose tip fell in the range — and
+      that is wrong in a working repository: `git branch backup` before a
+      rebase, or an abandoned worktree's branch, silently re-cut the stack.
+      `gh stack` reaches the same shape from the other side: a stack is
+      `init`ed and its layers `add`ed, never guessed at. With no marks left, the
+      cuts come back out of the records' change-id headers, so a fresh clone
+      still reconciles
+- [x] Not done, and it should not be: naming a mark after the member's
+      *bottom* commit, to match the pull title `stack create` takes from
+      there. The mark is named after the commit it lands on, which is the
+      member's top, so for a grouped member the branch name and the pull
+      title come from different commits. The fix does not exist: a member's
+      bottom commit is whatever the mark below it ends at, and that mark may
+      not have been placed yet — `stack mark part2 HEAD~1` after
+      `stack mark part1 HEAD~3` changes what part2's bottom is, retroactively.
+      Naming from the bottom would be wrong as often as it is right, and the
+      listings already print the subject next to the name, so nothing is
+      being hidden
+- [x] Seams the stack work left untested, now pinned: `stack view`'s `?` for
+      a member whose patch blob is gone, a mark whose branch was deleted,
+      `--add-change-ids` carrying the marks through the rewrite that moves
+      every commit under them, and `pr diff` on a member holding more than
+      one commit
+- [x] `atgc stack up`, `down`, `top`, `bottom` and `checkout` — move between
+      a stack's members. The marks already say where each one ends, and the
+      branches are already there; what was missing was the reverse index, so
+      standing on a mark could not say which stack it cut. `owners_of` reads
+      it back out of the config that recorded it, which is the second reason
+      marks are recorded rather than inferred. Walking past an end stops
+      there rather than refusing: `up` run until it stops is how a script
+      finds the top
+- [x] `atgc stack link` — chain pull requests that already exist, bottom
+      first, without reopening them. `dependentOn` is one field per record,
+      but until now the only way to write it was to build a chain from a
+      branch, so making three separately-opened pulls into a stack meant
+      cherry-picking them onto one branch, opening new pulls and closing the
+      originals: their numbers, rounds and review comments thrown away to
+      express an ordering. Written as one `applyWrites` because a chain built
+      a record at a time passes through a two-pulls-on-one-parent state the
+      appview refuses at ingest, and the order is checked against git wherever
+      both source branches are in the checkout
+- [x] `atgc stack rebase` and `atgc stack sync`. Marks live in git, so they
+      have to travel when the branch does: rebase replays with
+      `--update-refs`, which a plain `git rebase` does not, and sync is that
+      followed by the reconcile — the pairing `gh stack sync` and Graphite's
+      `gt sync` both ship. Sync stops after the rebase if the rebase stops,
+      because a reconcile planned against a half-rebased branch would write
+      rounds for commits that do not exist yet. The halves stay for the cases
+      that want one: an amend needs no fetch, a conflict needs no reconcile
+- [x] `stack create` refuses a cut that leaves one member, and asks before
+      opening a pull per commit on an unmarked branch of more than three.
+      The first was reachable with a mark on the top commit and wrote a pull
+      with no `dependentOn` — a pull request wearing a stack's command. The
+      second is the original complaint: eight commits became eight pull
+      requests with nothing said, and closing them is eight more acts
+- [x] `stack resubmit` — reconcile the records with the rewritten branch by
+      change-id (read off each member's latest round patch, where alone it
+      exists): matched commits with changed bytes append a round, identical
+      bytes append nothing — one deliberate divergence from Tangled, whose
+      resubmit rounds every member; it is what makes a rerun a no-op, which
+      is also the partial-failure recovery. New commits create records,
+      vanished ones delete records only under `--prune` (a vanished *merged*
+      member becomes the chain's anchor instead — the new bottom keeps
+      depending on it), the chain is relinked to the new order, and it all
+      goes in one applyWrites. Refuses: members whose state cannot be pinned
+      as unmerged while Bobbin lags (for any destructive fate), updates to
+      merged pulls, stacks not authored by the acting account, and members
+      whose rounds carry no Change-Id header. The reconcile planner is pure
+      and unit-tested, and the write path is proven live against the
+      atgc-stack-lab scratch repo: no-op rerun, amend (rounds only where
+      bytes changed), insert + drop + relink + `--prune` in one batch, and
+      the prune refusal — all against real records
+- [x] `stack resubmit` treated a freshly created stack as untouchable for
+      hours: a pull nobody has acted on has no status record anywhere, so
+      every member showed `?` until Bobbin indexed, and the unknown-state
+      refusal fired on the stack's own author. Found live, first resubmit
+      after the first live create. When the acting account owns the target
+      repo, both authorities Bobbin honours for status records are that
+      account, so its own PDS is *complete* and `?` resolves to open;
+      without ownership the conservative refusal stands
+- [x] `stack create` put the Change-Id trailer into pull bodies: a commit
+      whose body was only its added trailer produced a pull whose entire
+      description was `Change-Id: I…`. Found live on the first created
+      stack. Bodies now drop Change-Id lines from the final trailer
+      paragraph — other trailers stay, and a Change-Id quoted mid-body is
+      prose and stays too
+- [x] `stack view` — the whole chain with numbers, states and rounds, top
+      first; bails on an unstacked pull, pointing at `pr view`. The chain is
+      assembled in `stack::chain_containing` by following `dependentOn` both
+      ways, refusing forks (which the appview rejects at ingest, so one in a
+      listing means damaged data) and loops rather than smoothing them over;
+      a link naming a record outside the listing is reported as "continues
+      below", not treated as the bottom
+- [x] `pr view` shows stack context implicitly. Every member shares
+      `source.branch`, so the branch match on a stacked branch used to pick
+      whichever member was created last and say nothing about the others — a
+      wrong answer in the shape of a right one. It now shows the *top* pull
+      as the detail and prints the chain under it
+- [x] `pr resubmit` refuses a stack member outright — the one refusal in
+      "pr commands work on stack members": a whole-branch round is one the
+      appview can read no change-id from, which breaks the stack's
+      correlation for every later reconcile, permanently. The check reads
+      only the author's own PDS, on which no index can be behind
+- [x] `pr create` warns (never blocks — the check is best-effort) when the
+      branch's pulls already form a stack, naming `stack resubmit` as the
+      way to update the stack itself
+- [x] `pr checkout` of a stacked pull applies the pulls beneath it first,
+      bottom up — walked record by record along `dependentOn` (public reads,
+      no index consulted), latest round each, `--only` for the bare patch.
+      The stacked apply runs `git am --empty=drop`, so a member already
+      merged into the target three-way-resolves to nothing and drops.
+      Proven live against atgc-stack-lab: mid-stack, top and `--only` all
+      produce the right trees
+- [x] The fresh-stack `?` resolution moved from `stack resubmit` into
+      `repo_rows`, so `stack view` and the guards see it too: an authored
+      pull in an owned repo with no status record anywhere is open, not `?`.
+      Scoped to authored-and-owned on purpose — anyone else's pull can have
+      state sitting in a PDS the read never saw
+- [x] `stack merge` — the sub-stack from the bottom `--through` a position
+      (default: all of it) as one combined patch, the way Tangled's own
+      button lands a stack: `mergeCheck` first (also what `--dry-run`
+      stops after), one `merge`, then a merged status record per landed
+      pull, bottom up. Already-merged members contribute nothing; anything
+      not cleanly open is refused. Proven live on atgc-stack-lab, both
+      halves: `--through 1` landed the bottom alone, and after a rebase the
+      resubmit reconciled the survivors around the merged member as the
+      chain's anchor — the one fate that had only unit tests — then a full
+      `stack merge` landed the rest. The complete lifecycle (create, view,
+      amend, resubmit, checkout, partial merge, anchor reconcile, final
+      merge) has now run CLI-only against real records
+- [x] Adversarial round against injected damage (fork: two pulls on one
+      parent; cycle; dangling dependentOn; duplicate change-ids). Found and
+      fixed: a damaged chain — records the appview refuses at ingest but a
+      PDS happily holds — bricked `pr view` *and* `pr create` for the whole
+      branch. Both now degrade: `pr view` warns and shows the newest match
+      flat, `pr create`'s stack warning is best-effort all the way down.
+      `stack view`/`stack resubmit` keep their loud, precise refusals — the
+      stack commands are where damage should stop someone. Duplicate
+      change-ids (a cherry-pick keeps the trailer, so this is an ordinary
+      accident) are now refused at `stack create` and on both sides of the
+      reconcile, naming every claimant; a HashMap was silently keeping the
+      last one, which would have routed rounds to the wrong pull. A deleted
+      mid-chain record shows as "continues below", named as a possibility
+- [x] Adversarial round two, against ordinary git shapes. A **merge commit
+      in the range** produced a silent nonsense stack: rev-list interleaved
+      both parents' histories, the merge itself has no single patch, and
+      the change-id rewrite would have flattened it with wrong intermediate
+      diffs — now refused by every stack write (`git rebase `
+      linearizes). An **empty commit** created a stack the knot refuses to
+      merge (its check answers "conflicted" with no files) — now refused at
+      create and resubmit, and the empty-conflict report prints the knot's
+      message instead of a blank list. `pr checkout`'s `--empty=drop`
+      silently vanished an empty member from the checked-out branch — now
+      `--empty=keep` everywhere, so every patch in the input is visible in
+      the result and a flat empty pull checks out instead of stopping with
+      advice about a moved base. Hostile titles (backticks, quotes, emoji,
+      HTML, long) survived create, view, numbering and checkout untouched
+- [x] Image-upload interplay pass, after body images landed mid-refactor.
+      The consolidation held (`create_pull_op` carries image handling once
+      for create and the reconcile's Add; the Update arm merges blobs the
+      same way flat resubmit does), but the strict image scan ran at
+      *planning* time over every commit — so a Keep member whose embedded
+      image left the disk (deleted by a later commit, never committed, or a
+      different worktree) bricked every resubmit, no-op reruns included.
+      The scan's refusal is now deferred to the fate that publishes:
+      proven live — image-bearing bottom kept while its file was gone,
+      no-op rerun clean, and the amended case still refused by name with
+      both remedies
+- [x] Safety batch from the first full code review of the merged stack:
+      mergeCheck's verdict must be *present* (a truncated 2xx body read as
+      "clean" and merged); `target.branch` is read strictly (the "main"
+      fallback could land a merge on a branch nobody named); truncated
+      listings and dangling `missing_below` chains refuse the write
+      commands instead of re-minting or false-bottom-merging (repo_rows now
+      returns the flag gather always had); the changed-bytes compare strips
+      format-patch's version signature (raw equality broke rerun-is-a-no-op
+      across git versions and web rounds); forks are refused from every
+      chain entry point, not just the parent's side; resubmit's applyWrites
+      sends `swapCommit` read before the member re-reads (pds::latest_commit);
+      `stack create`'s collision check reads the PDS alone so a Bobbin
+      stall cannot block it; the entry branch-match is scoped to the acting
+      account's own pulls; CRLF bodies normalize before trailer stripping;
+      `latest_round_patch` bounds the wire's Content-Length like fetch_patch
+- [x] Concurrent member reads, the last of that review batch. `resubmit` and
+      `merge` both read every member with one `.await` per loop iteration,
+      and each read is a `getRecord` plus a blob download — the latest
+      round's patch, where the change-id lives and whose bytes decide whether
+      anything changed — so a ten-member stack was ten round trips end to end
+      before either command could plan anything. Both go through
+      `read_members` now, `buffered` at `MEMBER_READ_CONCURRENCY`. Ordered
+      rather than unordered, because both callers build a `Vec` indexed by
+      position in the chain and the chain's order is the whole subject;
+      `reordering_the_branch_relinks_the_chain_onto_the_new_order` is what
+      holds that, its three members all reading at once at this concurrency
+- [x] The rest of that batch had already landed — the
+      rest landed: the listing-chain-states preamble is one loader now
+      (`complete_rows`/`own_chain` in `stack/mod.rs`), the blob-fetch path
+      is shared (`pds::blob_bounded`, used by `review` and the stack),
+      resubmit's Add slot reuses create's record builder
+      (`create_pull_op`), and of the performance batch, patches gzip once
+      and merged statuses write as one batch. Resubmit and merge still
+      read every member with one `.await` per loop iteration — no
+      `join_all`/`try_join_all` anywhere in the file
+- [x] `pr view ` details the pull named, instead of substituting its
+      stack's top for it. Every member of a stack used to answer with the
+      top's title, body, rounds and state — under the member's own number,
+      rkey, at:// URI or URL, with only `--json`'s `url` disagreeing with
+      the question. The top promotion was right for the *branch* form, where
+      every member matches `source.branch`, and was being applied to both.
+      The chain listing now marks the member and says which of how many it
+      is, and `--json`'s `stack` gained `position`
+- [x] A *closed* stack member whose commits left the branch no longer needs
+      `--prune`. It is kept as-is and left out of the chain, with the
+      members around it relinked — closing a pull is how you take work out
+      of a stack deliberately, usually with a comment saying why, and
+      deletion was the only exit the reconcile offered. `--prune` is now
+      open pulls only, and the refusal says closing is the alternative
+- [x] `pr close` warns when open pulls sit above the target in a stack, and
+      names them rather than counting them: "3 pulls depend on this" says
+      there is something to check and not what to check. Transitive
+      dependents are included, `chain_containing` already walking the whole
+      chain. Not a refusal — closing a member is the documented way to take
+      work out of a stack, and the note says `stack resubmit` relinks the
+      chain around it. `pr reopen` says nothing: restoring a member's open
+      state repairs the dependency a close broke. Best effort throughout, and
+      silent when the listing will not load: this is a note attached to a
+      write that is going to happen anyway, and a read that fails is a reason
+      to say nothing rather than to refuse a close somebody asked for
+- [x] `pr view`/`pr list` printed `?` for the same fresh pulls that `stack
+      view` called open, because the promotion lived in `repo_rows` and they
+      read through `gather` directly. Done since, and the other way round
+      from what this entry proposed: `settle_own_open` moved *into* `gather`,
+      so every listing gets it and `repo_rows` gets it by going through the
+      same door. Its comment there says so
+- [x] `--add-change-ids` rewrites the branch before atgc has a session.
+      `ensure_change_ids` moves `refs/heads/` at the top of both
+      `create` and `resubmit`, and `agent_for_did` is not called until a
+      hundred lines later. So an expired session — the ordinary case —
+      rewrites the commits and then fails with "could not refresh the
+      session", leaving new shas and no pulls. It is worse in `resubmit`: the
+      rewrite mints fresh `I` ids for the commits that lacked one,
+      `reconcile` then refuses because N open pulls match no commit on the
+      branch any more, and the remedy it offers is `--prune`, which deletes
+      those pulls and their review comments. The session is a pure
+      prerequisite and can simply be taken first. `git reflog` is named only
+      in the success message and wants naming in every failure below the
+      rewrite, with the pre-rewrite sha the rewrite already holds
diff --git a/plan/testing.md b/plan/testing.md
new file mode 100644
index 0000000..36ac56c
--- /dev/null
+++ b/plan/testing.md
@@ -0,0 +1,97 @@
+---
+id: testing
+title: A defect in a sequence of commands is caught here, not in production
+status: open
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  Every command family has a flow test driving the real binary against a mock
+  service, and the mock behaves the way the real service does.
+---
+
+# testing
+
+Unit tests cannot see the thing that actually goes wrong here, which is the
+request side of a *sequence*: a round appended to the wrong record, a chain
+whose `dependentOn` points outside its own batch, a write made with the wrong
+account's credentials, a read-modify-write that drops the rounds it was not
+editing.
+
+So `tests/support/` drives the built binary against mock services on loopback
+ports. Two properties make it hermetic: `Command::env` is not `unsafe`, so a
+child may have its own `HOME`, and jacquard restores an unexpired fixture
+session without contacting anything.
+
+The argument against mocking a *parser* is unchanged and is in
+`docs/testing.md`. What is open is the coverage that is missing and one way the
+mock is not faithful.
+
+## What it needs
+
+- [ ] The `auth` subsystem still has no flow test. The store now has unit
+      coverage — the elided write, the new inode and mode per write, the key
+      spellings `logout` filters on, and a pretty-printed fixture standing in
+      for every store written by a previous version — but nothing exercises
+      `login`, `switch`, `logout`, `refresh` or `token` end to end, which is
+      a large part of why these defects lasted. That wants `tests/support/`
+      rather than unit tests: `Command::env` gives a child its own `HOME`,
+      and jacquard restores a fixture session with no network. Whoever takes
+      it should read `tests/support/mod.rs`, `tests/support/account.rs` and
+      `docs/testing.md` first — this was left undone for want of those, not
+      because the coverage is unwanted
+- [ ] The mock PDS serves `listRecords` oldest-first, by ascending record
+      key, where a real one lists newest first. Every paging test therefore
+      arranges its keys backwards from reality, and the one rule that reads
+      the *order* rather than the count — the status walk's floor stop, which
+      takes the last record on a page as the oldest seen — cannot be
+      exercised against it at all. Fixing it means reversing the mock and
+      re-reading every test that plants more than one record in a collection
+
+## Done
+
+- [x] `tempfile` as a dev-dependency. Eleven hand-rolled temp-directory
+      helpers across `testutil.rs`, `config/`, `cmd/`, `logging/` and
+      `tests/` — not nine — plus seven more directories built inline at the
+      test that used them, four with a `Drop` impl of their own, most
+      disambiguating by pid, which is an assumption about how the suite runs.
+      Dev-only, so it never ships. Production `write_atomic` stays as it is:
+      it sets the mode on the `open(2)` call, which `NamedTempFile::persist`
+      would regress.
+      The one thing that did not survive a straight swap, recorded because it
+      is invisible until something asserts on it: **`tempfile` creates a
+      directory `0o777 & ~umask`**, where the helpers it replaced handed back
+      a path that did not exist yet and let `create_private` make it `0700`.
+      `create_private` is `DirBuilder::recursive(true).mode(…)`, and that
+      applies its mode only to a directory it actually creates — so on an
+      existing one it is a silent no-op. Two consequences. The narrowing test
+      caught it directly, and now creates a child of the throwaway root so
+      the directory under test is one `create_private` made. And the five
+      helpers standing in for `~/.config/atgc` are pinned to `0700` through
+      `Builder::permissions`, because a test that plants a `0600` store file
+      inside a world-traversable directory is checking something weaker than
+      the thing it names. The ordinary scratch directories are left at the
+      default, which is what `create_dir_all` gave them before
+- [x] Integration environments: whole *sequences* of commands driven through
+      the built binary against mock services on loopback ports
+      (`tests/support/`, `tests/stack_flows.rs`, `tests/pr_flows.rs`). What
+      they assert is the request side — what atgc sends, in what order, as
+      whom — which no fixture can observe and no unit test can reach: a round
+      appended to the wrong record, a chain whose `dependentOn` points outside
+      its own batch, a write made with the wrong account's credentials, a
+      read-modify-write that drops the rounds it was not editing. Two
+      properties make it hermetic: `Command::env` is not `unsafe`, so a child
+      may have its own `HOME`, and jacquard returns an unexpired session
+      without contacting anything, so a fixture session restores offline. The
+      argument against mocking a *parser* is unchanged; see docs/testing.md
+- [x] `Cli::command().debug_assert()` runs in `main.rs`'s
+      `no_duplicate_ids_or_dangling_references_anywhere_in_the_command_tree`.
+      It walks every subcommand, which is what nothing else in the suite
+      does — parsing a line only builds the subcommands on that line, so a
+      malformed definition three commands away stays invisible until
+      somebody types it. Found nothing on the tree as it stands, which is
+      worth saying only because the check was proven to bite first: giving
+      `--no-input` a `-q` short makes it fail with "Short option names must
+      be unique for each argument, but '-q' is in use by both 'quiet' and
+      'no_input'". The `--account` collision that
+      `auth_switch_and_logout_do_not_collide_with_the_global_account_flag`
+      documents is the same class, and was found by hand months late
diff --git a/plan/toolchain.md b/plan/toolchain.md
new file mode 100644
index 0000000..cb47b6c
--- /dev/null
+++ b/plan/toolchain.md
@@ -0,0 +1,72 @@
+---
+id: toolchain
+title: The build is cheap enough to run two of
+status: shipped
+repos: [atgc]
+dependsOn: []
+exitCriterion: >
+  A fresh worktree builds on a pinned toolchain, with the language floor
+  enforced on every build rather than asserted once.
+---
+
+# toolchain
+
+`rust-version = "1.89"` is set by three things independently, and declaring it
+turns clippy's `incompatible_msrv` on, so the floor is enforced rather than
+believed. Dependencies build without debug info and this crate keeps all of it,
+which took a full debug build from 2.43 GiB to 1.66 with 253 dependency source
+files still carrying line tables.
+
+One `target/` per worktree, cargo's default, and no scheme on top of it.
+Sharing the build across worktrees was tried twice and is settled as a no: the
+uplifted `target/debug/atgc` is one name written by whichever worktree last
+compiled, and it is what `env!("CARGO_BIN_EXE_atgc")` bakes into the
+integration tests.
+
+## What it needs
+
+- [ ] Every fresh worktree pays one >4 GiB compile, and it is not the debug
+      info. `[patch.crates-io] jacquard-oauth = { path = "vendor/…" }` puts an
+      absolute path into the metadata hash, so `jacquard-oauth` and everything
+      downstream of it — `jacquard-api` above all — is rebuilt from cold in
+      every new worktree, however warm the main checkout's target dir is.
+      Measured while capping builds in a cgroup: a single `rustc` on
+      `jacquard-api` will not complete under a 4 GiB ceiling even at `-j1`
+      with the machine otherwise idle, while atgc's own units peak around
+      1.5-1.8 GiB. That is worth knowing before running two agents at once on
+      a 30 GiB laptop, and it is **not** an argument for a shared target
+      directory — that was reversed deliberately in 0a8a56e and would trade a
+      known cost for a known bug. Left recorded rather than fixed: the patch
+      is what pins the three vendored fixes, and dropping it is the same work
+      as landing them upstream
+
+## Done
+
+- [x] prek hooks over file hygiene, `cargo fmt --check` and clippy
+      `-D warnings`, on the pinned toolchain (prek.toml, rust-toolchain.toml)
+- [x] `rust-version = "1.89"` in Cargo.toml. Three things set that floor
+      independently: edition 2024's let-chains want 1.88, `File::try_lock`
+      and `TryLockError` (src/config/lock.rs) stabilised in 1.89, and
+      `smol_str 0.3.6` — the highest `rust-version` in the resolved graph,
+      arriving under jacquard — declares 1.89 itself. Declaring it also
+      turns clippy's `incompatible_msrv` on, so the floor is now enforced
+      on every build rather than asserted once; dropping the key to 1.88
+      reports `try_lock` and nothing else, which is how the number was
+      established. Not rust-toolchain.toml's 1.97.1, which is what it is
+      built with here and moves on its own schedule
+- [x] One `target/` per worktree, cargo's default, and no scheme on top of it.
+      Sharing the build across worktrees was tried twice and is settled as a
+      no: the uplifted `target/debug/atgc` is one name written by whichever
+      worktree last compiled, and it is what `env!("CARGO_BIN_EXE_atgc")`
+      bakes into the integration tests — once a green test run against
+      another branch's binary
+- [x] Dependencies build without debug info, this crate keeps all of it —
+      `[profile.dev.package."*"] debug = false`, whose `"*"` reaches every
+      dependency and no workspace member. Measured on a full debug build:
+      2.43 GiB to 1.66 GiB, target/debug/deps 1.93 to 1.31, the binary 228
+      to 102 MB and its .debug_info 70 MiB to 27, and the build itself a
+      little quicker. Cheaper in debuggability than it looks, because a
+      dependency's generic code is monomorphized into atgc's codegen units
+      and keeps atgc's settings: 253 dependency source files still have line
+      tables in the result. Only separately compiled non-generic dependency
+      code loses file and line, and symbol names survive regardless
diff --git a/prek.toml b/prek.toml
index 78ea718..ec2efb6 100644
--- a/prek.toml
+++ b/prek.toml
@@ -71,6 +71,36 @@ hooks = [
 
 # Stamp a Change-Id trailer on commits that lack one.
 #
+# plan/ — the epic register that replaced TODO.md.
+#
+# The epics' frontmatter is read by more than a human: the id is a commit scope
+# and the filename both, dependsOn names other epics, and the five tables in
+# plan/README.md are generated from all of it. So a malformed epic is not a
+# formatting problem — it is a row that silently stops matching the file it
+# points at, which is the failure the old flat file had and the whole reason
+# this directory exists.
+#
+# Shape only: the six frontmatter keys, the id matching the filename, the links
+# resolving, and the `## Done` invariant plan/README.md describes. Whether an
+# epic is well scoped is nobody's script to judge. Reads all of plan/ every
+# time because half the checks are about the set — one file cannot tell whether
+# it is in the register — and stdlib python over thirty files is milliseconds.
+[[repos]]
+repo = "local"
+hooks = [
+  { id = "plan-shape", name = "plan shape", entry = "scripts/check-plan.py", language = "system", pass_filenames = false, files = '^(plan/.*\.md|scripts/check-plan\.py)$', stages = ["pre-commit"] },
+  # The other half: the register's tables against the frontmatter they are
+  # generated from. A failure here means the register is stale — run
+  # scripts/gen-plan-readme.py and stage plan/README.md. It prints the diff, so
+  # the message says which rows moved.
+  #
+  # Checks rather than rewrites, unlike the fixers at the top of this file. A
+  # commit touching plan/ is a human writing prose in these same files, and a
+  # hook that edited the register underneath them would be changing the thing
+  # being written rather than reporting on it.
+  { id = "plan-register", name = "plan register", entry = "scripts/gen-plan-readme.py --check", language = "system", pass_filenames = false, files = '^(plan/.*|scripts/gen-plan-readme\.py)$', stages = ["pre-commit"] },
+]
+
 # A stacked pull request is matched to its commits by this trailer, and it can
 # only be written without rewriting history while the commit is being made. In
 # a repo with no hook framework `atgc repo configure` installs the same script
diff --git a/scripts/check-plan.py b/scripts/check-plan.py
new file mode 100755
index 0000000..e827d11
--- /dev/null
+++ b/scripts/check-plan.py
@@ -0,0 +1,385 @@
+#!/usr/bin/env python3
+"""Check the shape of plan/ — the epic files and the register that lists them.
+
+An epic is a markdown file with YAML frontmatter, and something already reads
+that frontmatter: the id is the Conventional Commits scope (see
+scripts/check-commit-scope.sh), and a docs component loads the whole directory
+as an Astro content collection. A malformed file therefore breaks a site build
+rather than a commit, which is the wrong place to find out.
+
+This checks shape only — the keys that must be there, the ids that must match,
+the links that must resolve, and the `## Done` invariant the register describes.
+Whether an epic is well scoped or its prose is true is a human's call and is
+deliberately not checked here.
+
+No third-party modules on purpose: this runs on every commit that touches
+plan/, so it has to start fast and work in a fresh clone. The frontmatter
+parser below is a subset of YAML, not YAML.
+
+Run by prek over plan/, or by hand: scripts/check-plan.py
+"""
+
+import os
+import re
+import sys
+
+# Every epic carries exactly these, no more and no less. The docs component
+# reads all six, so an epic missing one renders wrong and an epic with a
+# seventh is writing down something nothing consumes.
+REQUIRED_KEYS = ["id", "title", "status", "repos", "dependsOn", "exitCriterion"]
+
+# Documented in plan/README.md, under "Status".
+VALID_STATUS = ["shipped", "open", "blocked", "continuous", "declined"]
+
+# Files in plan/ that are not epics. Everything else there is one, so a new
+# hand-written page needs a line here or it fails as a malformed epic.
+NOT_EPICS = {"README.md", "milestones.md"}
+
+LINK_RE = re.compile(r"(?`
+    folded block. Anything else is reported rather than guessed at.
+    """
+    if not lines or lines[0].rstrip() != "---":
+        errors.append(
+            f"{path}: no frontmatter.\n"
+            f"  An epic starts with a --- block holding: "
+            f"{', '.join(REQUIRED_KEYS)}.\n"
+            f"  Copy the block from any other file in plan/."
+        )
+        return None
+
+    end = None
+    for i in range(1, len(lines)):
+        if lines[i].rstrip() == "---":
+            end = i
+            break
+    if end is None:
+        errors.append(
+            f"{path}: frontmatter is never closed.\n"
+            f"  Add a --- line after the last key."
+        )
+        return None
+
+    data = {}
+    i = 1
+    while i < end:
+        raw = lines[i]
+        i += 1
+        if not raw.strip():
+            continue
+        m = re.match(r"^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$", raw)
+        if not m:
+            errors.append(
+                f"{path}: frontmatter line {i} is not `key: value`:\n"
+                f"    {raw.strip()}\n"
+                f"  This file's frontmatter is a small subset of YAML. Keep it "
+                f"to the six keys other epics use."
+            )
+            return None
+        key, value = m.group(1), m.group(2).strip()
+        if key in data:
+            errors.append(
+                f"{path}: frontmatter sets `{key}` twice. Delete one of them."
+            )
+            return None
+        if value == ">" or value == "|":
+            # Folded or literal block: the indented lines that follow.
+            block = []
+            while i < end and (not lines[i].strip() or lines[i][:1] in " \t"):
+                block.append(lines[i].strip())
+                i += 1
+            data[key] = " ".join(p for p in block if p)
+        elif value.startswith("[") and value.endswith("]"):
+            inner = value[1:-1].strip()
+            data[key] = [p.strip() for p in inner.split(",") if p.strip()]
+        else:
+            data[key] = value.strip("'\"")
+    return data
+
+
+def check_frontmatter(path, stem, data, known_ids, errors):
+    if "order" in data:
+        errors.append(
+            f"{path}: frontmatter has an `order` key.\n"
+            f"  `order` was removed on purpose: a total order over twenty "
+            f"epics has to be\n"
+            f"  rewritten whenever one moves, so two branches adding an epic "
+            f"conflict over a\n"
+            f"  number neither cares about. Delete the key. What an epic "
+            f"waits on goes in\n"
+            f"  `dependsOn`; the reading order is plan/order.txt, which is "
+            f"one shared list\n"
+            f"  rather than a rank in every file, and which an epic does not "
+            f"have to appear in."
+        )
+
+    missing = [k for k in REQUIRED_KEYS if k not in data]
+    extra = [k for k in data if k not in REQUIRED_KEYS and k != "order"]
+    if missing:
+        errors.append(
+            f"{path}: frontmatter is missing {', '.join(missing)}.\n"
+            f"  Every epic carries exactly: {', '.join(REQUIRED_KEYS)}.\n"
+            f"  Copy the block from any other file in plan/ and fill it in."
+        )
+    if extra:
+        errors.append(
+            f"{path}: frontmatter has keys nothing reads: "
+            f"{', '.join(sorted(extra))}.\n"
+            f"  Every epic carries exactly: {', '.join(REQUIRED_KEYS)}.\n"
+            f"  Put it in the prose instead, or delete it."
+        )
+
+    if "id" in data and data["id"] != stem:
+        errors.append(
+            f"{path}: id is `{data['id']}` but the filename says `{stem}`.\n"
+            f"  The id is the file's name and the commit scope both — "
+            f"`feat({stem}): …`\n"
+            f"  is checked against this by scripts/check-commit-scope.sh. "
+            f"Either set\n"
+            f"  `id: {stem}`, or rename the file to {data['id']}.md and update "
+            f"the link in\n"
+            f"  plan/README.md."
+        )
+
+    if "status" in data and data["status"] not in VALID_STATUS:
+        errors.append(
+            f"{path}: status is `{data['status']}`. Use one of:\n"
+            f"    {', '.join(VALID_STATUS)}\n"
+            f"  plan/README.md, under \"Status\", says what each one means."
+        )
+
+    depends = data.get("dependsOn", [])
+    if not isinstance(depends, list):
+        errors.append(
+            f"{path}: dependsOn is not a list. Write it as `dependsOn: []` or "
+            f"`dependsOn: [some-epic]`."
+        )
+    else:
+        for dep in depends:
+            if dep not in known_ids:
+                errors.append(
+                    f"{path}: dependsOn names `{dep}`, which is not an epic.\n"
+                    f"  It has to be the id of a file in plan/ or "
+                    f"plan/complete/. Fix the spelling,\n"
+                    f"  write plan/{dep}.md, or drop it from the list."
+                )
+
+
+def check_done_section(path, lines, archived, errors):
+    """Exactly one `## Done`, last, with the checkboxes on the right side."""
+    done_at = None
+    trailing = []
+    for n, line in enumerate(lines):
+        m = HEADING_RE.match(line)
+        if not m:
+            continue
+        level, text = len(m.group(1)), m.group(2)
+        if level == 2 and text == "Done":
+            if done_at is not None:
+                errors.append(
+                    f"{path}: two `## Done` headings (lines {done_at + 1} and "
+                    f"{n + 1}).\n"
+                    f"  An epic has one, at the end. Merge them."
+                )
+                return
+            done_at = n
+        elif done_at is not None and level <= 2:
+            trailing.append((n + 1, line.strip()))
+
+    if done_at is None:
+        errors.append(
+            f"{path}: no `## Done` section.\n"
+            f"  Every epic ends in one, holding the finished work as `- [x]` "
+            f"items — that is\n"
+            f"  what makes \"is this epic finished\" answerable by looking. "
+            f"Add it at the end of\n"
+            f"  the file, empty if nothing is done yet."
+        )
+        return
+
+    if trailing:
+        line_no, text = trailing[0]
+        errors.append(
+            f"{path}: `## Done` is not the last section — `{text}` follows it "
+            f"on line {line_no}.\n"
+            f"  Move that section above `## Done`, so the finished work stays "
+            f"at the bottom."
+        )
+
+    for n, line in enumerate(lines):
+        m = CHECKBOX_RE.match(line)
+        if not m:
+            continue
+        checked = m.group(1).lower() == "x"
+        if checked and n < done_at:
+            errors.append(
+                f"{path}:{n + 1}: a `- [x]` item above `## Done`:\n"
+                f"    {line.strip()}\n"
+                f"  Finished work moves down under `## Done`. Open work stays "
+                f"above it as `- [ ]`."
+            )
+        elif not checked and n > done_at and not archived:
+            # An archived file gets the sharper message from check_archived
+            # instead: nothing is open in there, wherever it sits.
+            errors.append(
+                f"{path}:{n + 1}: a `- [ ]` item under `## Done`:\n"
+                f"    {line.strip()}\n"
+                f"  Done holds finished work only. Move it above the heading, "
+                f"or tick it."
+            )
+
+
+def check_archived(path, lines, errors):
+    """An epic in plan/complete/ has nothing left open."""
+    for n, line in enumerate(lines):
+        m = CHECKBOX_RE.match(line)
+        if m and m.group(1) == " ":
+            errors.append(
+                f"{path}:{n + 1}: an open `- [ ]` item in an archived epic:\n"
+                f"    {line.strip()}\n"
+                f"  plan/complete/ is for epics with nothing open. Either "
+                f"finish it and tick it,\n"
+                f"  or move the file back up to plan/ until it closes."
+            )
+
+
+def check_links(path, abs_path, lines, repo_root, errors):
+    """Every relative link resolves, from the linking file's own directory."""
+    base = os.path.dirname(abs_path)
+    for n, line in enumerate(lines):
+        for target in LINK_RE.findall(strip_code_spans(line)):
+            if re.match(r"^[a-z][a-z0-9+.-]*:", target) or target.startswith("//"):
+                continue
+            target = target.split("#", 1)[0]
+            if not target:
+                continue
+            resolved = os.path.normpath(os.path.join(base, target))
+            if not os.path.exists(resolved):
+                rel = os.path.relpath(resolved, repo_root)
+                errors.append(
+                    f"{path}:{n + 1}: link to `{target}` does not resolve "
+                    f"(looked for {rel}).\n"
+                    f"  Links are relative to the file they are in, so a file "
+                    f"in plan/complete/\n"
+                    f"  reaches the rest of plan/ with `../`. Fix the path, or "
+                    f"drop the link."
+                )
+
+
+def main():
+    repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+    plan_dir = os.path.join(repo_root, "plan")
+    register = os.path.join(plan_dir, "README.md")
+
+    if not os.path.isdir(plan_dir):
+        print(f"no plan/ directory at {plan_dir}", file=sys.stderr)
+        return 1
+
+    epics = []
+    for directory in (plan_dir, os.path.join(plan_dir, "complete")):
+        if not os.path.isdir(directory):
+            continue
+        for name in sorted(os.listdir(directory)):
+            # README.md is the register and milestones.md is the narrative,
+            # not epics: they carry no frontmatter and no Done section. The
+            # register is checked for its links and for listing everything
+            # else; milestones.md is prose nothing reads.
+            if not name.endswith(".md") or name in NOT_EPICS:
+                continue
+            epics.append(os.path.join(directory, name))
+
+    known_ids = {os.path.basename(p)[:-3] for p in epics}
+    errors = []
+
+    if not os.path.isfile(register):
+        errors.append(
+            "plan/README.md is missing. It is the register: every epic is "
+            "linked from it."
+        )
+        register_links = set()
+    else:
+        with open(register, encoding="utf-8") as fh:
+            lines = strip_fences(fh.read().splitlines())
+        check_links("plan/README.md", register, lines, repo_root, errors)
+        register_links = set()
+        for line in lines:
+            for target in LINK_RE.findall(strip_code_spans(line)):
+                if re.match(r"^[a-z][a-z0-9+.-]*:", target):
+                    continue
+                target = target.split("#", 1)[0]
+                if target:
+                    register_links.add(
+                        os.path.normpath(os.path.join(plan_dir, target))
+                    )
+
+    for path in epics:
+        with open(path, encoding="utf-8") as fh:
+            lines = strip_fences(fh.read().splitlines())
+        stem = os.path.basename(path)[:-3]
+        rel = os.path.relpath(path, repo_root)
+
+        archived = os.path.dirname(path).endswith(os.sep + "complete")
+
+        data = parse_frontmatter(rel, lines, errors)
+        if data is not None:
+            check_frontmatter(rel, stem, data, known_ids, errors)
+        check_done_section(rel, lines, archived, errors)
+        check_links(rel, path, lines, repo_root, errors)
+        if archived:
+            check_archived(rel, lines, errors)
+
+        if os.path.normpath(path) not in register_links:
+            where = os.path.relpath(path, plan_dir)
+            errors.append(
+                f"{rel}: not linked from plan/README.md.\n"
+                f"  The register is how an epic is found, and how "
+                f"check-commit-scope.sh knows the\n"
+                f"  scope exists. Add a row to the table for its status: "
+                f"`[{stem}]({where})`."
+            )
+
+    if errors:
+        print(f"plan/ check failed ({len(errors)} problem(s)):\n", file=sys.stderr)
+        for err in errors:
+            print(err + "\n", file=sys.stderr)
+        return 1
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/scripts/gen-plan-readme.py b/scripts/gen-plan-readme.py
new file mode 100755
index 0000000..b20a0da
--- /dev/null
+++ b/scripts/gen-plan-readme.py
@@ -0,0 +1,349 @@
+#!/usr/bin/env python3
+"""Generate the five tables in plan/README.md from the epics' own frontmatter.
+
+Every row in the register used to repeat an id, a title and a status that the
+epic file already carried. That cost two things. Nothing checked the copy, so a
+retitled epic kept its old title in the table; and every branch that added an
+epic edited the same tables, so two of them conflicted by construction.
+
+So the tables are output now. Each one sits between an HTML comment fence and
+is rewritten from the files; everything outside a fence is hand-written prose
+and is passed through untouched. Which table an epic lands in follows from
+where it is and what its status says: plan/complete/ is Complete, `shipped`
+outside it is Shipped-with-loose-ends, `open` and `blocked` are Open,
+`continuous` is Continuous, and `declined` is Not pursued.
+
+Row order comes from plan/order.txt, a plain list of ids that is advisory and
+optional — an epic it does not name still appears, alphabetically, at the end
+of its table. That is the whole reason it can exist without being the `order`
+field this project deleted: it is one shared line to move when a reading order
+is worth stating, not a rank every file has to carry and every insert has to
+renumber. Adding an epic needs no edit here at all.
+
+No third-party modules, for the same reason check-plan.py has none: this runs
+on every commit that touches plan/. The frontmatter parser is that script's,
+imported rather than copied, so the two can never disagree about what an epic
+says.
+
+Rewrite the register:  scripts/gen-plan-readme.py
+Ask whether it is current:  scripts/gen-plan-readme.py --check
+"""
+
+import argparse
+import difflib
+import importlib.util
+import os
+import re
+import sys
+
+SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
+REPO_ROOT = os.path.dirname(SCRIPTS_DIR)
+PLAN_DIR = os.path.join(REPO_ROOT, "plan")
+COMPLETE_DIR = os.path.join(PLAN_DIR, "complete")
+REGISTER = os.path.join(PLAN_DIR, "README.md")
+ORDER_FILE = os.path.join(PLAN_DIR, "order.txt")
+
+FENCE_OPEN_RE = re.compile(r"^$")
+FENCE_CLOSE = ""
+
+# name -> (heading it lives under, does the row carry a status column,
+#          which epics belong in it).
+#
+# The status column is only on Open, because that is the only table holding
+# more than one status: `blocked` reads as a fact about an open epic, while a
+# column of nothing but `continuous` says nothing the heading did not.
+TABLES = [
+    (
+        "complete",
+        False,
+        lambda e: e["archived"],
+    ),
+    (
+        "shipped",
+        False,
+        lambda e: not e["archived"] and e["status"] == "shipped",
+    ),
+    (
+        "open",
+        True,
+        lambda e: not e["archived"] and e["status"] in ("open", "blocked"),
+    ),
+    (
+        "continuous",
+        False,
+        lambda e: not e["archived"] and e["status"] == "continuous",
+    ),
+    (
+        "declined",
+        False,
+        lambda e: not e["archived"] and e["status"] == "declined",
+    ),
+]
+
+
+def load_check_plan():
+    """Import scripts/check-plan.py for its frontmatter parser.
+
+    Its filename has a hyphen in it, so `import` cannot reach it by name. Going
+    the long way round is still better than a second parser: the two scripts
+    read the same files on the same commit, and a subset-of-YAML parser that
+    drifts from its twin fails in a way nobody would look for.
+
+    Importing writes a scripts/__pycache__/ unless told not to, and this repo
+    does not ignore one. Nothing here is imported twice anyway.
+    """
+    path = os.path.join(SCRIPTS_DIR, "check-plan.py")
+    spec = importlib.util.spec_from_file_location("check_plan", path)
+    module = importlib.util.module_from_spec(spec)
+    sys.dont_write_bytecode = True
+    spec.loader.exec_module(module)
+    return module
+
+
+def read_epics(check_plan, errors):
+    """Every epic in plan/ and plan/complete/, with the fields a row needs."""
+    epics = []
+    for directory, archived in ((PLAN_DIR, False), (COMPLETE_DIR, True)):
+        if not os.path.isdir(directory):
+            continue
+        for name in sorted(os.listdir(directory)):
+            if not name.endswith(".md") or name in check_plan.NOT_EPICS:
+                continue
+            path = os.path.join(directory, name)
+            rel = os.path.relpath(path, REPO_ROOT)
+            with open(path, encoding="utf-8") as fh:
+                lines = fh.read().splitlines()
+            data = check_plan.parse_frontmatter(rel, lines, errors)
+            if data is None:
+                # parse_frontmatter has already said what is wrong with it.
+                continue
+            missing = [k for k in ("id", "title", "status") if k not in data]
+            if missing:
+                errors.append(
+                    f"{rel}: frontmatter is missing {', '.join(missing)}, so "
+                    f"there is nothing to\n"
+                    f"  put in the register. scripts/check-plan.py says what "
+                    f"an epic must carry."
+                )
+                continue
+            epics.append(
+                {
+                    "id": data["id"],
+                    "title": data["title"],
+                    "status": data["status"],
+                    "archived": archived,
+                    "link": os.path.relpath(path, PLAN_DIR).replace(os.sep, "/"),
+                    "rel": rel,
+                }
+            )
+    return epics
+
+
+def read_order(known_ids, errors):
+    """plan/order.txt as a list of ids. Blank lines and # comments are notes."""
+    if not os.path.isfile(ORDER_FILE):
+        return []
+    order = []
+    with open(ORDER_FILE, encoding="utf-8") as fh:
+        for n, raw in enumerate(fh.read().splitlines(), 1):
+            line = raw.split("#", 1)[0].strip()
+            if not line:
+                continue
+            if line in order:
+                errors.append(
+                    f"plan/order.txt:{n}: `{line}` is listed twice. "
+                    f"Delete one of them."
+                )
+                continue
+            if line not in known_ids:
+                errors.append(
+                    f"plan/order.txt:{n}: `{line}` is not an epic.\n"
+                    f"  Every line is the id of a file in plan/ or "
+                    f"plan/complete/. If the epic was\n"
+                    f"  renamed or deleted, drop the line — an epic missing "
+                    f"from this file is fine,\n"
+                    f"  it just sorts to the end of its table."
+                )
+                continue
+            order.append(line)
+    return order
+
+
+def escape_cell(text):
+    """A pipe in a title would end the cell early; nothing else is special."""
+    return text.replace("|", "\\|")
+
+
+def render_table(epics, with_status):
+    header = ["| id | title | status |", "|---|---|---|"]
+    if not with_status:
+        header = ["| id | title |", "|---|---|"]
+    rows = []
+    for epic in epics:
+        cells = [f"[{epic['id']}]({epic['link']})", escape_cell(epic["title"])]
+        if with_status:
+            cells.append(epic["status"])
+        rows.append("| " + " | ".join(cells) + " |")
+    return header + rows
+
+
+def build_blocks(epics, order, errors):
+    """The generated lines for each fence, keyed by the fence's name."""
+    rank = {epic_id: n for n, epic_id in enumerate(order)}
+    unlisted = len(rank)
+
+    blocks = {}
+    placed = set()
+    for name, with_status, belongs in TABLES:
+        rows = [e for e in epics if belongs(e)]
+        rows.sort(key=lambda e: (rank.get(e["id"], unlisted), e["id"]))
+        placed.update(e["id"] for e in rows)
+        blocks[name] = render_table(rows, with_status)
+
+    for epic in epics:
+        if epic["id"] not in placed:
+            errors.append(
+                f"{epic['rel']}: status `{epic['status']}` belongs to no "
+                f"table, so the epic\n"
+                f"  would vanish from the register. Use one of: shipped, "
+                f"open, blocked, continuous."
+            )
+    return blocks
+
+
+def rewrite(text, blocks, errors):
+    """Replace what is inside each fence. Returns None if the fences are wrong.
+
+    Everything outside a fence — the headings, the paragraphs between the
+    tables — is hand-written and comes through byte for byte.
+    """
+    lines = text.split("\n")
+    out = []
+    seen = []
+    i = 0
+    while i < len(lines):
+        match = FENCE_OPEN_RE.match(lines[i].strip())
+        if not match:
+            out.append(lines[i])
+            i += 1
+            continue
+
+        name = match.group(1)
+        if name not in blocks:
+            errors.append(
+                f"plan/README.md:{i + 1}: `` names "
+                f"no table.\n"
+                f"  The generated tables are: "
+                f"{', '.join(n for n, _, _ in TABLES)}."
+            )
+            return None
+        if name in seen:
+            errors.append(
+                f"plan/README.md:{i + 1}: a second "
+                f"`` fence.\n"
+                f"  Each table is generated in one place. Delete one of them."
+            )
+            return None
+
+        end = None
+        for j in range(i + 1, len(lines)):
+            if lines[j].strip() == FENCE_CLOSE:
+                end = j
+                break
+            if FENCE_OPEN_RE.match(lines[j].strip()):
+                break
+        if end is None:
+            errors.append(
+                f"plan/README.md:{i + 1}: the `{name}` fence is never closed.\n"
+                f"  Add a `{FENCE_CLOSE}` line after the table."
+            )
+            return None
+
+        seen.append(name)
+        out.append(lines[i])
+        out.extend(blocks[name])
+        out.append(lines[end])
+        i = end + 1
+
+    missing = [n for n, _, _ in TABLES if n not in seen]
+    if missing:
+        errors.append(
+            f"plan/README.md: no fence for: {', '.join(missing)}.\n"
+            f"  Each table is written between "
+            f"`` and `{FENCE_CLOSE}`.\n"
+            f"  Put the pair under the heading the table belongs to."
+        )
+        return None
+
+    return "\n".join(out)
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Generate plan/README.md's tables from epic frontmatter."
+    )
+    parser.add_argument(
+        "--check",
+        action="store_true",
+        help="say whether the register is current, and change nothing",
+    )
+    args = parser.parse_args()
+
+    if not os.path.isfile(REGISTER):
+        print(
+            "plan/README.md is missing. It is the register: every epic is "
+            "linked from it.",
+            file=sys.stderr,
+        )
+        return 1
+
+    check_plan = load_check_plan()
+    errors = []
+
+    epics = read_epics(check_plan, errors)
+    order = read_order({e["id"] for e in epics}, errors)
+    blocks = build_blocks(epics, order, errors)
+
+    with open(REGISTER, encoding="utf-8") as fh:
+        before = fh.read()
+    after = rewrite(before, blocks, errors)
+
+    if errors:
+        print(
+            f"plan/README.md cannot be generated ({len(errors)} problem(s)):\n",
+            file=sys.stderr,
+        )
+        for err in errors:
+            print(err + "\n", file=sys.stderr)
+        return 1
+
+    if after == before:
+        if not args.check:
+            print("plan/README.md is already current.")
+        return 0
+
+    if args.check:
+        diff = difflib.unified_diff(
+            before.splitlines(keepends=True),
+            after.splitlines(keepends=True),
+            fromfile="plan/README.md (committed)",
+            tofile="plan/README.md (generated)",
+        )
+        print(
+            "plan/README.md does not match the epics' frontmatter.\n"
+            "Run scripts/gen-plan-readme.py and stage the result. What "
+            "differs:\n",
+            file=sys.stderr,
+        )
+        sys.stderr.writelines(diff)
+        print(file=sys.stderr)
+        return 1
+
+    with open(REGISTER, "w", encoding="utf-8") as fh:
+        fh.write(after)
+    print("plan/README.md: tables rewritten.")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/src/config/lock.rs b/src/config/lock.rs
index 49d34c4..c831b25 100644
--- a/src/config/lock.rs
+++ b/src/config/lock.rs
@@ -71,8 +71,8 @@
 //! One consequence to know about: this makes the lock a hard dependency, so
 //! a filesystem where `flock` is unavailable rather than merely contended
 //! (`ENOLCK` on some remote mounts) fails these commands outright instead of
-//! degrading. That is the intended trade — see TODO.md, which records it as
-//! the one way this can bite.
+//! degrading. That is the intended trade — see plan/credential-store.md,
+//! which records it as the one way this can bite.
 
 use anyhow::{Context, Result};
 use std::fs::{File, TryLockError};
@@ -460,11 +460,11 @@ mod tests {
     /// The hole the task identity closes: a *different* task writing while a
     /// refresh is in flight must not read the refresh's hold as its own.
     ///
-    /// This is the case the design note in TODO.md said wanted task-local
-    /// state. A spawned task carries its own `tokio::task::Id`, so it does
-    /// not match the holder and takes the lock properly — which here means
-    /// it cannot take it at all while the section holds it, and says so
-    /// rather than proceeding unlocked.
+    /// This is the case the design note in plan/credential-store.md said
+    /// wanted task-local state. A spawned task carries its own
+    /// `tokio::task::Id`, so it does not match the holder and takes the lock
+    /// properly — which here means it cannot take it at all while the section
+    /// holds it, and says so rather than proceeding unlocked.
     #[tokio::test]
     async fn another_task_does_not_inherit_a_refresh_it_is_not_part_of() {
         let held = take_reentrant(Purpose::Refresh)
-- 
2.51.2