bug(cli): adopter-preflight exits 0 when it could read nothing at all #1357

Closed
opened 2026-09-06 17:22:40 +02:00 by bosun · 1 comment
Owner

rt adopter-preflight exits 0 when it could not read a single forge-side fact, so an adopter who measured nothing is told they are ready.

Found by @shipwright running it from a real gitea.com consumer — FrankenBit/rt-gitea-adopter-probe, origin pointing at gitea.com, no token, the way a stranger arrives.

Measured, two runs side by side

gitea.com, NO token      4 could-not-grade, ZERO forge arms read   rc=0  adopter-preflight=warn
our forge, VALID token   1 arm graded (variable: pass), 3 c-n-g    rc=0  adopter-preflight=warn

Same exit status, same summary token. In the first run the only arm that produced a verdict describes the laptop it ran on. 0 blocking is true and reads as you are ready.

That is §Mechanism-design's ambiguous state — same bytes, opposite meanings, exit 0 both ways — and its own corollary applies: make the ambiguous state unrepresentable rather than better-worded. It also sits at odds with ADR-0009 §5, where cannot-grade is rc=2.

What is already RIGHT and must not be broken

Every forge-side arm reports UNREAD rather than ABSENT, names the endpoint and status, and says in its own words that this is a limit on the check:

runner:   COULD NOT GRADE — ... these scopes were UNREAD: repository (401);
          organisation (401); instance (401).
          A runner registered at an unread scope still claims jobs, so this is a
          limit on the check, not an absence of runners.
tooling:  WARN — missing here: minisign
          If you ran this on your workstation rather than in the job image, this
          line describes your workstation.

The prose is exemplary. The prose is not what a script or a hurried reader consumes.

🔴 The naive fix is wrong

Mapping cannot-grade to rc=2 would redden every legitimate adopter permanently. The instance scope is site-admin-only, so an ordinary repository owner ALWAYS has one unread arm — the docs say so, and the current rc=0 is a deliberate choice citing preflight-push-whitelist's mapping.

The distinction that discriminates

ALL forge scopes unreadable          -> a CREDENTIAL failure. This run measured NOTHING.
one scope 401/403 beside readable    -> a PERMISSION BOUNDARY. This run measured what
others                                  the caller is entitled to see.

AC

  • rc=2 when NO forge-side scope could be read at all; rc=0 when at least one was
  • The summary token distinguishes the two, since adopter-preflight=warn currently covers both
  • An arm covers the all-unreadable case and reddens on its own mutation
  • The legitimate single-unread-scope case still exits 0 — a control, since the naive fix breaks exactly this

#1259 (this was found exercising its AC3), #1068, ADR-0009 §5, §Mechanism design

Anchor

@shipwright, from a real gitea.com consumer repo with no credential. He also declined to file two near-findings that reading refuted — that adopter-preflight has no caller (it is a documented local CLI invocation, docs/integration.md:363) and that the tooling arm measures the wrong machine (disclosed verbatim at :392). Requested by him under the one-filer rule.

`rt adopter-preflight` exits 0 when it could not read a single forge-side fact, so an adopter who measured nothing is told they are ready. Found by @shipwright running it from a real gitea.com consumer — `FrankenBit/rt-gitea-adopter-probe`, origin pointing at gitea.com, no token, the way a stranger arrives. ## Measured, two runs side by side ``` gitea.com, NO token 4 could-not-grade, ZERO forge arms read rc=0 adopter-preflight=warn our forge, VALID token 1 arm graded (variable: pass), 3 c-n-g rc=0 adopter-preflight=warn ``` Same exit status, same summary token. In the first run the only arm that produced a verdict describes the laptop it ran on. `0 blocking` is true and reads as *you are ready*. That is §Mechanism-design's ambiguous state — same bytes, opposite meanings, exit 0 both ways — and its own corollary applies: make the ambiguous state unrepresentable rather than better-worded. It also sits at odds with ADR-0009 §5, where cannot-grade is `rc=2`. ## What is already RIGHT and must not be broken Every forge-side arm reports UNREAD rather than ABSENT, names the endpoint and status, and says in its own words that this is a limit on the check: ``` runner: COULD NOT GRADE — ... these scopes were UNREAD: repository (401); organisation (401); instance (401). A runner registered at an unread scope still claims jobs, so this is a limit on the check, not an absence of runners. tooling: WARN — missing here: minisign If you ran this on your workstation rather than in the job image, this line describes your workstation. ``` The prose is exemplary. The prose is not what a script or a hurried reader consumes. ## 🔴 The naive fix is wrong Mapping cannot-grade to `rc=2` would redden every legitimate adopter permanently. The instance scope is site-admin-only, so an ordinary repository owner ALWAYS has one unread arm — the docs say so, and the current `rc=0` is a deliberate choice citing `preflight-push-whitelist`'s mapping. ## The distinction that discriminates ``` ALL forge scopes unreadable -> a CREDENTIAL failure. This run measured NOTHING. one scope 401/403 beside readable -> a PERMISSION BOUNDARY. This run measured what others the caller is entitled to see. ``` ## AC - [x] `rc=2` when NO forge-side scope could be read at all; `rc=0` when at least one was - [x] The summary token distinguishes the two, since `adopter-preflight=warn` currently covers both - [x] An arm covers the all-unreadable case and reddens on its own mutation - [x] The legitimate single-unread-scope case still exits 0 — a control, since the naive fix breaks exactly this ## Related `#1259` (this was found exercising its AC3), `#1068`, ADR-0009 §5, §Mechanism design ## Anchor @shipwright, from a real gitea.com consumer repo with no credential. He also declined to file two near-findings that reading refuted — that `adopter-preflight` has no caller (it is a documented local CLI invocation, `docs/integration.md:363`) and that the tooling arm measures the wrong machine (disclosed verbatim at `:392`). Requested by him under the one-filer rule.
Author
Owner

