fix(748): declare permissions: on all six workflows, and prove the declaration binds #860

Merged
timothy merged 4 commits from fix/748-v3 into main 2026-08-28 00:02:47 +02:00
Owner

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

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
timothy added 4 commits 2026-08-27 23:38:33 +02:00
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
docs(748): drop the attempt tally and the duplicated clause
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 24s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 28s
PR Gates / decisions lifecycle (pull_request) Successful in 35s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 26s
Review verdict / Set review-verdict status (pull_request_target) Successful in 33s
review-verdict/h10 Review-verdict: MERGEABLE @ 5b0e0e7 (base: main)
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 5m32s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m22s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
5b0e0e7ea5
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
Author
Owner

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.

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 main 2026-08-28 00:02:47 +02:00
timothy deleted branch fix/748-v3 2026-08-28 00:02:50 +02:00
Sign in to join this conversation.