Skip to content

Commit 16bf92b

Browse files
cavaliercoderclaude
andcommitted
Ground every ADR in principles, not development history
An ADR is read by someone deciding whether a change fits the design, who has never seen the branch that produced it and cannot reconstruct a state of the world that no longer exists. A passage turning on what the package used to do asks them to hold exactly that, and ages into lines that teach nothing once the thing being contrasted against is releases gone. Restates every such passage across the fifteen records as a property that holds now. Rejected alternatives stay -- relitigating them is what the record prevents -- but as standing arguments against designs a reader might propose again rather than as accounts of attempts: per-command stream setters, desc as a parsing stage, a Negatable field on the compiled flag, an unexported ir, and embedding a description into the implementation type. Amendment notes fold into the documents they qualify, with any content only they carried moving into the body. Status lines keep their dates. Also records in registries-carry-library-contributions that the pairing of a flag with its wrapper is a convention the package cannot enforce. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e76f023 commit 16bf92b

12 files changed

Lines changed: 169 additions & 175 deletions

‎docs/adr/argument-errors-print-usage.md‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,22 +63,21 @@ output formatted differently has `UsageFunc`.
6363
Exit codes and prefixes are untouched. The three-code contract in
6464
`docs/adr/exit-code-contract.md` and the `Argument error:` /
6565
`Program error:` / `Error:` prefixes in
66-
`docs/adr/human-readable-errors.md` stand as decided; this changes only
67-
what follows the error line.
66+
`docs/adr/human-readable-errors.md` stand as decided; only what follows
67+
the error line is settled here.
6868

6969
## Consequences
7070

71-
- Every bad command line now shows usage, so misuse costs more output.
71+
- Every bad command line shows usage, so misuse costs more output.
7272
A program that wants the terse behavior back reports errors itself:
7373
`Dispatch` returns the raw error and prints nothing.
7474
- The error line stays the first line on stderr, which is what scripts
7575
and tests that read only the head of the stream already observe.
7676
- A long usage message can scroll the error line away on a terminal.
7777
Printing usage first would keep the diagnostic nearest the prompt, but
7878
reads backwards and contradicts the prior art; rejected.
79-
- The no-handler report is two lines where it was one. Anything scraping
80-
that output now sees `Argument error: missing subcommand` before the
81-
usage message.
79+
- The no-handler report is two lines: anything scraping that output sees
80+
`Argument error: missing subcommand` before the usage message.
8281
- The shape of the report is wording, not API, with the same caveat
8382
`docs/adr/human-readable-errors.md` attaches to the prefixes: a caller
8483
must branch on `errors.As` and the exit code, never on stderr's

‎docs/adr/configuration-types-carry-no-behavior.md‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,6 @@ configuration only and keep everything they have.
5454

5555
## Consequences
5656

57-
- Breaking, but narrowly. Every program in the documentation already ended
58-
with `climux.Run(ctx, App)`, and the only non-test caller of any moved
59-
method was `RunWithArgs` itself. It breaks a program that reached for the
60-
methods rather than the functions, which the docs never taught.
6157
- `command.go` is now legibly the configuration type: a struct, a
6258
constructor, `Compile`, `lower`, and setters. The entry points and the
6359
error reporting live together in `climux.go`, which is what a reader

‎docs/adr/exit-code-contract.md‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
# Run reports outcomes as exit codes 0, 1 and 2
22

33
Status: accepted, 2026-08-23.
4-
Amended 2026-08-26: the constant for code 2 is `ExitCodeUsage`. It was
5-
`ExitCodeBadArgument`, which named only one of the three cases below.
64

75
## Context
86

‎docs/adr/handler-receives-invocation.md‎

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -81,44 +81,38 @@ parse result, which keeps the source type sealed.
8181
message built from it may show a full binary path. That is the composing
8282
author's call, not the package's.
8383

84-
## Note: the streams travel with the invocation
85-
86-
Added 2026-08-23, with the same status as the decision above.
87-
88-
`Command.Output(stdout, stderr)` was read only by help and error messages,
89-
so a program that pointed a command at a buffer captured the
90-
`Argument error:` line and nothing the handler printed, which is most of
91-
what a CLI emits. There was no stdin in the API at all. `Invocation`
92-
therefore carries `Stdin`, `Stdout` and `Stderr`, resolved from the invoked
93-
command and its ancestors, and `Output` gives way to `Command.Stdin`,
94-
`Command.Stdout` and `Command.Stderr`, one setter per stream. The setters
95-
were later removed and the streams moved onto `Run`; what survives here
96-
is that the invocation is where a handler finds them. See
84+
## The streams travel with the invocation
85+
86+
Where a handler's output goes is the second thing its author cannot know.
87+
The premise above is that a command is mounted by someone other than the
88+
person who wrote it, and the stream that command writes to is decided in
89+
the same place, and at the same time, as the path it is reached by. So
90+
`Invocation` carries `Stdin`, `Stdout` and `Stderr` alongside `Path`, and
91+
a handler looks in one place for both. Where those streams are set is
9792
docs/adr/streams-are-run-environment.md.
9893

