# Output contracts Five 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 On every flag. Notes, warnings and progress go to stderr, so `-q`/`-qq` and `ATGC_LOG` can turn them down without touching the answer or the exit code. ## `--json` is one value on stdout 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. 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. `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. Two more sit outside it. `pr diff` prints a patch, and a patch is the thing `git am` and `git apply` read: wrapping it in an object would mean every caller unwrapping it again, and `--interdiff` hands the whole job to `git range-diff` besides. `auth login` has non-interactive callers and still takes no `--json`, because the one value stdout may carry would have to be the authorization URL, which is printed long before the login has a result to report. Off a terminal it prints that URL alone on a line instead — no prose and no link escape, since whatever is reading is going to open it — and the login's own lines move to stderr. A CI job is the one session it refuses, because there is nothing there that could open the page. The other five `auth` verbs take the flag. `auth status --json` says `resolved`, not `active`. The field marks the account that answered, at whichever of the five ranks answered — which is a different thing from the *default*, one rank down, that answers only when nothing else does. A row can be resolved without being the default and the other way round, and one word for both is how that got confused. The rename is a breaking change to the shape and has no alias: unlike the registry on disk, nothing reads an old `--json` document back. `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. The three `logs` readers are the one place that prints JSON Lines: one log record per line, as the bytes are on disk, and nothing else on stdout. They cannot be one value, because `--follow` is an unbounded stream that ends when somebody interrupts it — the closing `]` of an array is a byte that never arrives, and a caller that read to EOF before parsing would hang on the case the flag is for. Emitting an array without `--follow` and lines with it would be worse: the same flag on the same command returning two shapes depending on another flag. So it is lines on every flag, and an empty result is zero lines rather than `[]`. Everything else about `--json` still holds here: the unreadable-line warnings, the "no log yet" note and the `--follow` "waiting …" step are all on stderr. Being the bytes on disk has one further consequence, and it is deliberate: **the log record shape is the schema.** `ts`, `inv` and `seq`, the `snake_case` `event` tag, and the fields of each event are a public interface under the stability rules below, and `crate::logging::file`'s `Record` and the three `Event` enums say so where somebody renaming a field will see it. The alternative was to re-serialize each line through this build's own enum, which would silently drop any field a *newer* atgc had added — exactly during the incident where an old binary is reading a new machine's log. 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 rules. ## A date column is UTC Every listing that shows a `created` or `opened` date shows the UTC calendar date of the instant, whatever offset the record was written with. Records carry a whole RFC 3339 timestamp and a column shows ten characters of it, so something has to choose between three dates: the writer's own offset, the reader's local zone, and UTC. UTC is the only one that makes two rows comparable, which is what a column is for. The writer's offset means two rows of one listing can disagree by a day about the same instant, and the reader's zone means the same listing prints differently on two machines and a piped listing depends on `TZ`. The visible consequence is intended: a record written at `+03:00` just before midnight prints the previous day. A timestamp that does not parse falls back to its first ten characters rather than erroring — a malformed record must not stop a listing rendering — and an empty one prints empty rather than a placeholder. `--json` is unaffected: it emits the stamp as the record carries it, and the date column is a rendering. ## Somebody else's text is not an instruction Every title, body, comment and excerpt a command prints came out of a record written by an account atgc has never met, and opening a pull or an issue against a repo needs no permission on it. A terminal does not draw a string, it interprets one, so anything printed at a person first goes through `term::text`: C0 control characters except `\n` and `\t` are dropped, and so are `U+007F` and the C1 range. A value going into a fixed-width column loses its `\n` and `\t` as well, because a title holding a newline prints a second line that reads as a second row. Dropped rather than escaped. `\x1b[2J` prints as `[2J`: the reader sees that something was there and nothing acts on it. Two outputs are exempt, both because they are not lines for a person. `--json` emits what the record holds — serde escapes control characters, and a caller reading the document back wants the value, not a rendering of it. `pr diff` emits a patch, which is bytes for `git am`; filtering one would corrupt it, and git's own pager decides what to do with what is in there. ## Exit status says which kind of failure | code | meaning | |---|---| | 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" | | 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 | 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`. 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 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` reports on services that need nothing from each other, so it names the row the 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. 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.