Declares permissions: on all six workflows so the owner-level Actions default can move to Restricted (server-management#714), and — the part that was actually blocking — proves the declaration binds, which ci.actions-credential-scoping had carried as UNPROBED since 2026-08-05.
review-verdict.yml (job-level) code: write, issues: read, pull-requests: read
the other five (top-level) code: read
Why this sat for three weeks
The change was written 2026-08-06 (7280a91d, subject NOT VERIFIED, do not merge) and blocked on #751, which closed 2026-08-10. The unblocking was noticed 2026-08-14 by a server-management session, which correctly left a cross-reference rather than working in this repo — and no ersatztv session claimed it since. Not abandoned; orphaned by a repo boundary.
It stayed parked for a real reason: review-verdict.yml is base-resolved, so CI-green on its own PR proves nothing, and a wrong unit blocks every merge with no force-merge escape since #743.
The probe
Matched scratch-base pair on Gitea 1.27.1, identical except for one permission unit, the block carried at job level on set-verdict-status — the shipped shape:
job failure at the POST; curl exit 22; no status written
The negative control is the load-bearing half: a green positive alone is equally consistent with permissions: being a no-op beside a permissive default. creator: null also confirms approach A preserves the provenance signal approach B would destroy. Probe branches and PRs (#851, #852) deleted.
What it does not establish: both controls ran under a permissive owner default, so nothing here shows what a declared code: write receives under Restricted. If Gitea caps at the default the way GitHub does, the flip makes the gate unwritable. server-management#714 stays open for that probe, and the issue's Deferred section says so.
This PR originally excluded ci-image.yml — editing it re-pointed ci-image-pin's expected at the editing commit and reddened a blocking job (CI demonstrated this on the first push), and its own paths: made the edit publish an image. #857 (#744/#835) removed the file from both. So ci-image.yml now declares too, and the one-name guard exemption this branch briefly carried was deleted rather than documented — the form #835 asked for.
Corrections to the issue body
docker-build.yml does not write a status with the injected token — no GITEA_TOKEN reference; its ETV_STATUS_AUTH is the scoped REGISTRY_* PAT doing read-only GETs. Only review-verdict.yml needs write. No packages: unit anywhere: all five container: blocks carry explicit credentials:.
Guard
Two mutation-proved tests in scripts/tests/test_pr_changed_files.py, over the git-derived population with no exemptions: the gate job's units by equality (an extra unit is a finding too — this is the repo's only CI repo-write), and every tracked workflow declaring a block. Needed because that workflow's base resolution means the PR removing code: write structurally cannot catch itself.
Verification
scripts/tests/ 1028 passed / 2 skipped; decisions_validate OK; catalog regenerated; ruff clean. Independent cold-context review in an isolated worktree across 10 rounds, re-running the mutation battery itself rather than accepting the claims; final verdict MERGEABLE.
Declares `permissions:` on all six workflows so the owner-level Actions default can move to Restricted (server-management#714), and — the part that was actually blocking — **proves the declaration binds**, which `ci.actions-credential-scoping` had carried as UNPROBED since 2026-08-05.
review-verdict.yml (job-level) code: write, issues: read, pull-requests: read
the other five (top-level) code: read
## Why this sat for three weeks
The change was written 2026-08-06 (`7280a91d`, subject `NOT VERIFIED, do not merge`) and blocked on #751, which closed 2026-08-10. The unblocking was noticed 2026-08-14 by a *server-management* session, which correctly left a cross-reference rather than working in this repo — and no ersatztv session claimed it since. Not abandoned; orphaned by a repo boundary.
It stayed parked for a real reason: `review-verdict.yml` is base-resolved, so CI-green on its own PR proves nothing, and a wrong unit blocks **every** merge with no force-merge escape since #743.
## The probe
Matched scratch-base pair on Gitea 1.27.1, identical except for one permission unit, the block carried **at job level on `set-verdict-status`** — the shipped shape:
| Control | Unit | Result |
|---|---|---|
| Positive | `code: write` | job success; `Posted review-verdict/h10-PROBE=success`; `creator: null` |
| Negative | `code: read` | job failure at the POST; `curl` exit 22; **no status written** |
The negative control is the load-bearing half: a green positive alone is equally consistent with `permissions:` being a no-op beside a permissive default. `creator: null` also confirms approach **A** preserves the provenance signal approach B would destroy. Probe branches and PRs (#851, #852) deleted.
**What it does not establish:** both controls ran under a **permissive** owner default, so nothing here shows what a declared `code: write` receives under **Restricted**. If Gitea caps at the default the way GitHub does, the flip makes the gate unwritable. server-management#714 stays open for that probe, and the issue's Deferred section says so.
## Rebased onto #857, which changed the answer
This PR originally excluded `ci-image.yml` — editing it re-pointed `ci-image-pin`'s `expected` at the editing commit and reddened a **blocking** job (CI demonstrated this on the first push), and its own `paths:` made the edit publish an image. #857 (#744/#835) removed the file from both. So `ci-image.yml` now declares too, and the one-name guard exemption this branch briefly carried was **deleted rather than documented** — the form #835 asked for.
## Corrections to the issue body
`docker-build.yml` does not write a status with the injected token — no `GITEA_TOKEN` reference; its `ETV_STATUS_AUTH` is the scoped REGISTRY_* PAT doing read-only GETs. Only `review-verdict.yml` needs write. No `packages:` unit anywhere: all five `container:` blocks carry explicit `credentials:`.
## Guard
Two mutation-proved tests in `scripts/tests/test_pr_changed_files.py`, over the git-derived population with no exemptions: the gate job's units by **equality** (an extra unit is a finding too — this is the repo's only CI repo-write), and every tracked workflow declaring a block. Needed because that workflow's base resolution means the PR removing `code: write` structurally cannot catch itself.
## Verification
`scripts/tests/` 1028 passed / 2 skipped; `decisions_validate` OK; catalog regenerated; ruff clean. Independent cold-context review in an isolated worktree across **10 rounds**, re-running the mutation battery itself rather than accepting the claims; final verdict MERGEABLE.
fixes #748
Rebased onto #857 (#744/#835), which landed while this was in review and
changed the answer rather than just the context.
review-verdict.yml (job-level) code: write, issues: read, pull-requests: read
the other five (top-level) code: read
`code: write` because Gitea has no `statuses` scope — status-write IS Code-write
(`reqRepoWriter(unit.TypeCode)`). The two read units are not spare: the gate job
also GETs `/issues/{n}/timeline` (the #706 retarget fence) and, via
`scripts/pr-changed-files.sh`, `/pulls/{n}` and `/pulls/{n}/files` (the #698
exemption path). Enumerated against every authenticated call reachable from
`set-verdict-status`; the set covers it exactly, with nothing over. No
`packages:` unit anywhere: all five `container:` blocks carry explicit
`credentials:`.
THE BLOCKER WAS NEVER THE DIFF. `ci.actions-credential-scoping` had carried
"whether `permissions:` is honored here is UNPROBED" since 2026-08-05, and
`review-verdict.yml` is base-resolved so its own PR's CI proves nothing about
it. Settled with a matched scratch-base probe pair on 1.27.1, the block carried
at JOB level on `set-verdict-status` — the shipped shape:
code: write -> job success, `Posted review-verdict/h10-PROBE=success`,
status creator: null
code: read -> job failure at the POST, curl exit 22, NO status written
The negative control is the load-bearing half: a green positive alone is equally
consistent with `permissions:` being a no-op beside a permissive default.
`creator: null` also confirms approach A preserves the provenance signal
approach B would destroy.
NOT established, and the docs say so: both controls ran under a PERMISSIVE owner
default, so nothing here shows what a declared `code: write` receives under
Restricted. If Gitea caps at the default the way GitHub does, the flip makes the
gate unwritable. server-management#714 stays open for that probe.
WHAT #857 CHANGED. This PR originally excluded `ci-image.yml` — editing it
re-pointed `ci-image-pin`'s `expected` at the editing commit and reddened a
BLOCKING job (which CI demonstrated on the first push), and its own `paths:`
made the edit publish an image. #744 removed this file from both. So the
one-name exemption this branch briefly carried is DELETED rather than
documented, and the guard asserts over the whole derived population with nothing
carved out — the form #835 asked for.
Corrects the issue body: `docker-build.yml` does NOT write a status with the
injected token (no `GITEA_TOKEN` reference; its `ETV_STATUS_AUTH` is the scoped
REGISTRY_* PAT doing read-only GETs). Only review-verdict.yml needs write.
Guard: two mutation-proved tests in `test_pr_changed_files.py` — the gate job's
units by EQUALITY (an extra unit is a finding too; this is the repo's only CI
repo-write), and every tracked workflow declaring a block. Needed because that
workflow's base resolution means the PR removing `code: write` structurally
cannot catch itself.
Reviewed cold in an isolated worktree across 7 rounds; the reviewer re-ran the
mutation battery itself rather than accepting the claims.
Decisions-Edit: yes
fixes#748
Rebase regression. Removing the ci-image exception generalized the
gate-trigger record's summary from "four of today's workflows can no longer post
a status, ci-image.yml still can" into "none of today's six workflows can post a
status" — which sweeps in `review-verdict.yml`, the one workflow that MUST post,
in the record whose whole subject is the workflow that posts the status.
It errs toward claiming more security than exists: a reader auditing forgery
routes was told the injected token cannot post anywhere. Both occurrences, body
and `rule:` (so the active catalog), now read: the five holding `code: read`
cannot post; the sixth holds `code: write` deliberately because it IS the gate.
Scoping was never meant to stop the gate posting — it was meant to stop
everything else being able to.
Same sentence carried a misattached hedge: "rests on the TOP-LEVEL form, which
is inferred rather than probed" was qualifying a claim about all six, but the
gate uses the JOB-LEVEL form, which is exactly the one that WAS measured. The
hedge is now scoped to the five it applies to.
Also `docs/ci-cd.md`: "This PR briefly carried a documented one-name exemption"
— "this PR" has no referent after merge (`docs.no-session-narrative`); the issue
number does. The rejected-and-why content stays, which is what #857 does for the
matching `persist-credentials` exemption.
Decisions-Edit: yes
Refs: #748
Third round wrong on one sentence, each time differently: "four cannot /
ci-image still can" (true) -> "none of the six can" (swept in the gate itself)
-> "only the gate can" (false via the secret store). Two defects from one
mechanism is this repo's threshold for WITHDRAWING rather than patching, and
that is what this does.
The mechanism was the same every time: a one-line "only X can post a status"
summary silently generalizes from the INJECTED token, which `permissions:`
scopes, to every credential a job can reach, which it does not touch at all.
That is false today and not hypothetically: `renovate.yml` is one of the five
holding `code: read` and references `secrets.RENOVATE_TOKEN`, a
`write:repository` bot PAT — so an existing `code: read` workflow holds a
status-capable credential right now. The same paragraph already said so seven
lines further down, so the record contradicted itself inside one field.
The summary is deleted from body and `rule:` (hence the catalog). Nothing is
lost: every precise fact it purported to summarize is already stated separately
— the unit split, the SHAPE staying open, `RENOVATE_TOKEN` still writing
statuses, a collaborator's token being a route with no workflow. Both places now
say explicitly that no such summary is stated and why, so it does not get
helpfully re-added by the next reader.
Two sibling records already scoped this correctly and were the model:
`ci.actions-credential-scoping` ("the property is 'only a workflow visibly
opting into write can assert the verdict', not 'no workflow can'") and
`release.verdict-status-check`.
Decisions-Edit: yes
Refs: #748
Two tidy-ups from the final review, both in the withdrawal note added last
commit.
"three successive attempts at one were each false in a different way" is
autobiography — it only parses for someone who watched the review
(`docs.no-session-narrative`). The durable content is the MECHANISM, which the
same sentence already gives: any such summary generalizes from the injected
token, which `permissions:` scopes, to every credential a job can reach, which
it does not touch. The count is in the commit messages, where it belongs.
The note's closing clause restated "a collaborator's own token is a route with
no workflow at all" immediately before the pre-existing sentence saying exactly
that. Dropped, and "the precise facts are each stated on their own below" went
with it — inaccurate once the SHAPE fact had been moved INTO the note.
Kept deliberately: the note itself, and the concrete falsifying case
(`RENOVATE_TOKEN` is referenced by `renovate.yml`, one of the five holding
`code: read`). That is "what was rejected and why, so it is not re-adopted",
which the doc rule protects — and the counterexample is a fact none of the three
summary versions contained.
Decisions-Edit: yes
Refs: #748
10-round cold-context adversarial review in an isolated worktree; reviewer re-ran the 17-path mutation battery itself. Unit set enumerated against every authenticated call reachable from set-verdict-status and measured end-to-end by a matched scratch-base probe with a negative control. Rebased onto #857; ci-image exemption deleted, not documented.
Review-verdict: MERGEABLE @ 5b0e0e7
10-round cold-context adversarial review in an isolated worktree; reviewer re-ran the 17-path mutation battery itself. Unit set enumerated against every authenticated call reachable from set-verdict-status and measured end-to-end by a matched scratch-base probe with a negative control. Rebased onto #857; ci-image exemption deleted, not documented.
timothy
merged commit 8aebba4d89 into main2026-08-28 00:02:47 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Declares
permissions:on all six workflows so the owner-level Actions default can move to Restricted (server-management#714), and — the part that was actually blocking — proves the declaration binds, whichci.actions-credential-scopinghad carried as UNPROBED since 2026-08-05.Why this sat for three weeks
The change was written 2026-08-06 (
7280a91d, subjectNOT VERIFIED, do not merge) and blocked on #751, which closed 2026-08-10. The unblocking was noticed 2026-08-14 by a server-management session, which correctly left a cross-reference rather than working in this repo — and no ersatztv session claimed it since. Not abandoned; orphaned by a repo boundary.It stayed parked for a real reason:
review-verdict.ymlis base-resolved, so CI-green on its own PR proves nothing, and a wrong unit blocks every merge with no force-merge escape since #743.The probe
Matched scratch-base pair on Gitea 1.27.1, identical except for one permission unit, the block carried at job level on
set-verdict-status— the shipped shape:code: writePosted review-verdict/h10-PROBE=success;creator: nullcode: readcurlexit 22; no status writtenThe negative control is the load-bearing half: a green positive alone is equally consistent with
permissions:being a no-op beside a permissive default.creator: nullalso confirms approach A preserves the provenance signal approach B would destroy. Probe branches and PRs (#851, #852) deleted.What it does not establish: both controls ran under a permissive owner default, so nothing here shows what a declared
code: writereceives under Restricted. If Gitea caps at the default the way GitHub does, the flip makes the gate unwritable. server-management#714 stays open for that probe, and the issue's Deferred section says so.Rebased onto #857, which changed the answer
This PR originally excluded
ci-image.yml— editing it re-pointedci-image-pin'sexpectedat the editing commit and reddened a blocking job (CI demonstrated this on the first push), and its ownpaths:made the edit publish an image. #857 (#744/#835) removed the file from both. Soci-image.ymlnow declares too, and the one-name guard exemption this branch briefly carried was deleted rather than documented — the form #835 asked for.Corrections to the issue body
docker-build.ymldoes not write a status with the injected token — noGITEA_TOKENreference; itsETV_STATUS_AUTHis the scoped REGISTRY_* PAT doing read-only GETs. Onlyreview-verdict.ymlneeds write. Nopackages:unit anywhere: all fivecontainer:blocks carry explicitcredentials:.Guard
Two mutation-proved tests in
scripts/tests/test_pr_changed_files.py, over the git-derived population with no exemptions: the gate job's units by equality (an extra unit is a finding too — this is the repo's only CI repo-write), and every tracked workflow declaring a block. Needed because that workflow's base resolution means the PR removingcode: writestructurally cannot catch itself.Verification
scripts/tests/1028 passed / 2 skipped;decisions_validateOK; catalog regenerated; ruff clean. Independent cold-context review in an isolated worktree across 10 rounds, re-running the mutation battery itself rather than accepting the claims; final verdict MERGEABLE.fixes #748
permissions:on all six workflows, and prove it binds bcc3669005Third round wrong on one sentence, each time differently: "four cannot / ci-image still can" (true) -> "none of the six can" (swept in the gate itself) -> "only the gate can" (false via the secret store). Two defects from one mechanism is this repo's threshold for WITHDRAWING rather than patching, and that is what this does. The mechanism was the same every time: a one-line "only X can post a status" summary silently generalizes from the INJECTED token, which `permissions:` scopes, to every credential a job can reach, which it does not touch at all. That is false today and not hypothetically: `renovate.yml` is one of the five holding `code: read` and references `secrets.RENOVATE_TOKEN`, a `write:repository` bot PAT — so an existing `code: read` workflow holds a status-capable credential right now. The same paragraph already said so seven lines further down, so the record contradicted itself inside one field. The summary is deleted from body and `rule:` (hence the catalog). Nothing is lost: every precise fact it purported to summarize is already stated separately — the unit split, the SHAPE staying open, `RENOVATE_TOKEN` still writing statuses, a collaborator's token being a route with no workflow. Both places now say explicitly that no such summary is stated and why, so it does not get helpfully re-added by the next reader. Two sibling records already scoped this correctly and were the model: `ci.actions-credential-scoping` ("the property is 'only a workflow visibly opting into write can assert the verdict', not 'no workflow can'") and `release.verdict-status-check`. Decisions-Edit: yes Refs: #748Review-verdict: MERGEABLE @
5b0e0e710-round cold-context adversarial review in an isolated worktree; reviewer re-ran the 17-path mutation battery itself. Unit set enumerated against every authenticated call reachable from set-verdict-status and measured end-to-end by a matched scratch-base probe with a negative control. Rebased onto #857; ci-image exemption deleted, not documented.