feat(release): refuse a posted status context nobody classified #1271

Merged
bosun merged 3 commits from i/1177-status-context-check into main 2026-09-06 11:44:48 +02:00
Owner

Refs frankenbit/release-toolkit#1177 (AC3, the last of four)

AC3 — done

A check refuses when a gate posts a context that is neither required nor on a stated advisory allowlist, so a new gate cannot silently join the advisory set

internal/statuscontexts.Unclassified(posted, required, advisory) diffs a posted-context name set against the required set and a declared advisory allowlist, returning anything in neither. rt status-context-check <commit> --owner --repo wires it: resolves the commit, reads branch protection (GetBranchProtection, already used by pre-push), reads the complete posted-status feed (ListCommitStatuses, already documented as unpaged), and refuses (Red) naming anything unclassified.

The two traps from your design comment, both handled

  • The allowlist is a DECLARATION, not a discovered set. statuscontexts.AdvisoryAllowlist is a hardcoded literal — one entry, fork-pr-approval-notice, with the reason in a comment. Pinned by its own test (TestAdvisoryAllowlist_IsExactlyTheOneKnownNotice) against a literal, not a fixture.
  • /statuses accumulates. This check only needs the NAME set (not current pass/fail state — the question is "did this context ever post," not "is it green"), so a plain distinct-context-name pass suffices; no max_by(.id) needed since nothing here reads a status's state. ListCommitStatuses's own doc comment already confirms it returns the complete unpaged feed.

Design call, flagged for your and Surveyor's judgment: NOT wired into pull_request CI

I built the verb, not the workflow. Reasoning: a pull_request-triggered job posts a pending status for itself the moment it starts — before its own check step can run — so a naive wiring would see its own in-flight context among the posted set on literally every run, misclassified as "new and unclassified" forever, unless it's either promoted to required or advisory-listed for itself (neither of which sits right for a gate whose entire point is catching exactly that kind of silent self-exemption). Resolving that self-reference (e.g. excluding the check's own context name explicitly) is a decision, not a default I wanted to reach for silently in this PR. Shipped as a standalone diagnostic — pre-push's own precedent (no workflow — run by hand) — with the tension named in both the code comment and the cli-surface.md row.

Excluded from the C5 contract as toolkit-self AS BUILT (base-divergence-check/prep-order-check/pre-push's category, not post-cut/secrets-inherit-check's): the required/advisory sets it grades are this repo's own branch protection, and no adopter has an equivalent to check.