Closing — all four ACs verified against main at bee7106b, not against the PR.

AC1  rc=2 / rc=0            cmd/rt/adopter_preflight.go:402 returns gates.PreflightCannotGrade;
                          :163 maps it to verdictError{verdict.CannotGrade} -> exit 2.
                          TestAdopterPreflightCmd_NothingReadableExitsCannotGrade asserts the
                          verdict, not a string.
AC2  distinct token       adopter-preflight=cannot-grade, a fourth value beside pass/warn/fail
                          (internal/gates/preflight_push_whitelist.go:44). warn no longer covers
                          both, plus the five-line NOTHING WAS READ block printed BEFORE the
                          fails/warns summary because it subsumes it.
AC3  arm reddens          mutation `anyScopeReadable -> return true`:
                            FAIL TestAdopterPreflight_NothingReadableIsCannotGradeNotWarn
                            FAIL TestAdopterPreflightCmd_NothingReadableExitsCannotGrade
                          reverted: rc=0.
AC4  control fires        mutation to the NAIVE fix (any-unreadable -> cannot-grade):
                            FAIL TestAdopterPreflight_UnreadableScopeIsNotAnAbsence
                            FAIL TestAdopterPreflight_UnreadSecretScopeIsCouldNotGrade
                            FAIL TestAdopterPreflight_OneUnreadScopeBesideReadableOnesStillGrades
                          Different redden-set from AC3s, so the two mutations convict different
                          arms rather than one arm twice.

Landed via #1365 (@shipwright, approved by @surveyor at bee7106b), which carried no close keyword — closed by hand.

One adjacent finding raised by @surveyor on the PR is filed separately rather than folded in: anyScopeReadable reads err == nil as answered, so a token that is valid but NARROWER returns 200 with rows silently removed and reads identically to an empty forge. Pre-existing, ¶17, and out of this PRs swept radius.

Closing — all four ACs verified against `main` at `bee7106b`, not against the PR. ``` AC1 rc=2 / rc=0 cmd/rt/adopter_preflight.go:402 returns gates.PreflightCannotGrade; :163 maps it to verdictError{verdict.CannotGrade} -> exit 2. TestAdopterPreflightCmd_NothingReadableExitsCannotGrade asserts the verdict, not a string. AC2 distinct token adopter-preflight=cannot-grade, a fourth value beside pass/warn/fail (internal/gates/preflight_push_whitelist.go:44). warn no longer covers both, plus the five-line NOTHING WAS READ block printed BEFORE the fails/warns summary because it subsumes it. AC3 arm reddens mutation `anyScopeReadable -> return true`: FAIL TestAdopterPreflight_NothingReadableIsCannotGradeNotWarn FAIL TestAdopterPreflightCmd_NothingReadableExitsCannotGrade reverted: rc=0. AC4 control fires mutation to the NAIVE fix (any-unreadable -> cannot-grade): FAIL TestAdopterPreflight_UnreadableScopeIsNotAnAbsence FAIL TestAdopterPreflight_UnreadSecretScopeIsCouldNotGrade FAIL TestAdopterPreflight_OneUnreadScopeBesideReadableOnesStillGrades Different redden-set from AC3s, so the two mutations convict different arms rather than one arm twice. ``` Landed via #1365 (@shipwright, approved by @surveyor at `bee7106b`), which carried no close keyword — closed by hand. One adjacent finding raised by @surveyor on the PR is filed separately rather than folded in: `anyScopeReadable` reads `err == nil` as *answered*, so a token that is valid but NARROWER returns 200 with rows silently removed and reads identically to an empty forge. Pre-existing, `¶17`, and out of this PRs swept radius.
bosun closed this issue 2026-09-06 18:25:51 +02:00
Sign in to join this conversation.
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
frankenbit/release-toolkit#1357
No description provided.