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)