99-
This follows from the decision above rather than qualifying it. The premise
100-
was that a command is mounted by someone other than its author; where its
101-
output goes is another thing that author cannot know, decided in the same
102-
place and at the same time as the path. The invocation is already where a
103-
handler looks for such answers, so there is nothing new to discover, and a
104-
handler that writes to `os.Stdout` cannot be tested by the very party who
105-
mounted it.
94+
Anything narrower fails the party doing the mounting. Streams reachable
95+
only by help and error messages let a program point a command at a buffer
96+
and capture the `Argument error:` line while missing everything the
97+
handler printed, which is most of what a CLI emits. A handler writing to
98+
`os.Stdout` cannot be tested by the very party that mounted it.
10699

107100
Consequences, beyond those above:
108101

109-
- `Invocation` is no longer only "what the command line said". It is what
110-
the handler needs in order to run, which is the wider promise and the one
102+
- `Invocation` is not only "what the command line said". It is what the
103+
handler needs in order to run, which is the wider promise and the one
111104
worth keeping: anything else the package resolves on a handler's behalf
112105
belongs here too.
113106
- Three more field names join the v1 surface, and handlers should use them.
114107
Nothing enforces it, as nothing can: `fmt.Println` in a handler is a
115108
defect the package can document but not detect. This mirrors `flag`,
116109
which offers `SetOutput` and cannot stop anyone printing past it.
117110
- Each stream is set and resolved on its own, so redirecting stdout leaves
118-
errors on stderr. Taking both writers at once meant inheriting them as a
119-
set: `Output(&buf, nil)` resolved a nil stderr and panicked on the first
120-
error message, and saying "leave this one alone" required passing a nil.
121-
- One word now follows each stream from the option that supplies it to
111+
errors on stderr. Taking both writers at once would inherit them as a
112+
set: a call like `Output(&buf, nil)` has to resolve a nil stderr or
113+
panic on the first error message, and saying "leave this one alone"
114+
means passing a nil.
115+
- One word follows each stream from the option that supplies it to
122116
`Invocation.Stdout`. That is worth more than matching
123117
`flag.FlagSet.SetOutput`, which has a single output stream and so never
124118
had to name a second one.

‎docs/adr/human-readable-errors.md‎

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@ mistyped a flag, and a library announcing its own name inside a host
2929
program's stderr is a leak of an implementation detail nobody asked to
3030
see, in the one case — the human's — where nobody benefits from it.
3131

32-
Separately, `docs/adr/exit-code-contract.md` already named a related loose
33-
end in its Consequences: a parse error prints `Argument error:` and a
34-
handler-raised usage error prints `Error:`, "because only the parser
35-
produces an `ArgumentError`" — true at the time, but no longer the whole
36-
story once a handler has a real reason to construct one itself.
32+
Separately, `docs/adr/exit-code-contract.md` names a related loose end in
33+
its Consequences: a parse error prints `Argument error:` while a
34+
handler-raised usage error prints `Error:`. That split holds only while
35+
the parser is the sole producer of an `ArgumentError`, and a handler has
36+
real reason to construct one itself — two mutually exclusive flags is a
37+
misuse only the handler can detect.
3738

3839
## Decision
3940

@@ -92,12 +93,12 @@ the flag's exact name and scope — which overlaps the still-undecided
9293
is worth designing once, not twice — and whether the JSON goes to stdout
9394
or stderr.
9495

95-
The prefix follows the error's type, not who constructed it. An earlier
96-
pass qualified this — `Argument error:` only when the `*ArgumentError`
97-
carried a `Cmd`, so that a handler raising one still printed `Error:` —
98-
which the code never implemented and which this amendment drops. A bad
99-
argument is a bad argument whoever noticed it, and the reader the prefix
100-
serves is the person who typed it, who does not care which layer caught
96+
The prefix follows the error's type, not who constructed it. Qualifying it
97+
by provenance — `Argument error:` only when the `*ArgumentError` carries a
98+
`Cmd`, so that a handler raising one prints `Error:` — serves nobody. A
99+
bad argument is a bad argument whoever noticed it, and the reader the
100+
prefix serves is the person who typed it, who does not care which layer
101+
caught
101102
them out.
102103

