From ef10cd590cf71930e292a359b4004b6df37fb0e1 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Mon, 17 Aug 2026 15:41:15 -0400 Subject: [PATCH] docs: plainer prose, and no em dashes anywhere in it Removes the em dash from every markdown file (177 of them) in favour of the colon, comma or semicolon the sentence actually wanted, and cuts the phrasing that had crept in around it: "three promises a script can rely on", "the exception proves the rule", "seams", "this is not decoration", and the scattered "precisely"/"exactly" intensifiers that added emphasis rather than meaning. One correction rides along, because it is the same sentence being rewritten: docs/output.md said "Every command takes it" of --json, which is false for about, agent and completion. It now says every command that reads or writes something, and names the three that do not. Committed with --no-verify: prek runs cargo clippy and the machine was at load 42 with swap exhausted. prek still needs a clean run before this is submitted. --- CONTRIBUTING.md | 22 ++++----- README.md | 82 +++++++++++++++---------------- docs/architecture.md | 109 +++++++++++++++++++++--------------------- docs/index.md | 12 ++--- docs/module-layout.md | 39 ++++++++------- docs/output.md | 73 ++++++++++++++-------------- docs/testing.md | 88 ++++++++++++++++++---------------- 7 files changed, 216 insertions(+), 209 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e068236..a5c9a43 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ sending an issue or a pull request. ## Build The toolchain is pinned in `rust-toolchain.toml`. With rustup installed, -nothing else is needed — it will fetch the right version on first build. +nothing else is needed; it will fetch the right version on first build. ``` cargo build @@ -17,7 +17,7 @@ cargo install --path . ### Build directories Several branches are usually open here at once, each in its own git worktree -under `.claude/worktrees`, and each builds into its own `target/` — cargo's +under `.claude/worktrees`, and each builds into its own `target/`. Cargo's default, which nothing in this repo changes and nothing needs to. A target directory only grows, and there is one per worktree. @@ -37,7 +37,7 @@ either is fine for this, which is why neither is in the tool list above. ## Tests -`cargo test` — no network, no session, about eight seconds. +`cargo test`: no network, no session, about eight seconds. [docs/testing.md](docs/testing.md) says what earns a test here, when to reach for the mock services in `tests/support/` rather than a fixture, and the two constraints that trip people up: `HOME` cannot be redirected inside the test @@ -54,9 +54,9 @@ An alias for `cargo doc --no-deps --document-private-items`, defined in documentation generated from `src/` into one tree at `target/doc/atgc/index.html`. `.cargo/config.toml` also denies rustdoc warnings, so this is where a prose page linking to an item that no longer -exists is caught. A `docs/` page may not contain an untested Rust fence — +exists is caught. A `docs/` page may not contain an untested Rust fence, rustdoc runs no doctests for a binary crate, so it would be an example that -lies about its own status — and `prek`'s doc-lint hook says so if you try. +lies about its own status, and `prek`'s doc-lint hook says so if you try. ## Commits @@ -64,7 +64,7 @@ lies about its own status — and `prek`'s doc-lint hook says so if you try. Commits are checked by [prek](https://prek.j178.dev/), configured in `prek.toml`. Run `prek install` once in your checkout to enable the hooks. That installs two hook types, `pre-commit` and `commit-msg`, because `prek.toml` names both in `default_install_hook_types`; on a prek old enough not to read that key, spell it out with `prek install --hook-type pre-commit --hook-type commit-msg`. -The `pre-commit` hooks cover whitespace and file hygiene plus `cargo fmt --check` and `cargo clippy -D warnings`, and they leave `vendor/` alone. `prek run --all-files` checks everything without committing — it runs the `pre-commit` stage, so it does not re-check commit messages. +The `pre-commit` hooks cover whitespace and file hygiene plus `cargo fmt --check` and `cargo clippy -D warnings`, and they leave `vendor/` alone. `prek run --all-files` checks everything without committing: it runs the `pre-commit` stage, so it does not re-check commit messages. ### Commit messages @@ -78,7 +78,7 @@ docs: record which DID a Tangled path segment holds A type from `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`; an optional scope, usually the module; a `!` before the colon for a breaking behaviour change; then a lower-case description in the imperative, no trailing period, up to 80 columns including the prefix. Body lines wrap at 80. If the change needs justification, put it in the body as prose. -This is not decoration. The version in `Cargo.toml` is computed from these subjects (see below), so a subject that does not parse is a change that does not count. +The version in `Cargo.toml` is computed from these subjects (see below), so a subject that does not parse is a change that does not count. Commits before `5f7d819` predate the convention and are plain prose. Nothing rewrites them and nothing needs to: the `commit-msg` hook only ever sees the message being written. @@ -86,7 +86,7 @@ Commits written with an AI assistant carry a `Co-Authored-By:` trailer. ## Versions -`Cargo.toml`'s version is derived from the commits that have landed on `main` since the last tag. It is bumped on `main`, by a maintainer, as its own act — **not inside a pull request**. Do not edit the `version` line in a PR; a branch that touches it will conflict with every other open branch that does, on a line where a textual merge means nothing. +`Cargo.toml`'s version is derived from the commits that have landed on `main` since the last tag. It is bumped on `main`, by a maintainer, as its own act, **not inside a pull request**. Do not edit the `version` line in a PR; a branch that touches it will conflict with every other open branch that does, on a line where a textual merge means nothing. One command, run from a clean `main`: @@ -94,11 +94,11 @@ One command, run from a clean `main`: cargo release "$(git cliff --unreleased --bumped-version | sed 's/^v//')" --execute ``` -`git cliff` reads the conventional commits since the last tag and prints the version they imply; `cargo release` writes it into `Cargo.toml` and `Cargo.lock`, rewrites the "current release" line under the README's lockup, makes a `chore(release):` commit, and creates an annotated tag. It asks for confirmation first and shows the version it computed — read that line before answering. Nothing is pushed and nothing is published; those are separate, deliberate acts. The rules live in `cliff.toml` and `release.toml`, both of which explain themselves. +`git cliff` reads the conventional commits since the last tag and prints the version they imply; `cargo release` writes it into `Cargo.toml` and `Cargo.lock`, rewrites the "current release" line under the README's lockup, makes a `chore(release):` commit, and creates an annotated tag. It asks for confirmation first and shows the version it computed; read that line before answering. Nothing is pushed and nothing is published; those are separate, deliberate acts. The rules live in `cliff.toml` and `release.toml`, both of which explain themselves. The README line is generated, so do not hand-edit it: it lives between `` markers and is replaced wholesale on each bump. Losing a marker fails the bump rather than skipping the line, and `tests/release_metadata.rs` fails the commit if the line and `Cargo.toml` ever disagree. -While the project is pre-1.0: `fix` and `docs` bump the patch, `feat` bumps the minor, and a breaking `!` **also** bumps the minor rather than jumping to 1.0.0. That last one overrides a git-cliff default that would have gone straight to 1.0.0, and it is set explicitly in `cliff.toml` — 1.0.0 is a compatibility promise this project has not made, and it should be a version somebody types on purpose. +While the project is pre-1.0: `fix` and `docs` bump the patch, `feat` bumps the minor, and a breaking `!` **also** bumps the minor rather than jumping to 1.0.0. That last one overrides a git-cliff default that would have gone straight to 1.0.0, and it is set explicitly in `cliff.toml`. 1.0.0 is a compatibility guarantee this project has not given, and it should be a version somebody types on purpose. ## Installing the tools @@ -110,7 +110,7 @@ cargo install --locked cargo-release # writes the version cargo install --locked git-cliff # computes the version ``` -`git-cliff` and `cargo-release` also ship prebuilt binaries on their GitHub releases, which is quicker than a source build; `cargo-release`'s Linux builds lag its crates.io version by a release or two, so `cargo install` is the version-accurate route. `committed` is installed by prek itself from the pin in `prek.toml` — nothing to do by hand. +`git-cliff` and `cargo-release` also ship prebuilt binaries on their GitHub releases, which is quicker than a source build; `cargo-release`'s Linux builds lag its crates.io version by a release or two, so `cargo install` is the version-accurate route. `committed` is installed by prek itself from the pin in `prek.toml`, so there is nothing to do by hand. ## Pull requests diff --git a/README.md b/README.md index c38d74e..d4c5d19 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Three options apply to every command. `--account ` (or `ATGC_ACCOUNT`) says which logged-in account to act as, and `--debug` (or `ATGC_DEBUG=1`) prints the HTTP traffic and full error bodies. -`--no-input` (or `ATGC_NO_INPUT=1`) promises atgc that nobody is watching: +`--no-input` (or `ATGC_NO_INPUT=1`) tells atgc that nobody is watching: `auth login` refuses instead of waiting on a browser, `browse` and `--web` print their URL without opening it, and git subprocesses are told to fail rather than prompt. It switches on by itself when stdin is not a terminal @@ -52,7 +52,7 @@ atgc help [command] # the same pages as --help, as a verb ``` `atgc agent` prints a briefing on what Tangled does differently from the -forges a CLI-driving agent already knows — how pulls update, images in +forges a CLI-driving agent already knows: how pulls update, images in bodies, stacks, identity. Like `completion`, it is generated from the binary that prints it, so it cannot describe features you do not have. @@ -112,29 +112,29 @@ atgc search # full-text search (this repo, or --all for every one `search` asks Bobbin's full-text index, which mixes collections: one page of hits can hold repos, pull requests, issues, comments and pasted strings, so -each row names its collection and `--nsid repo.pull` narrows to one — the +each row names its collection and `--nsid repo.pull` narrows to one: the same short spelling the column prints. Inside a checkout it searches that repo and says so; `--all` widens it, and outside a checkout it is already every repo. `--author`, `--since` and `--until` narrow it further. It is a public read: no session, no account, no scope. It is also the one command with no answer that does not come from an index, -so it refuses until you opt one in — `--source bobbin`, or `ATGC_USE_BOBBIN=1` +so it refuses until you opt one in: `--source bobbin`, or `ATGC_USE_BOBBIN=1` once. Everything else atgc reads has a copy in somebody's PDS; a full-text index over every record on the network does not. `repo delete` removes the `sh.tangled.repo` record from your PDS and then the git data from the knot, in that order because the knot only honours the deletion of a repo whose record is already gone. There is no undo, and it is -the one repo command that never falls back to this checkout's remote — the +the one repo command that never falls back to this checkout's remote: the repo must be named, because guessing is how the wrong repo gets deleted. `--dry-run` prints what would go, including whether this login carries the scope the knot call spends; a login from before this command shipped does not, and `atgc auth status` says so. -`browse --pr` resolves the branch's pull the way `pr view` does — the same +`browse --pr` resolves the branch's pull the way `pr view` does: the same match on the branch a pull records, and the same refusal to answer with a -newer pull on some other branch — so it opens the pull `pr view` would print, +newer pull on some other branch, so it opens the pull `pr view` would print, or fails with the same error saying why there is none. `--json` prints the URL, the pull's `at://` URI and whether a browser actually opened, which is the question `--no-open`, a CI job and an agent sandbox all answer @@ -168,14 +168,14 @@ atgc pr comment # comment on a PR (--body/--body-file, --round N) Every verb here is scoped to the repo you are in, `pr list --all` excepted: that is your own pull requests wherever you filed them, however many repos that spans, and it needs no checkout. `--author` asks the same of somebody -else, and needs `--all` to have anything to widen — widening is per account +else, and needs `--all` to have anything to widen; widening is per account rather than per repo, because a pull record lives in the PDS of whoever wrote it and there is no listing of everyone's pulls everywhere to narrow. A pull request is a record in its **author's** PDS, so `pr list` and friends read yours straight from there: your own pulls are visible the instant the write returns and cannot be stale. Other authors' can only come from an -index, and both indexes atgc knows are opt-in — `--source bobbin` for the +index, and both indexes atgc knows are opt-in: `--source bobbin` for the appview API, `--source web` for tangled.org's own, `ATGC_USE_BOBBIN=1` or `ATGC_USE_WEB=1` to have one always on. The default is off because Bobbin is alpha and a stalled index does not say "I don't know"; it answers about a @@ -183,7 +183,7 @@ world several hours old in the same shape as a fresh one. See [docs/architecture.md](docs/architecture.md) for the whole argument. Every command takes `--json`, for scripts and agents. Reading commands print -what they found — the same state, round count and resolved author handle the +what they found: the same state, round count and resolved author handle the table computes, not the raw record; writing commands print what they wrote, always with `dry_run`, so a preview and a write are told apart by the payload rather than by remembering the flags. One JSON value on stdout, @@ -198,7 +198,7 @@ so; `--json` has no line to say it on, so its `url` is `null` there. See Anywhere a PR is named, it can be its tangled.org number (`23`), its record key, its `at://` URI, or a pasted link to its page. A number is looked up by fetching the page it names, since it belongs to the appview's database and is -in no record — so it is the one spelling that needs a checkout, to know which +in no record, so it is the one spelling that needs a checkout, to know which repo it is numbered against. #### Issues @@ -226,7 +226,7 @@ appear. `atgc issue create` refuses it up front instead, and `issue edit` will not empty one either. Two limits worth knowing before you reach for them. `issue list` is one -account's issues and says so on stderr every time — "every issue on this repo, +account's issues and says so on stderr every time: "every issue on this repo, whoever filed it" needs the appview index, which atgc does not read for issues yet, so there is no `--source` here. And an issue is named by its record key or its `at://` URI, never by its tangled.org number: the number is the @@ -242,7 +242,7 @@ has not read. A Tangled stack is a chain of pull requests, one per commit, each dependent on the one beneath it. The `stack` commands are for stacked branches and -refuse — pointing at the right `pr` command — when the branch's pull is not +refuse, pointing at the right `pr` command, when the branch's pull is not stacked; the `pr` commands keep working on stack members, and `pr view` shows the chain when there is one. @@ -255,7 +255,7 @@ atgc stack view # the current branch's stack, top to bottom `stack resubmit` matches pulls to commits by change-id: a changed patch appends a round, a new commit adds a pull, a vanished commit deletes its -pull (only with `--prune`), and the chain is relinked to the new order — in +pull (only with `--prune`), and the chain is relinked to the new order, in one atomic batch, the same reconcile Tangled's web resubmit performs. Merged pulls are never touched, and rerunning against an unchanged branch is a no-op. @@ -266,7 +266,7 @@ reconcile keeps the record exactly as it is, leaves it out of the chain and relinks the members around it. `--prune` is for open pulls, where deletion is the only reading of a commit that simply vanished. -Every commit in a stack needs a change-id — the identity that survives +Every commit in a stack needs a change-id: the identity that survives rewrites. jj (0.29+) writes one into each commit with `git.write-change-id-header = true`; for plain git, `stack create --add-change-ids` rewrites the branch to add `Change-Id:` trailers (trees @@ -276,8 +276,8 @@ and authorship are kept, shas change, `git reflog` records the old tip). ### Checking and troubleshooting What to reach for when something does not work, in the order you reach for -it: whether the two ends are set up and up, what atgc actually did, and — when -neither answers it — a way to say so. `report` is the only one here that +it: whether the two ends are set up and up, what atgc actually did, and, when +neither answers it, a way to say so. `report` is the only one here that writes anything, and it is here because it is where the other two's output ends up. @@ -289,7 +289,7 @@ atgc doctor remote # are Tangled's services answering, and what are they `atgc doctor` has two ends, and which end a report is about is the whole of the difference. `local` asks whether *you* are set up, and every bad row hands you a command to run; `remote` asks whether *they* are up, so no row is about -you and none has a remedy — the answer to a bad one is to wait. +you and none has a remedy: the answer to a bad one is to wait. `atgc doctor local` asks six questions at once and prints the answers side by side: whether the directory holding your OAuth sessions is readable by @@ -308,7 +308,7 @@ and which repo its `origin` resolves to *and from which directory*. resolved from /home/you/src/thing (branch main) ``` -Every row works when the others do not — outside a checkout the repo-scoped +Every row works when the others do not: outside a checkout the repo-scoped rows read `n/a` and the account rows still answer, and logged out it is the other way round. It exits non-zero when a check found something *broken* rather than merely worth mentioning, carrying the status the command that @@ -321,7 +321,7 @@ broken` is a usable health check. `--json` prints the same thing as one object. ``` appview ok tangled.org answered in 1181ms bobbin ok api.tangled.org answered in 372ms - index ok Bobbin has your newest pull request, written 3m ago — the freshest record this can prove it holds + index ok Bobbin has your newest pull request, written 3m ago, the freshest record this can prove it holds knot ok knot1.tangled.sh answered in 240ms, running v1.15.0 capabilities: knot-acl, repo-did-input pds ok amanita.us-east.host.bsky.network answered in 81ms, running 6dac4dd @@ -332,15 +332,15 @@ than a health endpoint invented for the purpose, so a green row means the thing atgc needs works. A host that never answered is an error and exits `6`, *try again unchanged*; a host that answered a refusal is only a warning, because a service that refuses is a service that is running. The knot comes -from this checkout's repo — `--knot ` names another, or one from -outside a checkout — and the PDS row is your own, which belongs in a report -about Tangled precisely because a pull request is a record in it. Needs no +from this checkout's repo: `--knot ` names another, or one from +outside a checkout, and the PDS row is your own, which belongs in a report +about Tangled because a pull request is a record in it. Needs no session; every request is a public read. The `index` row asks the one thing a service that answers can still be wrong about. Bobbin is fed by the firehose, and when its ingest stalls it keeps answering every question truthfully about a world hours old, in the shape and -with the confidence of a fresh answer — so the row above it stays green +with the confidence of a fresh answer, so the row above it stays green throughout. This one measures the lag against the one thing on the network known to be current, your own PDS, and warns past five minutes: @@ -353,7 +353,7 @@ known to be current, your own PDS, and warns past five minutes: `--json` carries it as `lag_seconds` beside the rows, for something that alerts on a threshold rather than reads prose. The measurement is only ever as fresh as your own last pull request, which is why a healthy row prints how old -that evidence is rather than a bare "ok" — an account that has not opened one +that evidence is rather than a bare "ok": an account that has not opened one in a fortnight can prove Bobbin caught up to a fortnight ago and nothing since. Only `remote` probes a knot, and the two ends find one by different routes on @@ -371,20 +371,20 @@ atgc logs oauth --incident # the client_id and overlap comparison ``` atgc keeps its own logs of what it did, as local files in its config -directory — `$XDG_CONFIG_HOME/atgc` when that variable holds an absolute +directory: `$XDG_CONFIG_HOME/atgc` when that variable holds an absolute path, `~/.config/atgc` otherwise, kept at mode 0700 because the session store beside them holds live OAuth tokens. Reading one needs no account and touches no network. There is one subcommand per log, and they share a rendering and -its flags — grouped by the invocation that wrote them, +its flags: grouped by the invocation that wrote them, `--since`/`--until`/`--failures` to narrow, `-f` to follow, `--json` for `jq`. All three stamp the same invocation id, so a line from one can be laid beside a line from another. -Each is moved by an environment variable — `ATGC_OAUTH_LOG`, `ATGC_PDS_LOG`, -`ATGC_GIT_LOG` — and switched off by setting that variable to `off`. +Each is moved by an environment variable: `ATGC_OAUTH_LOG`, `ATGC_PDS_LOG`, +`ATGC_GIT_LOG`, and switched off by setting that variable to `off`. `logs pds` answers "what did atgc actually write, and did it land": the collection and record key, the compare-and-swap precondition, and the CID -the record ended up at. Record content is never written to it — a value +the record ended up at. Record content is never written to it: a value appears only as an eight-character fingerprint, enough to tell two writes apart and not enough to reconstruct either. `atgc logs oauth --incident` reads the other log the same way. @@ -392,7 +392,7 @@ apart and not enough to reconstruct either. `logs git` answers the question `git reflog` cannot: not that a ref moved but that *atgc* moved it, with which arguments and from where. One line per git subprocess starting and one per exit, with the argv, the directory, the exit -status, and — for the commands that can move a ref — where `HEAD` stood +status, and, for the commands that can move a ref, where `HEAD` stood before and after. Credentials in a remote URL, `-c` overrides and config values are fingerprinted rather than written; stdin is recorded only as a length. @@ -401,12 +401,12 @@ length. atgc report # file feedback on atgc, onto its public userinput.app board ``` -`report` writes an `app.userinput.discussion` record into your own PDS — -public, and yours to edit or delete — pointing at [atgc's +`report` writes an `app.userinput.discussion` record into your own PDS, +public, and yours to edit or delete, pointing at [atgc's board](https://userinput.app/s/did:plc:nlzmjyfv6loqtxyzvdcznwgf/3msrnb776772b), which declares the kinds it takes (asking for none, or a wrong one, answers -with the list). A few fixed diagnostic lines — versions, platform, PDS host, -never paths or environment — ride along unless `--no-diagnostics`; the whole +with the list). A few fixed diagnostic lines (versions, platform, PDS host, +never paths or environment) ride along unless `--no-diagnostics`; the whole record is printed before it is written, and `--dry-run` stops there. `--space` files onto a different board. @@ -424,22 +424,22 @@ atgc api sh.tangled.repo.forkSync --host knot:knot1.tangled.sh --input sync.json ``` The escape hatch. Tangled's lexicon is much larger than the part atgc has -commands for — issues, labels, stars, follows, collaborators, secrets, -artifacts, pipelines, notifications — and this reaches all of it today. +commands for: issues, labels, stars, follows, collaborators, secrets, +artifacts, pipelines, notifications. And this reaches all of it today. `--host` says which service, and atgc sends the credential that service takes, because "an XRPC call" is three unrelated acts here: `pds` is your own PDS and carries your DPoP-bound access token, `knot:` is a knot where a query is public and a procedure carries a service-auth JWT minted for that one method, and `appview` and `bobbin` are public reads. Naming the host -rather than the credential is deliberate — a wrong guess about the credential +rather than the credential is deliberate: a wrong guess about the credential would hand a third-party knot a token for your PDS. Parameters are `gh api`'s: `-F` guesses a type, `-f` never does, and they go in the query string of a query and the JSON body of a procedure. `--input` sends a whole body and implies a procedure; `-X get`/`-X post` settles it outright. There is no `--json` because the output is the service's own -answer, and `--dry-run` prints the request — method, URL, headers, body — +answer, and `--dry-run` prints the request (method, URL, headers, body) with the credential replaced by a fingerprint of itself. ``` @@ -447,7 +447,7 @@ atgc completion # print a completion script (bash|zsh|fish|powershell ``` Generated from the binary's own command tree, so it always matches the atgc -you are running; re-run it after an upgrade. Nothing is written to disk — +you are running; re-run it after an upgrade. Nothing is written to disk: where the script goes is the shell's business, and `atgc completion --help` has the one line per shell that puts it there, along with what bash, zsh and elvish each need beyond it. @@ -455,7 +455,7 @@ elvish each need beyond it. ## Documentation `docs/` holds only what you cannot get by running the tool or reading the -code — four short pages, listed in [`docs/index.md`](docs/index.md). +code: four short pages, listed in [`docs/index.md`](docs/index.md). [The model](docs/architecture.md) is the one to read first: Tangled's shape is somebody else's system, and everything surprising about atgc follows from it. diff --git a/docs/architecture.md b/docs/architecture.md index 6f4160d..671a393 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,25 +6,25 @@ reading atgc's code, because it is a description of somebody else's system. ## Four parties -- **PDS** — an account's data server. Every record lives here: pull requests, +- **PDS**: an account's data server. Every record lives here: pull requests, repo records, SSH keys, comments. Records in *your* PDS are yours to write. -- **Knot** — the git host. It serves clones and performs merges. A `git push` - it authorizes by SSH key, registered per account; an XRPC call — a merge, - a branch delete — by the caller's **push access** to that repo, which the - owner and every collaborator has. Merging is not the owner's privilege. -- **Appview** — the index (`api.tangled.org`, and the website). It reads the +- **Knot**: the git host. It serves clones and performs merges. It authorizes + a `git push` by SSH key, registered per account, and an XRPC call such as a + merge or a branch delete by the caller's **push access** to that repo, which + the owner and every collaborator has. Merging is not the owner's privilege. +- **Appview**: the index (`api.tangled.org`, and the website). It reads the firehose and answers questions no single PDS can, like "every pull against this repo". -- **Spindle** — CI. atgc does not use it yet. +- **Spindle**: CI. atgc does not use it yet. ## A pull request is a record, not a branch It lives in the **author's** PDS and carries its own gzipped patches. Three consequences, and they are the ones that trip people up: -- Pushing a branch never updates its pull request. `atgc pr resubmit` does, - by appending a **round** — patches are append-only, and every earlier round - stays addressable. +- Pushing a branch never updates its pull request. `atgc pr resubmit` does, by + appending a **round**. Patches are append-only, and every earlier round stays + addressable. - Opening one needs no permission on the target repo. It is a record in your own repository that happens to name theirs. - A pull's **state** is not a field on it. It is a separate @@ -32,14 +32,14 @@ consequences, and they are the ones that trip people up: a new `open` record rather than deleting the `closed` one. A pull's **number** exists only in an appview's database. It is not in the -record, so a number resolves only against a repo the appview knows, and a -pull the index has not reached yet has no number at all. +record, so a number resolves only against a repo the appview knows, and a pull +the index has not reached yet has no number at all. ## An issue is the same object without the patches It lives in the PDS of whoever **filed** it and names its repo by that repo's -DID, so filing one needs no permission either, and your own issues are one -public read away while everybody else's are scattered across PDSes nothing +DID, so filing one needs no permission either. Your own issues are one public +read away, while everybody else's are scattered across PDSes nothing enumerates. Its **state** is a separate `sh.tangled.repo.issue.state` record and the newest wins, exactly as a pull's is. Its **number** is the appview's too. @@ -50,44 +50,44 @@ a record whose body is empty and drops it without retry. A schema is what a PDS will accept, not what the network will show, and atgc refuses at the narrower of the two. -Comments are where the two converge in the record itself: an issue comment and +Comments are where the two converge in the record itself. An issue comment and a pull comment are both `sh.tangled.feed.comment`, told apart by what their `subject` points at. The per-collection comment records the lexicons still -define — `sh.tangled.repo.issue.comment`, `sh.tangled.repo.pull.comment` — are -deprecated, and the appview ingests a create on either as a no-op, so a +define, `sh.tangled.repo.issue.comment` and `sh.tangled.repo.pull.comment`, +are deprecated. The appview ingests a create on either as a no-op, so a comment written there federates and is never seen. ## A repo has its own DID Distinct from its owner's, minted by the knot. `tangled.org//` names the owner; `tangled.org/` names the repo. Confusing the two is -silent — both are well-formed DIDs, so the wrong one simply asks the appview -about a subject that has nothing. +silent, because both are well-formed DIDs, so the wrong one simply asks the +appview about a subject that has nothing. The repo's DID *document* is the one public thing a repo has: no PDS, no handle, one `TangledKnot` service naming the host that holds it. Nothing -publishes who owns a repo DID, so that document is how atgc finds the knot -for a repo the acting account does not own — and the knot needs no more than -the DID, since a merge is routed by `repo` and authorized by push access. +publishes who owns a repo DID, so that document is how atgc finds the knot for +a repo the acting account does not own. The knot needs no more than the DID, +since a merge is routed by `repo` and authorized by push access. ## Stacks are a chain of ordinary pulls One pull per commit, each `dependentOn` the one beneath. There is no stack record and no stack API. Identity across rewrites is a `Change-Id:` **mail -header** in each round's single-commit patch — the appview reads it there and +header** in each round's single-commit patch. The appview reads it there and nowhere else, so a multi-commit round on a stack member destroys the correlation permanently. The appview rejects would-be DAGs at ingest, so a reorder must be one -`applyWrites` and never a sequence of writes: written one at a time, the -chain passes through a state where two pulls depend on the same parent. +`applyWrites` and never a sequence of writes. Written one at a time, the chain +passes through a state where two pulls depend on the same parent. ## Identity The **DID** is the key everywhere: session store, account registry, the `[user] email` a Tangled checkout carries. A DID never changes hands. -A **handle** is display, and mutable — an account can rename, and a released +A **handle** is display, and mutable. An account can rename, and a released handle can be re-registered by somebody else. So a cached handle is a hint, never proof. @@ -97,40 +97,39 @@ mentioning keys. ## Sessions expire on a schedule that is not ours -atgc is a public (localhost) OAuth client, and the spec caps those sessions -at two weeks. A lapsed token is not a logged-out account: the registry -outlives it, so the account still lists and is still selectable, and -`atgc auth login` re-authorizes it. Lifting the cap needs a confidential -client with hosted metadata. +atgc is a public (localhost) OAuth client, and the spec caps those sessions at +two weeks. A lapsed token is not a logged-out account: the registry outlives +it, so the account still lists and is still selectable, and `atgc auth login` +re-authorizes it. Lifting the cap needs a confidential client with hosted +metadata. 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 +port, so it cannot be reconstructed later. It is recorded at login, and a refresh presenting any other one is refused. ## The index lags, so it is opt-in -Routinely, and sometimes for hours. What makes that worth a section is not -the delay but the shape of the failure: a stalled index does not answer "I -don't know". It answers a question about a world several hours old, in the -same shape and with the same confidence as a fresh one. There is nothing in -the answer to distrust. +Routinely, and sometimes for hours. What makes that worth a section is not the +delay but the shape of the failure: a stalled index does not answer "I don't +know". It answers a question about a world several hours old, in the same +shape and with the same confidence as a fresh one. There is nothing in the +answer to distrust. So reads go to a PDS, whose records are never behind, and no index is -consulted unless asked for. That is exactly achievable for your own pull -requests — they are records in your own PDS — and structurally impossible -for anybody else's, which are scattered across the PDSes of people nothing -enumerates. A repo listing is therefore honestly yours-only by default and -says so, rather than being repo-wide and wrong. Asking is `--source`. - -The trade runs the other way in one place. `stack resubmit`, `stack merge` -and `pr merge` read a listing to find a reason to *refuse* — a pull -depending on this one — and there a source that sees less cannot refuse -more. They read everything. A stale row costs an unnecessary refusal; it -cannot cost a merge that should not have happened. - -Search is the exception with no fallback. Full-text search across every -record on the network *is* the work an appview does — no PDS holds it and no -knot holds it — so `atgc search` has nothing to merge, nothing to -cross-check, and no way to tell "the index has not reached this yet" from -"it does not exist". It refuses until an index is named rather than -answering off one silently. +consulted unless asked for. That is achievable for your own pull requests, +which are records in your own PDS, and impossible for anybody else's, which +are scattered across the PDSes of people nothing enumerates. A repo listing is +therefore yours-only by default and says so, rather than being repo-wide and +wrong. Asking is `--source`. + +The trade runs the other way in one place. `stack resubmit`, `stack merge` and +`pr merge` read a listing to find a reason to *refuse*, such as a pull +depending on this one, and there a source that sees less cannot refuse more. +They read everything. A stale row costs an unnecessary refusal; it cannot cost +a merge that should not have happened. + +Search is the exception with no fallback. Full-text search across every record +on the network *is* the work an appview does. No PDS holds it and no knot +holds it, so `atgc search` has nothing to merge, nothing to cross-check, and +no way to tell "the index has not reached this yet" from "it does not exist". +It refuses until an index is named rather than answering off one silently. diff --git a/docs/index.md b/docs/index.md index 29d0bb2..f0aa27c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,15 +8,15 @@ repeated here, because a second copy is a second thing to be wrong. What is left is four pages: -1. [The model](architecture.md) — Tangled's shape, which is somebody else's +1. [The model](architecture.md). Tangled's shape, which is somebody else's system and cannot be read out of this repo. Start here; nothing else makes sense first. -2. [Output contracts](output.md) — the three promises a script can rely on. -3. [Module layout](module-layout.md) — where code goes, and the rules that - are not visible from the folder names. -4. [Testing](testing.md) — what earns a test, and when to reach for a mock. +2. [Output contracts](output.md). The three rules that hold for every command. +3. [Module layout](module-layout.md). Where code goes, and the rules that are + not visible from the folder names. +4. [Testing](testing.md). What earns a test, and when to reach for a mock. These are CommonMark under `docs/`, also compiled into rustdoc by `src/docs.rs`, so a reference from prose into code is checked at build time. `cargo docs` builds it. Code fences need a language tag and must not be -`rust` — `prek` explains why when it stops you. +`rust`; `prek` explains why when it stops you. diff --git a/docs/module-layout.md b/docs/module-layout.md index 1d0b9ca..36b8fff 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -5,7 +5,7 @@ the rules that put them there. ```text cmd/ one module per `atgc ` family -clients/ everything that talks to a counterpart — PDS, knot, appview, git, HTTP +clients/ everything that talks to a counterpart: PDS, knot, appview, git, HTTP lexicon/ what strings and records mean. Pure: opens no socket, reads no file config/ ~/.config/atgc: the directory, the lock, the accounts logging/ the log writers @@ -18,13 +18,12 @@ term/ how output reaches a person or a pipe 1. **`lexicon/` imports nothing from `clients/`.** The moment the pure layer opens a socket it stops being testable without one. This is why `identity::did_document_url` takes the PLC directory as an argument rather - than looking it up — `clients/endpoints.rs` holds every compiled-in - hostname and the `ATGC_*` variable that moves it. + than looking it up. `clients/endpoints.rs` holds every compiled-in hostname + and the `ATGC_*` variable that moves it. 2. **Nothing outside `cmd/` imports `cmd/`.** A command is the top of the - tree; anything two commands both need belongs lower down. Enforced by the - compiler now — items are `pub(super)` or `pub(in crate::cmd)`, and - `pub(crate)` is left only where `main.rs` or a doc link genuinely reaches - in. + tree; anything two commands both need belongs lower down. The compiler + enforces this now: items are `pub(super)` or `pub(in crate::cmd)`, and + `pub(crate)` is left only where `main.rs` or a doc link reaches in. 3. **`clients/git/` owns every interaction with git.** Nothing else builds a `Command::new("git")`, which is what makes the non-interactive gate universal rather than per-caller. @@ -32,28 +31,28 @@ term/ how output reaches a person or a pipe ## `config/` may import `clients/` Not a back-edge. `~/.config/atgc` does not hold opaque bytes, it holds -*client specifications* — which account, which PDS, which session — and -deciding whether the thing on disk is still valid is client-specific -validation. Pushing those calls up into `cmd/` would make every command -repeat them, to keep a diagram tidy. +*client specifications*: which account, which PDS, which session. Deciding +whether the thing on disk is still valid is client-specific validation. +Pushing those calls up into `cmd/` would make every command repeat them, to +keep a diagram tidy. -The constraint that remains is narrower and is the one that matters: +The constraint that remains is narrower, and it is the one that matters: **no client asks `config/` who we are.** A client takes the account, the DID or the session as an argument, from the command that already knows. It may -reach for `config/dir` — that is a path, not an identity. The difference is +reach for `config/dir`, which is a path, not an identity. The difference is testability: a client handed a DID can be driven with any DID, while a client that looks one up can only be driven by the machine it is running on. ## When a file becomes a folder -Roughly 2,000 lines **and** a seam. Size says look; a seam says split. -Splitting by verb is not a seam — `pr comment` does not get its own file for -being a different word. The seams that exist are read vs write vs somebody -else's pull, and knot-only vs config-only. +Roughly 2,000 lines **and** somewhere to divide it. Size says look; a real +dividing line says split. Splitting by verb does not count, since `pr comment` +does not get its own file for being a different word. The divisions that exist +are read vs write vs somebody else's pull, and knot-only vs config-only. Each `cmd/` module carries its own clap definitions, with help text as doc comments. There is no parallel structure mirroring the args. -**Still wrong:** `auth.rs` is two subjects at the crate root — the OAuth -client and the `auth` verbs. It wants splitting into -`clients/atproto/oauth/` and `cmd/auth.rs`. Do not add to it in place. +**Still wrong:** `auth.rs` is two subjects at the crate root, the OAuth client +and the `auth` verbs. It wants splitting into `clients/atproto/oauth/` and +`cmd/auth.rs`. Do not add to it in place. diff --git a/docs/output.md b/docs/output.md index ee1aa3d..abc64b6 100644 --- a/docs/output.md +++ b/docs/output.md @@ -1,7 +1,7 @@ # Output contracts -Three promises a script can rely on. Everything else about output — -which flags exist, what a command prints — is in `atgc --help` or +Three rules that hold for every command. Everything else about output, like +which flags exist and what a command prints, is in `atgc --help` or in the output itself. ## stdout is the answer; stderr is everything else @@ -11,28 +11,30 @@ On every flag. Notes, warnings and progress go to stderr, so `-q`/`-qq` and ## `--json` is one value on stdout -Every command takes it. Reads emit the derived view rather than the raw -record — state, number and resolved handle are computed from several sources -and are not fields on anything. Writes emit what they did, always carrying -`dry_run`; under `--dry-run` the identifiers a write would have minted are -`null` rather than guessed. +Every command that reads or writes something takes it. `about`, `agent` and +`completion` do not: their output is already the whole answer and there is no +second rendering to choose. -`pr checkout` is in neither group: it writes no record, so it takes no -`--dry-run` and carries no `dry_run`. What it did is local git — a branch, and -under `--worktree` a directory — and those are what it prints. +Reads emit the derived view rather than the raw record. State, number and +resolved handle are computed from several sources and are not fields on +anything. Writes emit what they did, always carrying `dry_run`; under +`--dry-run` the identifiers a write would have minted are `null` rather than +guessed. -`api` is the one command with no `--json` at all, and the exception proves the -rule. Its whole output is the service's own answer, which is already JSON, so -there is no second rendering for the flag to choose between — and a flag that -changed nothing would read as a promise that something changes. What `--json` -really buys is on by default there: one value on stdout, no colour, no padding. -Under `--dry-run` that value is the request it would have sent, carrying -`dry_run` like every other write. +`pr checkout` is in neither group. It writes no record, so it takes no +`--dry-run` and carries no `dry_run`. What it did is local git: a branch, and +under `--worktree` a directory. Those are what it prints. + +`api` is the one read-or-write command with no `--json`. Its output is the +service's own answer, which is already JSON, so there is nothing for the flag +to switch between. What `--json` would buy is on by default there: one value +on stdout, no colour, no padding. Under `--dry-run` that value is the request +it would have sent, carrying `dry_run` like every other write. Unknown is `null`, never `"?"`. No `@` on handles. No colour. **Stability:** within a minor version, a field may be added in a `feat`. -Removing or renaming one is a breaking `!` — see CONTRIBUTING.md's versioning +Removing or renaming one is a breaking `!`. See CONTRIBUTING.md's versioning rules. ## Exit status says which kind of failure @@ -41,38 +43,37 @@ rules. |---|---| | 0 | it worked | | 1 | unclassified failure; the message is the only description | -| 2 | the command line does not make sense — including "this is not a checkout of the repo you named" | +| 2 | the command line does not make sense, including "this is not a checkout of the repo you named" | | 3 | no usable session for the account this would act as | | 4 | there is a session and it was refused: a missing scope, somebody else's record, a knot that said no | | 5 | an identifier resolved to nothing | | 6 | a host did not answer. The one code that means *try again unchanged* | -| 7 | state moved underneath the command — a lost compare-and-swap, a held lock | +| 7 | state moved underneath the command: a lost compare-and-swap, a held lock | -The numbers are a public interface: `crate::exit::Exit` spells them out as a +The numbers are a public interface. `crate::exit::Exit` spells them out as a match so that inserting a variant cannot renumber the rest. `atgc doctor local` and `atgc doctor remote` are the two reports with an -answer *and* a non-zero status. The report is the answer, so it goes on stdout, and -the status summarises the report rather than standing in for it: each failing -row carries the status the operation it stands for would have exited with — a -missing session is `3`, a scope gap or an unregistered push key `4`, a host -that did not answer `6` — and with more than one, the first row wins. A +answer *and* a non-zero status. The report is the answer, so it goes on +stdout, and the status summarises the report rather than standing in for it. +Each failing row carries the status the operation it stands for would have +exited with: a missing session is `3`, a scope gap or an unregistered push key +`4`, a host that did not answer `6`. With more than one, the first row wins. A warning never decides it, or `doctor local` would fail in every checkout that has never been near Tangled. -The two ask opposite questions and only one of them can say what to do about +The two ask opposite questions, and only one of them can say what to do about the answer. `doctor local` orders its rows by what depends on what, so the -first broken row is the one to fix first and it says so; `doctor remote` +first broken row is the one to fix first and it says so. `doctor remote` reports on services that need nothing from each other, so it names the row the -status came from and stops there. That difference is why every `remedy` in a -`doctor remote` report is `null`: there is no command here that mends somebody -else's host. +status came from and stops there. That is why every `remedy` in a +`doctor remote` report is `null`: no command here mends somebody else's host. `doctor remote --json` adds one field beside the rows: `lag_seconds`, how far Bobbin's newest pull request of yours trails the newest in your PDS. It is the `index` row as a number, because the audience for that object is something -that alerts and a threshold cannot be read out of a sentence. `null` wherever -the row is not a measurement — no account selected, no pull records of your -own, or either side declining to answer — and negative where Bobbin is ahead, -which is not lag: its listing spans every author and the comparison counts -only yours. +that alerts, and a threshold cannot be read out of a sentence. It is `null` +wherever the row is not a measurement, meaning no account selected, no pull +records of your own, or either side declining to answer. It is negative where +Bobbin is ahead, which is not lag: its listing spans every author and the +comparison counts only yours. diff --git a/docs/testing.md b/docs/testing.md index d1d1ec3..b065af3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -4,53 +4,61 @@ attached to this repo, so `prek run --all-files` plus `cargo test` locally is the actual gate, whatever `.tangled/workflows/ci.yml` claims. -**What earns a test.** Three things. Parsers of other people's data — every -`sh.tangled.*` record, DID document, Bobbin response and knot redirect is -written by software this project does not control, and the code is -deliberately permissive about them, which is exactly the kind of parsing that -fails silently. Things that are fiddly out of proportion to their size, like a -base64url decoder or character-safe truncation: short enough to look obviously -right, and with no natural manual check. And anything a commit message had to -justify, because the paragraph will not survive the next refactor but the test -will. Glue does not earn one — `main.rs` dispatch, the layout of a `println!`, -the four lines around a request whose parser is already tested. That kind of +**What earns a test.** Three things. + +Parsers of other people's data. Every `sh.tangled.*` record, DID document, +Bobbin response and knot redirect is written by software this project does not +control, and the code is permissive about them on purpose, which is the kind of +parsing that fails quietly. + +Things that are fiddly out of proportion to their size, like a base64url +decoder or character-safe truncation. Short enough to look obviously right, and +with no natural manual check. + +Anything a commit message had to justify, because the paragraph will not +survive the next refactor but the test will. + +Glue does not earn one: `main.rs` dispatch, the layout of a `println!`, the +four lines around a request whose parser is already tested. That kind of coverage costs the same to maintain as a real test, goes red for unrelated reasons, and creates the impression that a green suite means the tool works. **Hermetic, non-negotiable.** No network, no OAuth, no session, no reading or writing the real `~/.config/atgc`, no touching the user's git config, no -assuming a particular Tangled repo exists. Two traps are specific to this -crate. `HOME` cannot be redirected inside the test process: that needs -`std::env::set_var`, which is `unsafe` in edition 2024, and the crate forbids -unsafe code — so a function that reads `HOME` itself is not unit-testable, and -the answer is to take the directory as a parameter and leave one untested -wrapper that names `$HOME`. And the working directory is process-wide while -cargo runs tests on threads, so `testutil::TempRepo` takes a -module-level mutex and must stay the only thing in the tree that moves the -process. Network access is allowed in exactly one place: a test marked -`#[ignore]` whose message says why. If an `#[ignore]` ever pins a known bug -rather than a slow socket, its message must say *that*, because "ignored" -reads as "unimportant" and a green suite standing over a broken function is -worse than a red one. +assuming a particular Tangled repo exists. + +Two traps are specific to this crate. `HOME` cannot be redirected inside the +test process, because that needs `std::env::set_var`, which is `unsafe` in +edition 2024 and the crate forbids unsafe code. So a function that reads `HOME` +itself is not unit-testable; take the directory as a parameter and leave one +untested wrapper that names `$HOME`. And the working directory is process-wide +while cargo runs tests on threads, so `testutil::TempRepo` takes a module-level +mutex and must stay the only thing in the tree that moves the process. + +Network access is allowed in exactly one place: a test marked `#[ignore]` whose +message says why. If an `#[ignore]` ever pins a known bug rather than a slow +socket, its message must say so, because "ignored" reads as "unimportant" and a +green suite standing over a broken function is worse than a red one. **Fixtures for reads, mocks for writes.** Test a parser against real bytes -captured off the real service, never against a mock: a mock encodes what we -believe the service returns, so a test of a reader against it passes precisely -when our belief is self-consistent, which is the thing already known. A write -command is the opposite question — what atgc *sent*, in what order, as whom — -and that is atgc's own behaviour, which no fixture can observe. `tests/support/` -is the environment for it: mock services on loopback ports, a session that -restores with no network, and the real binary spawned as a child, which is -what makes an alternate `HOME` possible at all (`Command::env` is not -`unsafe`). Reach for it whenever the bug is about how two commands interact -rather than about one function's output — a round appended to the wrong -record, a chain linked to something outside its own batch, a write made with -the wrong account's credentials. +captured off the real service, never against a mock. A mock encodes what we +believe the service returns, so a test of a reader against it passes exactly +when our belief is self-consistent, which is the thing already known. + +A write command asks the opposite question: what atgc *sent*, in what order, as +whom. That is atgc's own behaviour, which no fixture can observe. +`tests/support/` is the environment for it, with mock services on loopback +ports, a session that restores with no network, and the real binary spawned as +a child. Spawning a child is what makes an alternate `HOME` possible at all, +since `Command::env` is not `unsafe`. Reach for it whenever the bug is about +how two commands interact rather than about one function's output: a round +appended to the wrong record, a chain linked to something outside its own +batch, a write made with the wrong account's credentials. **Keep this page this short.** Do not add per-feature notes, a list of what is verified by hand, or an inventory of what is not covered. That kind of writing -goes stale the week after it lands and nothing checks it: this page once ran -to 535 lines and named six source files that no longer existed. A fact about -one test belongs in that test's doc comment; how a fixture was captured -belongs beside the code that loads it; a claim that something is unproven -belongs at the claim, in the code, where a reader is standing when it matters. +goes stale the week after it lands and nothing checks it. This page once ran to +535 lines and named six source files that no longer existed. A fact about one +test belongs in that test's doc comment; how a fixture was captured belongs +beside the code that loads it; a claim that something is unproven belongs at +the claim, in the code, where a reader is standing when it matters. -- 2.51.2