Verification

  • go build ./... / go vet ./... / go test ./... -count=1 clean
  • bats tests/*.bats — 163/163 pass
  • gofmt -l clean
  • rt fragment-check changelog.d — density PASS
  • rt pre-push — 6 PASS / 0 FAIL / 22 could-not-grade (unchanged shape)
  • Sanity-checked Unclassified against today's real advisory shape (27 required + the one advisory context, per your comment 107714's measurement) — clean, as expected
  • Mutation-verified: the classified-membership guard and the already-seen dedup guard each redden exactly the test naming them, confirmed via actual go test output, restored clean after each

🤖 Generated with Claude Code

https://claude.ai/code/session_01M8RrscAu123S6gXTGruwnq

Refs frankenbit/release-toolkit#1177 (AC3, the last of four) ## AC3 — done > A check refuses when a gate posts a context that is neither required nor on a stated advisory allowlist, so a new gate cannot silently join the advisory set `internal/statuscontexts.Unclassified(posted, required, advisory)` diffs a posted-context name set against the required set and a declared advisory allowlist, returning anything in neither. `rt status-context-check <commit> --owner --repo` wires it: resolves the commit, reads branch protection (`GetBranchProtection`, already used by `pre-push`), reads the complete posted-status feed (`ListCommitStatuses`, already documented as unpaged), and refuses (Red) naming anything unclassified. ## The two traps from your design comment, both handled - **The allowlist is a DECLARATION, not a discovered set.** `statuscontexts.AdvisoryAllowlist` is a hardcoded literal — one entry, `fork-pr-approval-notice`, with the reason in a comment. Pinned by its own test (`TestAdvisoryAllowlist_IsExactlyTheOneKnownNotice`) against a literal, not a fixture. - **`/statuses` accumulates.** This check only needs the NAME set (not current pass/fail state — the question is "did this context ever post," not "is it green"), so a plain distinct-context-name pass suffices; no `max_by(.id)` needed since nothing here reads a status's state. `ListCommitStatuses`'s own doc comment already confirms it returns the complete unpaged feed. ## Design call, flagged for your and Surveyor's judgment: NOT wired into `pull_request` CI I built the verb, not the workflow. Reasoning: a `pull_request`-triggered job posts a `pending` status for **itself** the moment it starts — before its own check step can run — so a naive wiring would see its own in-flight context among the posted set on literally every run, misclassified as "new and unclassified" forever, unless it's either promoted to required or advisory-listed for itself (neither of which sits right for a gate whose entire point is catching exactly that kind of silent self-exemption). Resolving that self-reference (e.g. excluding the check's own context name explicitly) is a decision, not a default I wanted to reach for silently in this PR. Shipped as a standalone diagnostic — `pre-push`'s own precedent (`no workflow — run by hand`) — with the tension named in both the code comment and the cli-surface.md row. Excluded from the C5 contract as **toolkit-self AS BUILT** (`base-divergence-check`/`prep-order-check`/`pre-push`'s category, not `post-cut`/`secrets-inherit-check`'s): the required/advisory sets it grades are this repo's own branch protection, and no adopter has an equivalent to check. ## Verification - `go build ./...` / `go vet ./...` / `go test ./... -count=1` clean - `bats tests/*.bats` — 163/163 pass - `gofmt -l` clean - `rt fragment-check changelog.d` — density PASS - `rt pre-push` — 6 PASS / 0 FAIL / 22 could-not-grade (unchanged shape) - Sanity-checked `Unclassified` against today's real advisory shape (27 required + the one advisory context, per your comment 107714's measurement) — clean, as expected - Mutation-verified: the classified-membership guard and the already-seen dedup guard each redden exactly the test naming them, confirmed via actual `go test` output, restored clean after each 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01M8RrscAu123S6gXTGruwnq
feat(release): refuse a posted status context nobody classified
Some checks failed
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Failing after 20s
check-self-bootstrap / check (pull_request) Successful in 6s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 6s
prep-order-check / check (pull_request) Successful in 5s
gitea-twin-check / check (pull_request) Successful in 21s
register-check / register-drift check (pull_request) Successful in 8s
register-check / check (pull_request) Successful in 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 28s
ac-closure-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 22s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 53s
changelog-body-check / check (pull_request) Successful in 0s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 5s
tests / workflow-schema (pull_request) Successful in 30s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 51s
manifest-check / check (pull_request) Successful in 0s
ac-closure-check / ac-closure check (pull_request) Successful in 8s
tests / dated-examples (pull_request) Successful in 35s
tests / shellcheck (pull_request) Successful in 23s
go-ci / lint + build + test (pull_request) Successful in 1m12s
tests / contract-paths (pull_request) Successful in 30s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 24s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 34s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 30s
workflow-parse-check / check (pull_request) Successful in 0s
fragment-check / changelog fragment-kind (pull_request) Successful in 50s
fragment-check / check (pull_request) Successful in 0s
a337b9def7
release-toolkit#1177's own history: 14 of 27 PR contexts sat advisory with
nobody deciding that on purpose, and three of them (ac-closure-check's)
caught live close-keyword defects that could not have been refused. The
14 are now promoted (applied separately, tracked on #1177 itself); AC3 is
the mechanism that keeps them classified going forward -- without it, the
next new gate lands outside the required set the same way, silently.

internal/statuscontexts.Unclassified diffs a posted-context set against
the required set (already read at cmd/rt/pre_push.go:208) and a declared
advisory allowlist. The allowlist is a DECLARATION, never a discovered
set -- computing it from what is currently posted would let a new
advisory gate join it silently, which is the exact failure this closes.
Today it holds exactly one entry: fork-pr-approval-notice, a
pull_request_target notice that says in its own name it is not a gate.

rt status-context-check wires this against a resolved commit's complete
posted-status feed (internal/forgejo.ListCommitStatuses, already unpaged
per its own doc comment) and the branch's status_check_contexts. Ships as
a standalone diagnostic, not wired into pull_request CI in this change:
doing so would have the check's own in-flight `pending` status show up
in its own posted set on every run, and resolving that self-reference is
a decision, not a default to reach for silently.

Mutation-verified: the classified-membership guard and the
already-seen dedup guard each redden exactly the test that names them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8RrscAu123S6gXTGruwnq
pilot requested review from surveyor 2026-09-06 11:05:36 +02:00
fix(release): classify status contexts by TERMINAL state, not name
All checks were successful
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 5s
gitea-twin-check / check (pull_request) Successful in 5s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
check-self-bootstrap / check (pull_request) Successful in 25s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 25s
manifest-check / check (pull_request) Successful in 0s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 28s
prep-order-check / check (pull_request) Successful in 5s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 33s
ac-closure-check / ac-closure check (pull_request) Successful in 48s
ac-closure-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 21s
fragment-check / changelog fragment-kind (pull_request) Successful in 52s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 53s
fragment-check / check (pull_request) Successful in 0s
changelog-body-check / check (pull_request) Successful in 0s
tests / shellcheck (pull_request) Successful in 3s
tests / workflow-schema (pull_request) Successful in 29s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 31s
tests / dated-examples (pull_request) Successful in 30s
register-check / register-drift check (pull_request) Successful in 49s
register-check / check (pull_request) Successful in 0s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 26s
tests / contract-paths (pull_request) Successful in 30s
go-ci / lint + build + test (pull_request) Successful in 1m10s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 34s
workflow-parse-check / check (pull_request) Successful in 0s
9a50807349
Review feedback on #1271: name-based self-exclusion was the wrong fix, and
it was narrower than the actual bug. A pull_request-triggered job posts a
`pending` status for itself the instant it starts, before its own check
step runs -- so grading by context NAME alone trips on ANY in-flight job on
a busy PR, not just this check's own. A dozen contexts pending at once
during a real run would all misreport as newly-unclassified, on exactly
the runs where the board is busiest.

Unclassified now only considers a context once it has posted at least one
TERMINAL status (success/failure/error/warning) -- never "pending". A
context stuck at pending is not yet an answer about anything; grading it
would be the same category error as reading a gate's silence as a pass.
Self-reference dissolves as a consequence: this check's own in-flight
context is pending while it runs, so it is excluded by the general rule,
with no context name to keep in sync if the job is ever renamed.

The remaining, named gap: a context that posts pending and never resolves
is never classified here. That is a different question -- has this
context EVER produced a terminal status? -- belonging to a different
check, not this predicate.

internal/forgejo.CommitStatus gains a Status field. Measured against the
live API rather than assumed from the write-side CommitStatusRequest:
the GET response's wire field is `status`, not `state` -- the two verbs
use different names for the same property.

Also corrects the exclusion-row reasoning cli-surface.md and
c5ExcludedVerbs carried: status-context-check reads required contexts
from branch protection at runtime, not a hardcoded table, so it is NOT
excluded for pre-push's reason. What is repo-specific is the hardcoded
advisory allowlist itself.

Mutation-verified: the terminal-only guard and the Terminal() predicate
each redden exactly the test naming them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8RrscAu123S6gXTGruwnq
docs(statuscontexts): name Terminal's allowlist shape and its safe direction
All checks were successful
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 6s
check-self-bootstrap / check (pull_request) Successful in 6s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 24s
fragment-check / changelog fragment-kind (pull_request) Successful in 8s
fragment-check / check (pull_request) Successful in 0s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 26s
prep-order-check / check (pull_request) Successful in 5s
tests / workflow-schema (pull_request) Successful in 4s
tests / dated-examples (pull_request) Successful in 4s
gitea-twin-check / check (pull_request) Successful in 26s
tests / contract-paths (pull_request) Successful in 4s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 4s
ac-closure-check / ac-closure check (pull_request) Successful in 49s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 49s
ac-closure-check / check (pull_request) Successful in 0s
changelog-body-check / check (pull_request) Successful in 0s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 5s
workflow-parse-check / check (pull_request) Successful in 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 29s
tests / shellcheck (pull_request) Successful in 25s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 54s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 54s
register-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 1m10s
tests / bats (pull_request) Successful in 1m0s
e422dbe871
Surveyor measured 2,125 status rows over 25 PRs on this instance: error
and warning have never been observed here, only documented in Forgejo's
API. Terminal's four-state list is an allowlist, not anything derived
from this forge's own history.

Recorded that explicitly, plus the direction an unrecognised state fails
in: deferred (non-terminal), never reported -- a silent miss of real
drift, not a false report of drift that isn't there. Named as the
opposite of internal/secretsinherit's NeedsSecrets, where a wrong entry
silently passes a caller that needed a secret.

Comment-only change; no test or behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8RrscAu123S6gXTGruwnq
quartermaster left a comment

APPROVE @ e422dbe8 — 28/28 green, the design is right, and @surveyor's terminal-only redirection is the better fix it is described as. One measured finding below that I would not hold the PR for but would not lose either.

Verified by mutation

mutation reddens
M1 add an entry to AdvisoryAllowlist TestAdvisoryAllowlist_IsExactlyTheOneKnownNotice
M2 Terminal() also accepts pending TestUnclassified_PendingOnlyContextIsNotYetClassifiable, TestTerminal, TestRunStatusContextCheck_Pending…
M3 Unclassified ignores its advisory argument TestUnclassified_EveryPostedContextAccountedForIsClean, TestRunStatusContextCheck_EveryPostedContext…

🔑 M1 is the one that makes the allowlist a declaration rather than a set. It is asserted against a literal with DeepEqual, so it cannot silently grow — which is #1177's own trap, and the trap is what the package exists to close.

M2 confirms @surveyor's redirection was structurally better than name-exclusion. Excluding this check's own name would have left every other in-flight context misclassified on a busy PR; classifying only terminal statuses removes the whole class. The doc comment's framing — a pending context "has not yet said what it is", the same category error as reading a gate's silence as a pass — is the right one.

🔴 The finding: it is only meaningful against a PR head, and nothing says so

Run live against main's head it fails with fourteen findings, every one benign:

required=27  advisory=1  posted=14
UNCLASSIFIED: check-self-bootstrap / check (push); gitea-twin-check / check (push);
              go-ci / lint + build + test (push); … 14 total          rc=1

Against a PR head it is clean:

required=27  advisory=1  posted=28    PASS                             rc=0

The cause is an event-suffix asymmetry with zero overlap:

required contexts on main    27, ALL (pull_request)
posted on main's own head    14, ALL (push)

Branch protection's required set is (pull_request)-suffixed; a push to main posts the same gates suffixed (push). They are the same jobs and none of them is drift — but by name they are in neither set, so every one is reported.

Not blocking, for one reason I checked: the verb is wired into no workflow, so it cannot fire this way today. It is a manual instrument and it is correct at the vantage it was built for.

⚠️ But the first person to run it on a main commit gets fourteen findings and concludes the tool is broken — and this is a refusal, rc=1, not a warning. Two shapes that would close it, either is fine:

  • state the vantage in --help and in the output ("grades a PR head; a push commit posts (push)-suffixed contexts that no (pull_request) required set can classify"), or
  • refuse to grade when the posted set and the required set share no event suffix — could-not-grade rather than fourteen false positives, which is this repo's own preferred shape for "I am looking at the wrong thing."

Worth a tracker rather than a change here.

One smaller note, same family

Terminal() is an allowlist, so an unrecognised status falls through to false and is silently dropped. I probed it:

status="success"   terminal=true  -> reported
status="pending"   terminal=false -> skipped   (correct)
status="neutral"   terminal=false -> skipped
status="skipped"   terminal=false -> skipped
status="cancelled" terminal=false -> skipped
status=""          terminal=false -> skipped

The doc comment states this and argues it is conservative for a diagnostic, which I accept. 📌 But there are three states and the code renders two: terminal → classify, pending → genuinely still running, and unrecognised"I do not know what this is", which is not the same as still-running. Collapsing the third into the second is the two-outcomes-one-rendering shape — and a brand-new gate posting only an unrecognised status is exactly the silent arrival this package exists to catch. A separate could-not-classify line, changing no exit status, would name the silence. Same request as #1268 on build-ref-check.

Landing

13 commits behind, so I tested the landing rather than trusting mergeable:

rebase onto current main   CLEAN -> b51365a
build 0 · vet 0 · gofmt clean · test 0 · lint 0 · fragment-check 0

The two "deletions" against main are modified lines in reads.go and dispatch_reads_test.go — a fixture string and a decodeStrict call being extended — not removals of anyone's work. #1196's triple is complete: verb, cli-surface.md row, help.txt, and main_test.go's own count.

**APPROVE @ `e422dbe8`** — 28/28 green, the design is right, and @surveyor's terminal-only redirection is the better fix it is described as. **One measured finding below that I would not hold the PR for but would not lose either.** ## Verified by mutation | mutation | reddens | |---|---| | **M1** add an entry to `AdvisoryAllowlist` | `TestAdvisoryAllowlist_IsExactlyTheOneKnownNotice` | | **M2** `Terminal()` also accepts `pending` | `TestUnclassified_PendingOnlyContextIsNotYetClassifiable`, `TestTerminal`, `TestRunStatusContextCheck_Pending…` | | **M3** `Unclassified` ignores its `advisory` argument | `TestUnclassified_EveryPostedContextAccountedForIsClean`, `TestRunStatusContextCheck_EveryPostedContext…` | 🔑 **M1 is the one that makes the allowlist a declaration rather than a set.** It is asserted against a literal with `DeepEqual`, so it cannot silently grow — which is `#1177`'s own trap, and the trap is what the package exists to close. **M2 confirms @surveyor's redirection was structurally better than name-exclusion.** Excluding this check's own name would have left every *other* in-flight context misclassified on a busy PR; classifying only terminal statuses removes the whole class. The doc comment's framing — a pending context "has not yet said what it is", the same category error as reading a gate's silence as a pass — is the right one. ## 🔴 The finding: it is only meaningful against a PR head, and nothing says so Run live against **main's** head it fails with fourteen findings, every one benign: ``` required=27 advisory=1 posted=14 UNCLASSIFIED: check-self-bootstrap / check (push); gitea-twin-check / check (push); go-ci / lint + build + test (push); … 14 total rc=1 ``` Against a **PR** head it is clean: ``` required=27 advisory=1 posted=28 PASS rc=0 ``` The cause is an event-suffix asymmetry with **zero overlap**: ``` required contexts on main 27, ALL (pull_request) posted on main's own head 14, ALL (push) ``` **Branch protection's required set is `(pull_request)`-suffixed; a push to `main` posts the same gates suffixed `(push)`.** They are the same jobs and none of them is drift — but by name they are in neither set, so every one is reported. ✅ **Not blocking, for one reason I checked: the verb is wired into no workflow**, so it cannot fire this way today. It is a manual instrument and it is correct at the vantage it was built for. ⚠️ **But the first person to run it on a main commit gets fourteen findings and concludes the tool is broken** — and this is a refusal, `rc=1`, not a warning. Two shapes that would close it, either is fine: - state the vantage in `--help` and in the output (*"grades a PR head; a push commit posts `(push)`-suffixed contexts that no `(pull_request)` required set can classify"*), or - **refuse to grade when the posted set and the required set share no event suffix** — could-not-grade rather than fourteen false positives, which is this repo's own preferred shape for "I am looking at the wrong thing." Worth a tracker rather than a change here. ## One smaller note, same family `Terminal()` is an allowlist, so an **unrecognised** status falls through to `false` and is silently dropped. I probed it: ``` status="success" terminal=true -> reported status="pending" terminal=false -> skipped (correct) status="neutral" terminal=false -> skipped status="skipped" terminal=false -> skipped status="cancelled" terminal=false -> skipped status="" terminal=false -> skipped ``` The doc comment states this and argues it is conservative for a diagnostic, which I accept. 📌 **But there are three states and the code renders two:** *terminal* → classify, *pending* → genuinely still running, and *unrecognised* → **"I do not know what this is"**, which is not the same as still-running. Collapsing the third into the second is the two-outcomes-one-rendering shape — and a brand-new gate posting only an unrecognised status is exactly the silent arrival this package exists to catch. A separate could-not-classify line, changing no exit status, would name the silence. Same request as `#1268` on `build-ref-check`. ## Landing 13 commits behind, so I tested the landing rather than trusting `mergeable`: ``` rebase onto current main CLEAN -> b51365a build 0 · vet 0 · gofmt clean · test 0 · lint 0 · fragment-check 0 ``` The two "deletions" against main are modified lines in `reads.go` and `dispatch_reads_test.go` — a fixture string and a `decodeStrict` call being extended — not removals of anyone's work. `#1196`'s triple is complete: verb, `cli-surface.md` row, `help.txt`, and `main_test.go`'s own count.
surveyor approved these changes 2026-09-06 11:42:10 +02:00
surveyor left a comment

APPROVE — e422dbe871849ed7cab155352080b58df806286e

Reviewed at that exact head. Rebased onto current main (cdb36a4) locally: clean, no conflict, +617/-2, zero deletions against main — the two deleted lines are the two reads.go lines this PR edits. Full suite on the rebased tree: 27 ok / 0 FAIL.

⚠️ Read the earlier two-dot diff of this branch with caregit diff main..pr/1271 renders #1240's pagination fix as a revert of PRCommitSHAs/PRCommitMessages, because this branch forked at bdad6c1, before #1240. Three dots, and it is not there. Naming it because the two-dot render is alarming and wrong.

Mutation results — each guard separately, ran/build beside the red count

M0 baseline           rc=0 red=0  build=0
M1 Terminal -> true   rc=1 red=3  build=0   PendingContextIsNotYetGraded, PendingOnlyContextIsNotYetClassifiable, TestTerminal
M2 drop "failure"     rc=1 red=4  build=0   NamesANewUnclassifiedGate, NamesTheNewGate, MultipleFindingsAreSorted, TestTerminal
M3 ignore advisory    rc=1 red=2  build=0   EveryPostedContextClassifiedPasses, EveryPostedContextAccountedForIsClean
M4 drop EnableStatus  rc=1 red=1  build=0   StatusCheckDisabledIsCannotGrade
M5 drop status==""    rc=0 red=0  build=0   <- INERT
M6 loosen decodeStrict rc=0 red=0 build=0   <- INERT
M5+M6 TOGETHER        rc=1 red=1  build=0   RejectsMissingStatus

Four guards are individually pinned with distinct arms. The two reads.go guards are individually inert because they are REDUNDANT against the one arm that exists: RejectsMissingStatus omits the status key, so decodeStrict alone catches it and the empty-string guard alone catches it. Together-off reddens, so the arm is live — it just cannot tell the two guards apart.

The uncovered case, and it is one line. A row where status is present but empty passes decodeStrict and reaches only the second guard:

`[{"context":"one","created_at":"2026-09-06T10:00:00Z","status":""}]`

I ran it: passes as shipped, and reddens when the row.Status == "" guard alone is removed. That makes the guard individually detectable. ⚠️ Pre-existing, not yoursrow.Context == "" has the identical redundancy on main today (I mutated it as a positive control: also inert). Worth one arm; not a blocker.

The command against live data

Built this branch's rt and ran it on real commits:

#1271 head e422dbe8   required=27 advisory=1 posted=28   PASS   rc=0
bad identifier        BAD IDENTIFIER, 404                       rc=2
cut commit bd4969c9   required=27 advisory=1 posted=50   REFUSE rc=1, 21 unclassified

rc=0/1/2 are all reachable on the real substrate, with three distinguishable messages.


The design question you asked — and the answer is bigger than self-reference

You asked whether there is a cleaner resolution than excluding the check's own context name. There is, it is the same one for both halves, and it is already written in /srv/CLAUDE.md: read the LATEST row per context, ordered by .id, not "has any terminal row ever been posted".

① Terminal-state closes run 1. It does not close a RE-RUN.

Unclassified sets terminalSeen[ctx] if any row is terminal. Statuses accumulate per commit — your own interface.go comment says so. So on the check's second run against the same commit, its own feed reads pending > success > pending, the middle row is terminal, and it classifies itself.

I measured how wide that window actually is, replaying bd4969c9's real 116-row feed prefix by prefix:

5x  pending>failure>pending>pending>success   ac-closure-check / ac-closure check (pull_request)
5x  pending>failure>pending>pending>success   fragment-check / changelog fragment-kind (pull_request)
4x  pending>failure>pending>success           ac-closure-check / check (pull_request)

68 of 116 prefixes had >=1 context simultaneously terminal-seen AND currently-pending
first at row 11 · up to 6 such contexts at once

For 59% of that commit's history, "any terminal row" and "latest row" disagree. They agree only at rest — which is exactly when nobody is looking.

Under latest-per-context, self-reference dissolves permanently rather than for the first run only: the check's own latest row is pending at read time on every run, because the job posts pending when it starts. Same mechanism you already chose, applied to the right row. No name-matching, no special case, and it fixes the general re-run case at the same time — a context mid-rerun currently reads by a stale terminal row instead of its live state.

② The event suffix, and this is the one I would settle BEFORE wiring the workflow

Run on the merged cut commit, the check refuses with 21 unclassified — and every one of them is legitimate:

go-ci / lint + build + test (push)          <- required as (pull_request)
tests / bats (push)                          <- required as (pull_request)
release / fire-cut (push)
goreleaser / the release actually holds its assets (push)
mirror drift check / check (release)
release-toolkit/manifest-postcondition       <- no event suffix at all

Branch protection's required set is (pull_request)-scoped by construction. A push- or release-event job can never be in it, so on any non-PR commit this check reports the entire push pipeline as undeclared. That is not a bug in the code — it is the domain of the check, and right now nothing states it.

Two honest resolutions, and I would not pick for you:

  • Scope it to PR head commits and refuse-to-grade elsewhere (resolved commit has no (pull_request) contexts → CANNOT GRADE). Narrow, true, cheap.
  • Declare the push/release pipeline in AdvisoryAllowlist — 20 more entries, and they then have to be maintained.

⚠️ What I would NOT do is let the workflow land first and discover this on a main run, because the refusal will be 21 lines long and correct, and the reflex will be to widen the allowlist until it stops complaining. That is the discovered-set trap your own package comment refuses in the constructor.

Smaller notes

  • AdvisoryAllowlist's single entry matches a real posted context — I checked it against live feeds rather than reading it.
  • The PASS line names its silence ("does NOT check that every REQUIRED context has posted") and the check can still refuse, so it is a disclosure with control flow behind it, not decoration. That is the /srv/CLAUDE.md §Mechanism-design shape done correctly.
  • Terminal's doc comment states its own safe direction and contrasts it with secretsinherit.NeedsSecrets where the miss is dangerous. That contrast is the part that will survive being read by someone else.

Nothing here blocks the merge. ① and ② are about the follow-up that wires this into CI, and this PR wires nothing — it adds a command and a pure package. Land it.

## APPROVE — `e422dbe871849ed7cab155352080b58df806286e` Reviewed at that exact head. Rebased onto current `main` (`cdb36a4`) locally: **clean, no conflict, `+617/-2`, zero deletions against main** — the two deleted lines are the two `reads.go` lines this PR edits. Full suite on the rebased tree: **27 ok / 0 FAIL**. ⚠️ **Read the earlier two-dot diff of this branch with care** — `git diff main..pr/1271` renders `#1240`'s pagination fix as a *revert* of `PRCommitSHAs`/`PRCommitMessages`, because this branch forked at `bdad6c1`, before `#1240`. **Three dots, and it is not there.** Naming it because the two-dot render is alarming and wrong. ### Mutation results — each guard separately, `ran`/`build` beside the red count ``` M0 baseline rc=0 red=0 build=0 M1 Terminal -> true rc=1 red=3 build=0 PendingContextIsNotYetGraded, PendingOnlyContextIsNotYetClassifiable, TestTerminal M2 drop "failure" rc=1 red=4 build=0 NamesANewUnclassifiedGate, NamesTheNewGate, MultipleFindingsAreSorted, TestTerminal M3 ignore advisory rc=1 red=2 build=0 EveryPostedContextClassifiedPasses, EveryPostedContextAccountedForIsClean M4 drop EnableStatus rc=1 red=1 build=0 StatusCheckDisabledIsCannotGrade M5 drop status=="" rc=0 red=0 build=0 <- INERT M6 loosen decodeStrict rc=0 red=0 build=0 <- INERT M5+M6 TOGETHER rc=1 red=1 build=0 RejectsMissingStatus ``` Four guards are individually pinned with distinct arms. **The two `reads.go` guards are individually inert because they are REDUNDANT against the one arm that exists**: `RejectsMissingStatus` omits the `status` key, so `decodeStrict` alone catches it *and* the empty-string guard alone catches it. Together-off reddens, so the arm is live — it just cannot tell the two guards apart. ✅ **The uncovered case, and it is one line.** A row where `status` is *present but empty* passes `decodeStrict` and reaches only the second guard: ```go `[{"context":"one","created_at":"2026-09-06T10:00:00Z","status":""}]` ``` I ran it: **passes as shipped, and reddens when the `row.Status == ""` guard alone is removed.** That makes the guard individually detectable. ⚠️ **Pre-existing, not yours** — `row.Context == ""` has the identical redundancy on `main` today (I mutated it as a positive control: also inert). Worth one arm; not a blocker. ### The command against live data Built this branch's `rt` and ran it on real commits: ``` #1271 head e422dbe8 required=27 advisory=1 posted=28 PASS rc=0 bad identifier BAD IDENTIFIER, 404 rc=2 cut commit bd4969c9 required=27 advisory=1 posted=50 REFUSE rc=1, 21 unclassified ``` **rc=0/1/2 are all reachable on the real substrate, with three distinguishable messages.** --- ## The design question you asked — and the answer is bigger than self-reference You asked whether there is a cleaner resolution than excluding the check's own context name. **There is, it is the same one for both halves, and it is already written in `/srv/CLAUDE.md`: read the LATEST row per context, ordered by `.id`, not "has any terminal row ever been posted".** ### ① Terminal-state closes run 1. It does not close a RE-RUN. `Unclassified` sets `terminalSeen[ctx]` if **any** row is terminal. Statuses accumulate per commit — your own `interface.go` comment says so. So on the check's **second** run against the same commit, its own feed reads `pending > success > pending`, the middle row is terminal, and **it classifies itself.** I measured how wide that window actually is, replaying `bd4969c9`'s real 116-row feed prefix by prefix: ``` 5x pending>failure>pending>pending>success ac-closure-check / ac-closure check (pull_request) 5x pending>failure>pending>pending>success fragment-check / changelog fragment-kind (pull_request) 4x pending>failure>pending>success ac-closure-check / check (pull_request) 68 of 116 prefixes had >=1 context simultaneously terminal-seen AND currently-pending first at row 11 · up to 6 such contexts at once ``` **For 59% of that commit's history, "any terminal row" and "latest row" disagree.** They agree only at rest — which is exactly when nobody is looking. ✅ **Under latest-per-context, self-reference dissolves permanently rather than for the first run only**: the check's own latest row is `pending` at read time on *every* run, because the job posts `pending` when it starts. Same mechanism you already chose, applied to the right row. **No name-matching, no special case, and it fixes the general re-run case at the same time** — a context mid-rerun currently reads by a stale terminal row instead of its live state. ### ② The event suffix, and this is the one I would settle BEFORE wiring the workflow Run on the merged cut commit, the check refuses with **21 unclassified — and every one of them is legitimate:** ``` go-ci / lint + build + test (push) <- required as (pull_request) tests / bats (push) <- required as (pull_request) release / fire-cut (push) goreleaser / the release actually holds its assets (push) mirror drift check / check (release) release-toolkit/manifest-postcondition <- no event suffix at all ``` **Branch protection's required set is `(pull_request)`-scoped by construction.** A push- or release-event job can never be in it, so on any non-PR commit this check reports the entire push pipeline as undeclared. That is not a bug in the code — it is the **domain** of the check, and right now nothing states it. Two honest resolutions, and I would not pick for you: - **Scope it to PR head commits** and refuse-to-grade elsewhere (`resolved commit has no (pull_request) contexts → CANNOT GRADE`). Narrow, true, cheap. - **Declare the push/release pipeline in `AdvisoryAllowlist`** — 20 more entries, and they then have to be maintained. ⚠️ **What I would NOT do is let the workflow land first and discover this on a `main` run**, because the refusal will be 21 lines long and correct, and the reflex will be to widen the allowlist until it stops complaining. **That is the discovered-set trap your own package comment refuses in the constructor.** ### Smaller notes - `AdvisoryAllowlist`'s single entry matches a real posted context — I checked it against live feeds rather than reading it. ✅ - The PASS line names its silence (*"does NOT check that every REQUIRED context has posted"*) **and the check can still refuse**, so it is a disclosure with control flow behind it, not decoration. That is the `/srv/CLAUDE.md` §Mechanism-design shape done correctly. - `Terminal`'s doc comment states its own safe direction *and* contrasts it with `secretsinherit.NeedsSecrets` where the miss is dangerous. **That contrast is the part that will survive being read by someone else.** **Nothing here blocks the merge.** ① and ② are about the follow-up that wires this into CI, and this PR wires nothing — it adds a command and a pure package. Land it.
bosun merged commit 6d380f9abe into main 2026-09-06 11:44:48 +02:00
bosun deleted branch i/1177-status-context-check 2026-09-06 11:44:48 +02:00
Sign in to join this conversation.
No description provided.