docs(exitcodes): publish that a 429 can exit 2, which the contract denied - #591
Conversation
…nied
The published exit-code table said, flatly:
| 6 | Rate limited — throttled by the API (HTTP 429). |
| 2 | Usage error — a bad flag, a missing required flag or argument, … |
pkg/civitai reclassifies the API's deep-paging cap — a 429 whose message says
"too many pages" / "use cursors instead" — to ErrBadRequest, i.e. exit 2. That
is deliberate and correct: the capped request is PERMANENT, so a generic 429
backoff-and-retry loop spins on it forever, and the remedy is --cursor instead
of --page, which is a change to the invocation.
But the contract denied it, in the dangerous direction. A scripter reads the
table, writes `if rc == 6: backoff; retry`, gets 2, and reads row 2 as "you
passed a bad flag". The visible MESSAGE is identical in both cases — the code is
the only discriminator — so the one thing the contract had to publish is the one
thing it did not.
WHAT CHANGES
Only the published text. No behaviour moves: the reclassification, its narrow
message match and its bounds are unchanged.
- exitCodeDocs row 6 gains "**Not every 429 lands here**: the deep-paging cap is
a usage error and exits `2`" plus three Detail bullets.
- Row 2 gains a bullet, because a reader who lands on 2 must be able to learn
why a 429 put them there.
- README's table and per-code sections regenerated FROM THE GENERATOR'S OWN
BYTES rather than hand-edited.
🔴 THE PRE-SPLIT CLAUSE IS KEPT VERBATIM. A first draft rewrote row 6's summary
to "a genuine throttle from the API (HTTP 429)" and
TestEveryPreSplitClauseSurvives caught it: that guard pins pre-split contract
text word for word, so the addition has to arrive BESIDE the old clause, not
instead of it. The guard was right and the draft was wrong.
PINNED, SO IT CANNOT BE SILENTLY DELETED AGAIN
exitcodes_doc_test.go's guards are all AGREEMENT guards — they stay green if a
Note is deleted, because deleting it moves both surfaces together. That is how
this drift survived. So the decision is added to contractClaims, the ledger of
things the published contract must SAY, with pinnedBy naming the behavioural
guard (pkg/civitai's TestDeepPagingCapClassifiesOnTheWireMessageNotTheStrippedOne).
MUTATION MATRIX
delete row 6's Detail TestPublishedExitCodeClaims fails, naming
"structurally doomed" / "Branch on the exit code"
delete the reclassification pkg/civitai reddens — the behaviour is pinned
independently of the prose
ledger self-check TestExitCodeClaimsLedgerIsNotVacuous passes
⚠ Two of my own instruments were wrong here and are worth recording. A first
mutation run reported the claim SURVIVING — it had not, my `-run 'Contract'`
filter matched no test at all. And an awk extraction of the guard's "paste this"
output captured the failure message's `want` AND `got` halves, leaking the
literal string "got:" into README.md; regenerating from readmeExitCodeSections()
directly is what fixed it. Read the runner's own output, not a filter's silence.
Gate: make ci green (21 packages ok); make lint 0 issues.
Refs #542.
…9 that exits 5 Audit round 1 on #591. Two 🔴, both the PR's own stated deliverable. 🔴 IT UPDATED FOUR SURFACES AND LEFT THE FIFTH — the one a user reaches. README's hand-written Troubleshooting row still read | `rate limited (429)` | Throttled; exit `6`. … That is the row README's own Troubleshooting preamble tells the reader to look up, and the measured cap message matches its left column. So a capped user greps, lands there, reads "exit 6", and writes `if rc == 6: backoff; retry` — the exact loop this PR exists to prevent — while the generated section 30 lines above says the opposite. Precisely the "#371 shipped having updated two of three" failure AGENTS.md warns about, in a PR whose whole purpose was closing a contract gap. The row now names all three codes and says to branch on the code. 🔴 THE LEDGER ROW WAS NOT FLOORED, so the PR body's "PINNED, SO IT CANNOT BE SILENTLY DELETED AGAIN" was false. exitcodes_claims_test.go's own comment says protection is opt-in per row and to "append the name in the same commit as the row"; the row was added and the name was not. Measured: delete the new row -> suite GREEN (mutant survives) delete an existing FLOORED row -> RED, "the ledger row … is gone" (control) so the green was the omission, not a broken instrument. Name appended; the same mutant now exits 1 with that message, and I re-ran it after confirming `go vet` accepts the mutant — the first attempt cut at the `phrases` block's brace and died as a COMPILE error, which proves nothing. 🟡 A 429 CAN ALSO EXIT 5, AND NOTHING SAID SO. A throttle carrying `Retry-After` is retried (retry.go:226-234); if it survives readMaxAttempts, retryExhaustedError tags it ErrNetwork (retry.go:170-178) — exit 5. The exit-6 text promised 6 for the case it itself named, and the exit-5 row listed only 502/503/504. Both now state it: one message, three codes. Edited in `exitCodeDocs`, not in README. The first attempt at this patched the README bullet directly and TestREADMEExitCodeSectionsAreGenerated caught it — that section is generated, exactly as AGENTS.md says. The sections were then regenerated from `readmeExitCodeSections()` via a throwaway test rather than pasted out of a failure message, and that test was deleted. Also: claudedocs/decisions/38 still said "The README does not document the 429 → exit 2 reclassification at all" — the record AGENTS.md routes readers to, now contradicted by the tree. Rewritten to record what shipped, that the first pass left the fifth surface, the third exit code, and the real residual: nothing ties a Troubleshooting row's stated exit code to exitCodeDocs, which is why this went stale and can again. Gate: make ci green, 22 package result lines, 0 FAIL (counted, not read off an exit code).
Round 1 — two 🔴, both this PR's own stated deliverableFull audit (round 0 + nine axes), dispatched blind. The payload's direction is right and its 🔴 It updated four surfaces and left the fifth — the one a user reaches
That is the row README's own Troubleshooting preamble tells the reader to look up, and the Fixed: the row now names all three codes and says to branch on the code. 🔴 The ledger row was not floored, so "cannot be silently deleted again" was false
So the green was the omission, not a broken instrument. Name appended; the same mutant now 🟡 A 429 can also exit 5, and no surface said soA throttle carrying How that was edited — because the first attempt was wrongI patched the README bullet directly and 🟡 The decision record contradicted the tree
Ledger: |
… two Round 2 on #591. Four findings, and the first refutes this PR's own headline. 🔴 "THIS ONE MESSAGE HAS THREE EXIT CODES" IS FALSE. Measured with the built binary against a local 429 server, four combinations: Retry-After body rc message printed present cap 5 Civitai returned HTTP 429 after 4 attempts — … present generic 5 (same) absent cap 2 rate limited (429): You've requested too many pages… absent generic 6 rate limited (429): Too Many Requests… The 429 STATUS reaches 2/5/6. The MESSAGE `rate limited (429)` reaches only 2 and 6 — exit 5 is produced by retryExhaustedError BEFORE readError's 429 branch runs, so it prints something else entirely. Round 1 put a `5` limb on the Troubleshooting row, which is keyed on the message: a user who hits exit 5 greps the string they saw and finds no row at all, while the row that claims their code is keyed to a message they never saw. The row now says TWO codes and points at a new row keyed on `Civitai returned HTTP` — the fragment that actually exists in source, per that index's own stated rule that every left column is a fragment of a real printed string. `Civitai returned HTTP 429 after` is not: the 429 is a %d. 🔴 THE HEADER IS CONSULTED BEFORE THE MESSAGE, so a cap-worded 429 carrying Retry-After exits 5, not 2 — measured, 4 requests. That is a structurally doomed request landing on the code to RETRY on, which is the exact hazard the 2-reclassification exists to prevent. It rests entirely on the vendored assumption that the server never attaches Retry-After to a cap 429, and there is no local guard on it, so it is now published rather than relied on silently. 🔴 AND THE NEW 429→5 CONTRACT WAS ITSELF UNPINNED — the same hole round 1 closed one row over, reopened one row further in the same commit. Measured: negating both sentences to their opposite left the suite fully green. There is now a contractClaim for it plus its floor entry; that negation, and deleting the row, both go red (the latter on a mutant go vet accepts). 🟡 decisions/38 said "The exit-5 row still lists only HTTP 502/503/504 after retries" — false about the tree its own commit shipped, since that commit added the second bullet. Corrected. 🟡 Three comments in pkg/civitai/retry.go still asserted that a Retry-After-less 429 IS the deep-paging cap. Measured false: a header-less GENERIC throttle is also terminal there and exits 6 — the cap is identified later, by readError, from the message. README:1078 was corrected away from that exact wording in the previous commit; the source copies were not, so one commit disagreed with itself. All three now state that the absent header decides whether to RETRY and does not identify the cap. Gate: make ci rc=0, 0 FAIL, golangci-lint 0 issues — lint run this time, after the previous commit reached CI red for skipping it. One note on method: my first three attempts to measure this were wrong, and all three read as plausible. A stale server on the port returned 401s for every row; then zsh did not word-split `set -- $combo`, so all four "combinations" ran as no-header/generic and returned a uniform rc=6. Only after curl-ing the fixture server to prove both switches worked did the real matrix appear.
Round 2 — it refuted this PR's own headlineDelta over 🔴 "This one message has THREE exit codes" — measured, it has twoBuilt the binary and ran it against a local 429 server, four combinations:
The 429 status reaches 2/5/6. The message Round 1 put a 🔴 The header is consulted BEFORE the message — a cap 429 with
|
| mutant | result |
|---|---|
| negate the 429→5 text in README and the generator | RED |
delete the new claim row (on a mutant go vet accepts) |
RED — ledger row … is gone |
🟡 Two prose surfaces contradicted the tree
decisions/38 said "The exit-5 row still lists only HTTP 502/503/504" — false about the
tree its own commit shipped. And three comments in pkg/civitai/retry.go still asserted a
Retry-After-less 429 is the deep-paging cap. Measured false: a header-less generic
throttle is also terminal there and exits 6 — the cap is identified later, by readError,
from the message. README:1078 was corrected away from that exact wording in the previous
commit; the source copies were not, so one commit disagreed with itself.
Method note, because three attempts to measure this were wrong
All three read as plausible. A stale server on the port returned 401s for every row; then
zsh did not word-split set -- $combo, so all four "combinations" ran as
no-header/generic and returned a uniform rc=6. Only after curl-ing the fixture server to
prove both switches worked did the real matrix appear. The uniformity was the tell twice
and I read past it twice.
Ledger: round 2 · payload lines changed THIS round: 41 · elapsed: ~1.5h.
Gate: make ci rc=0, 0 FAIL, golangci-lint 0 issues — run this time.
1. "one message, three codes" is retracted; measured, `rate limited (429)` reaches 2 or 6 and never 5, because exit 5 prints a different message
2. the Troubleshooting index has two rows now, the second keyed on `Civitai returned HTTP`, a fragment that exists in source
3. header-before-message precedence is published: a cap-worded 429 carrying Retry-After exits 5, not 2, measured at 4 requests
4. the 429→5 contract has a contractClaim and a floor entry; negating the text in both surfaces, and deleting the row, both go red
5. decisions/38's false "exit-5 row still lists only 502/503/504" is corrected
6. retry.go's three comments no longer claim the absent header identifies the cap; they state it decides whether to RETRY
…im, and the other 🔴 was unpinned Round 3. Five findings; two are the previous round's fix failing in the same way it diagnosed. 🔴 decisions/38 STILL SHIPPED "one message — `rate limited (429)` — reaches 2, 5 or 6" — the exact sentence the previous commit exists to retract, and which README and exitcodes_doc.go now contradict outright. Worse, that commit's one edit to the bullet was APPENDED to the false sentence: "Both the exit-5 and exit-6 rows now state it", where *it* is the refuted claim. So the record briefly asserted the opposite of the published contract while claiming to describe it. An agent hitting AGENTS.md item 38's trigger would read that, and re-add the `5` limb to the Troubleshooting row on the decision record's authority — undoing the fix. That is the "#371 updated two of three surfaces" pattern, inside the paragraph that warns about it. Rewritten to record the refutation AND that the correction arrived here a commit late, because "the fix round's own prose is the likeliest next finding" is what this ladder keeps producing. 🔴 THE OTHER 🔴 FROM LAST ROUND WAS ENTIRELY UNPINNED. Deleting the header-before-message bullet from BOTH published surfaces left the whole suite green — the agreement guard moves them together and sees nothing, which is the blindness exitcodes_claims_test.go's own header describes. The previous commit wrote "the same hole round 1 closed one row over, reopened one row further in the same commit" and then did it again with its own new bullet. There is now a code-6 contractClaim for it plus a floor entry. Same mutant is red, naming two phrases. 🔴 AND THE NEW CLAIM WAS WALKABLE BY REWORDING. Of its three phrases, "service-availability" and "Retry-After" were ALREADY in exit code 5's text before the claim existed — so the row was one phrase deep. A rewrite restoring "reaches 2, 5 or 6" kept all three intact and the suite stayed green. Phrases are now ones a reversal has to delete; verified by applying that exact reversal, which is caught twice. 🟡 The new Troubleshooting row was keyed on `Civitai returned HTTP` but told a 429-only cause story. That string is emitted for ANY retriable status that exhausts its retries — 502/503/504 included, which is the commoner case — so a user in an outage read "a throttle that carried Retry-After". Rewritten to name the real set; the exit code and remedy were right either way. 🟡 The claim's pinnedBy cited retry.go:170-178 and :226-234. Both were correct at db8fcc8 and stale on arrival: the same commit inserted ~19 lines into that file and copied the numbers forward. Now 173-180 and 249, with a note to re-check them — they are printed verbatim in the guard's failure message, which is the moment someone follows them. Gate: make ci rc=0, 22 package lines, 0 FAIL, golangci-lint 0 issues. Method note: one mutation run in this round was mangled by zsh expanding backticks inside a double-quoted `python3 -c`, so only half of it applied. It still went red, which is exactly the shape that would let a half-applied mutant read as a verified one. Re-applied from a file and confirmed.
Round 3 — the fix round failed in the way it had just diagnosed, twiceDelta over 🔴
|
…it base rate is four for four (#597) Session handoff delta for the agent-setup-onboarding arc. Merged this pass: cli#583 (rank 19), #587, #588, #589, #590 (rank 24), #591 (rank 11), #594. In flight: #596 (rank 23), not audited. Issues #542 and #577 closed by hand with evidence, never by keyword. Records three things a commit message cannot: a deletion justified by a measurement inherits that measurement's SCOPE (#583's reduction deleted a relocation property the measurement never covered, caught by audit, fixed in #588); five commits shipped unaudited and four of four audited ones contained a defect; and the soft-wrap forgery is live on main outside #577's scope, now ranked with a closing condition. Rank 29 is new and is about this document: 105,958 B against a 65,536 B ceiling, so its budget test reds main for everyone. Pre-existing and untracked until now. This update cuts ~12 KB but leaves it ~28 KB over; the prune is its own commit. Docs-only.
…ly dropped (#598) `main`'s Troubleshooting index went back to | `rate limited (429)` | Throttled; exit `6`. For deep paging use `--cursor`… which is the exact 🔴 that #591 round 1 fixed, and it now contradicts the generated section thirty lines above it — which still correctly says the message reaches 2 or 6 and never 5. The `Civitai returned HTTP` row, the landing place for the exit-5 case, was gone entirely. HOW. #591 squash-merged at 02:51. At 03:50 I merged `main` into #585's branch, resolved the two real conflicts, and took #585's README as the base — then re-applied the main-side rows I could think of. #591's Troubleshooting rows were in a region that never conflicted, so git had nothing to say about them and my spot-check did not think of them. This is the third time this exact trap has fired today, each time in a region with no conflict: twice caught during a merge, once shipped to `main`. The lesson stuck only partly — on the second merge I did run the systematic diff and it found a drop my spot-check had missed. I did not run it on the first. 🔴 AND I NEARLY DESTROYED THE EVIDENCE. Having merged both PRs I deleted the orphan branch, saying "nothing unique" — while the `git diff --stat` I had just printed showed README differing by five lines. The object survived locally, and those five lines were exactly this fix. Both rows below are restored VERBATIM from that commit rather than retyped. Verified: the restored left column `Civitai returned HTTP` is a real fragment of a string the CLI prints (`pkg/civitai/retry.go:174`), per the index's own stated rule, and make ci + golangci-lint are clean on the result.
The published exit-code table said, flatly:
| 6 | Rate limited — throttled by the API (HTTP 429). |
| 2 | Usage error — a bad flag, a missing required flag or argument, … |
pkg/civitai reclassifies the API's deep-paging cap — a 429 whose message says
"too many pages" / "use cursors instead" — to ErrBadRequest, i.e. exit 2. That
is deliberate and correct: the capped request is PERMANENT, so a generic 429
backoff-and-retry loop spins on it forever, and the remedy is --cursor instead
of --page, which is a change to the invocation.
But the contract denied it, in the dangerous direction. A scripter reads the
table, writes
if rc == 6: backoff; retry, gets 2, and reads row 2 as "youpassed a bad flag". The visible MESSAGE is identical in both cases — the code is
the only discriminator — so the one thing the contract had to publish is the one
thing it did not.
WHAT CHANGES
Only the published text. No behaviour moves: the reclassification, its narrow
message match and its bounds are unchanged.
a usage error and exits
2" plus three Detail bullets.why a 429 put them there.
BYTES rather than hand-edited.
🔴 THE PRE-SPLIT CLAUSE IS KEPT VERBATIM. A first draft rewrote row 6's summary
to "a genuine throttle from the API (HTTP 429)" and
TestEveryPreSplitClauseSurvives caught it: that guard pins pre-split contract
text word for word, so the addition has to arrive BESIDE the old clause, not
instead of it. The guard was right and the draft was wrong.
PINNED, SO IT CANNOT BE SILENTLY DELETED AGAIN
exitcodes_doc_test.go's guards are all AGREEMENT guards — they stay green if a
Note is deleted, because deleting it moves both surfaces together. That is how
this drift survived. So the decision is added to contractClaims, the ledger of
things the published contract must SAY, with pinnedBy naming the behavioural
guard (pkg/civitai's TestDeepPagingCapClassifiesOnTheWireMessageNotTheStrippedOne).
MUTATION MATRIX
delete row 6's Detail TestPublishedExitCodeClaims fails, naming
"structurally doomed" / "Branch on the exit code"
delete the reclassification pkg/civitai reddens — the behaviour is pinned
independently of the prose
ledger self-check TestExitCodeClaimsLedgerIsNotVacuous passes
⚠ Two of my own instruments were wrong here and are worth recording. A first
mutation run reported the claim SURVIVING — it had not, my
-run 'Contract'filter matched no test at all. And an awk extraction of the guard's "paste this"
output captured the failure message's
wantANDgothalves, leaking theliteral string "got:" into README.md; regenerating from readmeExitCodeSections()
directly is what fixed it. Read the runner's own output, not a filter's silence.
Gate: make ci green (21 packages ok); make lint 0 issues.
Refs #542.