103104
There is still no `UsageErrorf`. A handler that discovers a usage problem
@@ -126,9 +127,9 @@ that a refactor had quietly reverted to 1.
126127
- An agent or script that only sees a CLI's stdout/stderr, rather than
127128
calling into the library, still has nothing structured to read. The
128129
direction is decided — a flag, JSON, built on `desc` — but not built.
129-
- Three prefixes now map onto three causes, so the wording of a message is
130-
no longer the only thing telling a misconfigured program apart from a
131-
mistyped command line. Exit codes cannot: both are 2.
130+
- Three prefixes map onto three causes, so the wording of a message is not
131+
the only thing telling a misconfigured program apart from a mistyped
132+
command line. Exit codes cannot: both are 2.
132133
- The prefixes are wording, not API, and the same caveat as `String()`
133134
applies — a program must not branch on them. A caller needing to tell
134135
the cases apart uses `errors.As`, and an agent reading only stderr still
@@ -141,9 +142,9 @@ that a refactor had quietly reverted to 1.
141142
because a successful `Compile` is what makes a program well-formed and
142143
the stream overrides are part of what failed it — `getStderr` resolves
143144
by walking the very parent links `Compile` checks. Honoring them would
144-
mean reading configuration the library has just declared invalid, and it
145-
used to address the report to the offending command's stream, chosen by
146-
that command's author rather than by the composer the message is for.
145+
mean reading configuration the library has just declared invalid, and
146+
addressing the report to the offending command's stream — chosen by that
147+
command's author rather than by the composer the message is for.
147148
Nothing is lost by writing early: `Compile` runs before argv is lexed,
148149
so no handler has run and the process exits 2 immediately. A program
149150
that wants these faults as values, which is the better place to catch

‎docs/adr/machine-readable-schema.md‎

Lines changed: 34 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -69,29 +69,27 @@ points at the leaf deliberately. A consumer that only reads documents —
6969
a tool diffing two of them, with no command tree anywhere in it — gets
7070
the plain data and none of the implementation.
7171

72-
The name is the one the retired package had, because the role it names is
73-
the one that survived. What is different is that `desc` is no longer a
74-
*stage*: it failed as one because the lexer read a projection carrying no
75-
`Value`, so resolving an argument to something the applier could act on
76-
grew a private parallel tree and a `bind()` pass, eighty lines spent
77-
keeping one field off a struct. Nothing internal reads a description. The
78-
parser still runs on `ir`, and the conversion happens once, at the
79-
boundary, on the way out. Calling the package `schema` was considered and
80-
dropped: the format is published as a JSON Schema document, and one word
81-
should not name both the Go types and the thing that validates them.
82-
83-
Embedding the description into the implementation type was the other
84-
candidate and does not work, for a reason independent of that history:
85-
`encoding/json` flattens an anonymous embedded struct, so
86-
`ir.Command{desc.Command; Handler ...}` marshals to exactly the keys it
87-
marshals to today. It would buy a type and not a format.
88-
89-
### `ir` no longer marshals
90-
91-
Nothing in `ir` is encoded or decoded, and its `json:"-"` tags come off
92-
with the field comments that explain them. The guarantee that behavior
93-
never leaks into a document stops being a tag discipline pinned by
94-
`TestMarshalOmitsBehavior`, which retires, and becomes structural:
72+
`desc` is a boundary, not a *stage*. Nothing internal reads a description:
73+
the parser runs on `ir`, and the conversion happens once, on the way out.
74+
A description used as a stage does not work, because a lexer reading a
75+
projection that carries no `Value` cannot resolve an argument to anything
76+
the applier can act on, and putting that back costs a private parallel
77+
tree and a `bind()` pass — eighty lines spent keeping one field off a
78+
struct. The package is not called `schema`, because the format is
79+
published as a JSON Schema document and one word should not name both the
80+
Go types and the thing that validates them.
81+
82+
Embedding the description into the implementation type is the other
83+
candidate and does not work either: `encoding/json` flattens an anonymous
84+
embedded struct, so
85+
`ir.Command{desc.Command; Handler ...}` marshals to exactly the keys
86+
`ir.Command` marshals on its own. It buys a type and not a format.
87+
88+
### `ir` does not marshal
89+
90+
Nothing in `ir` is encoded or decoded, and it carries no `json:"-"` tags.
91+
The guarantee that behavior never leaks into a document is not a tag
92+
discipline pinned by a test but a structural property:
9593
`Describe` copies named fields, so a behavior field added to `ir` later is
9694
absent from the output because nothing wrote it there, not because a tag
9795
excluded it.
@@ -327,17 +325,13 @@ operands is an ordinary configuration error naming the author's own
327325
mistake. That question can stay open on its own merits rather than
328326
blocking this.
329327

