Let the exit-code taxonomy reach the two consumers that read it #342

Merged
coilysiren merged 1 commit from exit-codes into main 2026-08-30 05:41:06 +00:00
Owner

Closes #341.

pkg/exitcode has carried Coded, From, and a doc comment saying "main.go checks this via errors.As" since it was written. Nothing in the repo ever called it - grep -rn "exitcode.Coded" returns no non-test hit. Every coded error the engines carefully build was constructed and then thrown away.

Three changes, one per consumer.

exitcode.Of

Resolves an error to its declared code, Generic when it declares none, Success for nil. The resolver both call sites wanted and neither had.

The generated binary

Exits with that code instead of a flat 1. It also sets CommandNotFound, because urfave exits 3 on an unknown subcommand and 3 is UpstreamFailed - a typo was being reported to an orchestrator as an upstream fault. A denied verb and a misspelt one both land there, and the message deliberately does not say which, since deny is absence.

The audit row

Records the same code, and says reject instead of accept when policy refused. Every failing row used to read accept / exit_code: 1, so the durable log could not separate a guard refusal from a bad flag either. That is the half that matters months later, when the terminal is long gone.

Measured, on a generated binary over a real MCP upstream

case before after
permitted call 0 0
policy refusal 1 2
missing required input 1 5
unknown or denied verb 3 5

Audit row for the refusal now reads decision=reject exit_code=2, against exit_code=5 for the bad flag beside it.

Scope

This is why my claim in #336 and #337 that "the exit-code taxonomy binds unchanged" was wrong, and wrong for every dialect rather than only mcp. Corrected on #336.

Verified with go build, go vet, go test ./..., golangci-lint run ./..., and pre-commit run --all-files, plus the before/after measurement above against the node-stats MCP server on kai-server.

Closes #341. `pkg/exitcode` has carried `Coded`, `From`, and a doc comment saying "main.go checks this via errors.As" since it was written. **Nothing in the repo ever called it** - `grep -rn "exitcode.Coded"` returns no non-test hit. Every coded error the engines carefully build was constructed and then thrown away. Three changes, one per consumer. ## `exitcode.Of` Resolves an error to its declared code, `Generic` when it declares none, `Success` for nil. The resolver both call sites wanted and neither had. ## The generated binary Exits with that code instead of a flat 1. It also sets `CommandNotFound`, because urfave exits **3** on an unknown subcommand and 3 is `UpstreamFailed` - a typo was being reported to an orchestrator as an upstream fault. A denied verb and a misspelt one both land there, and the message deliberately does not say which, since deny is absence. ## The audit row Records the same code, and says `reject` instead of `accept` when policy refused. Every failing row used to read `accept` / `exit_code: 1`, so the durable log could not separate a guard refusal from a bad flag either. That is the half that matters months later, when the terminal is long gone. ## Measured, on a generated binary over a real MCP upstream | case | before | after | | --- | --- | --- | | permitted call | 0 | 0 | | policy refusal | 1 | **2** | | missing required input | 1 | **5** | | unknown or denied verb | 3 | **5** | Audit row for the refusal now reads `decision=reject exit_code=2`, against `exit_code=5` for the bad flag beside it. ## Scope This is why my claim in #336 and #337 that "the exit-code taxonomy binds unchanged" was wrong, and wrong for **every** dialect rather than only mcp. Corrected on #336. Verified with `go build`, `go vet`, `go test ./...`, `golangci-lint run ./...`, and `pre-commit run --all-files`, plus the before/after measurement above against the node-stats MCP server on kai-server.
fix(exitcode): let the taxonomy reach the two consumers that read it
All checks were successful
ci / secrets (pull_request) Successful in 10s
ci / lint (pull_request) Successful in 38s
ci / test (pull_request) Successful in 51s
e2cfe7871c
closes #341

pkg/exitcode has carried Coded, From, and a documented contract saying
"main.go checks this via errors.As" since it was written. Nothing in the
repo ever called it: `grep -rn exitcode.Coded` returned no non-test hit.
So every coded error the engines build was constructed and then discarded.

Three changes, one per consumer.

exitcode.Of resolves an error to its code, Generic when it declares none.
That resolver is what both call sites wanted and neither had.

The generated main exits with it instead of a flat 1, and sets
CommandNotFound so an unrecognised verb exits UserError rather than
urfave's own 3, which collides with UpstreamFailed and reported a typo as
an upstream fault. A denied verb and a misspelt one land there alike, and
the message deliberately does not say which: deny is absence.

The audit row records the same code, and says `reject` rather than
`accept` when policy refused. Every failing row used to read
accept/exit_code=1, so the durable log could not separate a guard refusal
from a bad flag either. That is the half that matters months later.

Measured on a generated binary over a real MCP upstream, before and after:

  policy refusal          1 -> 2
  missing required input  1 -> 5
  unknown or denied verb  3 -> 5
  permitted call          0 -> 0

This is why #336 and #337 claiming "the exit-code taxonomy binds
unchanged" was wrong for every dialect, not only mcp. Corrected there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: platform
coilysiren deleted branch exit-codes 2026-08-30 05:41:06 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/umbra!342
No description provided.