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)