330-
The two-type-model ADR states a rule this changes, and should be edited to
331-
say three when this is accepted rather than before.
332-
333328
`ir` gains two fields, neither of them behavior. A golden document for
334329
`examples/orbital` is what will make an unintended wire change visible,
335330
alongside the help goldens already there.
336331

337332
Machine-readable error output should project through the `desc` types
338-
rather than defining a second vocabulary for a command and a flag. That
339-
was already the intent recorded for it, against the `desc` that no longer
340-
existed when it was written.
333+
rather than defining a second vocabulary for a command and a flag, which
334+
is what docs/adr/human-readable-errors.md leaves open for it.
341335

342336
Recording an option's effect is a change to `ir`, and the only one here
343337
that is not additive. `ir.Flag`'s comment says today that what a
@@ -351,13 +345,13 @@ generated from and the effect of naming it, and `internal/argv` fills in
351345
the second where it already fills in the first.
352346

353347
`ir` still knows no dialect by this, and the distinction has to be exact,
354-
because a narrower version of the same field was rejected twice: first as
355-
a negated form on the compiled flag, then as a `Negated` boolean on a
356-
claim, both as a dialect feature leaking into the type every dialect
357-
shares. The test that sank them was whether a dialect lacking the feature
358-
would leave the field meaningless, and a boolean fails it. A dialect
359-
without negation reports false everywhere, and one whose modifier is a
360-
repeat or a reset needs a second field while the first sits dead.
348+
because the narrower versions of the same field do not survive it. A
349+
negated form on the compiled flag, or a `Negated` boolean on a claim, is a
350+
dialect feature leaking into the type every dialect shares. The test is
351+
whether a dialect lacking the feature leaves the field meaningless, and a
352+
boolean fails it: a dialect without negation reports false everywhere, and
353+
one whose modifier is a repeat or a reset needs a second field while the
354+
first sits dead.
361355

362356
An effect names no feature. It is a term the dialect writes and a
363357
consumer reads, over a vocabulary that is open, so a dialect with no
@@ -374,10 +368,10 @@ the shared type changing. Two rules keep it there:
374368
rather than by reading the effect. The modifier still lives entirely
375369
inside the dialect, and the effect exists for the document alone.
376370

377-
Under those, this supersedes the earlier rejections rather than
378-
contradicting them. What the type records is not that this dialect
379-
negates, but that the dialect had something to say about an option, in a
380-
word it and the reader share.
371+
Under those two rules an effect is not the field those narrower versions
372+
would have been. What the type records is not that this dialect negates,
373+
but that the dialect had something to say about an option, in a word it
374+
and the reader share.
381375

382376
A flattened per-command projection is anticipated and deliberately not
383377
built. Something calling one command wants that command's calling

‎docs/adr/middleware-wraps-handlers.md‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,21 @@ reasons that compound:
2323
discovered when someone deprecates a command on data that never covered
2424
it.
2525

26-
Until now the only answer was the `Wrap` idiom shown in
26+
Without a mechanism for it, the only answer is the `Wrap` idiom shown in
2727
`example_di_test.go` — a closure returning a `HandlerFunc`, which climux
28-
does not provide so much as permit — registered by each command at the
29-
point it declares its handler. That leaves the decision with the wrong
30-
party. climux exists so that many teams can compose one binary, and the
31-
team that decides a subtree must be measured, or audited, is the one
32-
assembling it.
28+
does not provide so much as permit — applied by each command where it
29+
declares its handler. That leaves the decision with the wrong party.
30+
climux exists so that many teams can compose one binary, and the team
31+
deciding that a subtree must be measured, or audited, is the one
32+
assembling it rather than each team that wrote a handler.
3333

34-
`orbital` had already shown the cost before this change: three unrelated
35-
packages each imported a `middleware` package and threaded the audit
36-
identity through their own `HandleFunc` calls, and `--trace` advertised
37-
"a timing trace for every command" while tracing only the three that
38-
happened to opt in.
34+
The cost arrives as soon as a binary is large enough to want the
35+
mechanism. A concern applied handler by handler makes every package
36+
carrying it import the package defining it and thread the same state
37+
through its own `HandleFunc` calls, and a flag such as `--trace`, which
38+
advertises "a timing trace for every command", traces only the commands
39+
that remembered to opt in — with nothing in the flag's own description to
40+
tell a reader which those are.
3941

4042
## Decision
4143

‎docs/adr/path-scoped-flag-names.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ mounted.
5050

5151
## Consequences
5252

53-
- The debugging trap goes away: within any one invocation, a name means one
53+
- There is no debugging trap: within any one invocation, a name means one
5454
flag bound to one variable.
5555
- Validation becomes ancestry-aware rather than per command, so it costs a
5656
walk down each path rather than a pass over each command.

0 commit comments

Comments
 (0)