diff --git a/.gitea/workflows/review-verdict.yml b/.gitea/workflows/review-verdict.yml
index cb70ce633..c922c5522 100644
--- a/.gitea/workflows/review-verdict.yml
+++ b/.gitea/workflows/review-verdict.yml
@@ -73,6 +73,40 @@ name: Review verdict
# ignored: the BASE definition ran and posted `h10=pending`, on both `opened` and `synchronize`,
# with `secrets` still available.
#
+# THE DEFINITION-RESOLUTION HALF is re-established FROM THE v1.27.1 SOURCE, 2026-09-02
+# (ersatztv#869) — the mechanism behind the measurement above. In
+# `services/actions/notifier_helper.go` one function resolves the two triggers from two SEPARATELY
+# RESOLVED refs: for a PR event `input.Ref` resolves to the PR's own HEAD ref (`WithPullRequest`
+# defaults it to `pr.GetGitHeadRefName()` when the caller has not already set one), and workflows
+# detected at that commit are kept only if their trigger is NOT `pull_request_target`; the base is
+# then resolved separately as `git.BranchPrefix + input.PullRequest.BaseBranch`, and workflows
+# detected there are kept only if their trigger IS `pull_request_target`. A head rewrite cannot
+# reach this file's definition at all.
+#
+# WHICH HALF, precisely — the source covers definition resolution and NOTHING ELSE. It does not
+# speak to which `types` produce a run, nor to secrets availability.
+#
+# What IS established on 1.27.1, and stated no wider than it was measured: `pull_request_target`
+# fires for THIS workflow continuously — 210 such runs across 189 distinct head shas, counting only
+# runs started strictly after the 2026-08-05 upgrade (measured 2026-09-02). The WINDOW is part of
+# the claim, and an earlier draft got it wrong by citing the all-time REPO-WIDE figure: 258 runs /
+# 233 shas = 44 before the upgrade date + 4 ON it + 210 after. Citing 258 here would have imported
+# 1.25.4 evidence into a 1.27.1 sentence. This workflow's own all-time count is 250; the difference
+# is 8 runs from three since-deleted probe workflows, all pre-upgrade — which is why every one of
+# the 210 post-upgrade runs is this file.
+# The 4 same-day runs are excluded rather than assigned, because the upgrade landed mid-session and
+# their 17:41-22:36 UTC start times cannot be placed either side of it without the exact upgrade
+# time. Read those as UTC deliberately: this endpoint returns `started_at` Z-suffixed, while other
+# Gitea timestamps render in the host zone, Europe/Brussels — `+02:00` in August, `+01:00` in
+# winter — so a UI comparison shows different clock times.
+# What that does NOT establish, deliberately: WHICH `types:` fire (a run count identifies no event
+# type), and whether the STORED secret store is reachable under this trigger — this job references
+# only the injected `secrets.GITEA_TOKEN`. Both of those stay 1.25.4-dated.
+#
+# That is also why `branches: [main]` is load-bearing rather than tidy: the base is resolved by
+# BRANCH NAME, not by a pinned sha, so the base definition is whatever that branch points at when
+# the event fires.
+#
# `pull_request_target` is normally the DANGEROUS trigger, and it is worth being explicit about why
# that reputation does not transfer here. Its footgun is running untrusted HEAD code with a
# privileged token. This job never checks out the head and never executes anything the PR supplies:
@@ -1364,12 +1398,47 @@ jobs:
# The guard still exists for its original reason: re-posting `pending` over a real human
# verdict would un-approve a reviewed head and stall the PR. So it discriminates by PROVENANCE.
#
- # MEASURED on this instance (Gitea 1.25.4), on the COMBINED endpoint this job reads: a status
+ # MEASURED on this instance, on the COMBINED endpoint this job reads: a status
# POSTed with a USER credential — how `scripts/post-review-verdict.sh` writes a verdict —
# carries `.creator.login`, while one POSTed by an Actions job with the built-in `GITEA_TOKEN`
# carries `"creator": null`. A real verdict read back `creator=timothy`; this job's own
# exemption read back `creator=null`.
#
+ # FIRST measured on 1.25.4; RE-MEASURED on 1.27.1, 2026-09-02 (ersatztv#869) — this is the
+ # claim the H10 allow-list rests on, which is why #869 prioritised it. It is NOT the last
+ # 1.25.4-dated load-bearing claim: the `pull_request_target` half the retarget fence relies
+ # on is still 1.25.4-dated too, deliberately (`ci.verdict-write-retarget-fence`).
+ # Four merged PR heads read back independently (#903, #884, #902,
+ # #899): on every one exactly ONE status carries `.creator.login` — `review-verdict/h10`,
+ # `timothy`, the one `post-review-verdict.sh` writes — and EVERY other row on that head
+ # carries `creator: null`. It holds on BOTH endpoints, combined `/commits/{sha}/status` and
+ # list `/statuses/{sha}`, so this guard and `page_statuses` see the same attribution. The
+ # two differ only in HOW MANY rows come back, which is not an attribution difference: the
+ # combined endpoint is latest-per-context (15 rows on each of the four) and the list
+ # endpoint returns every row ever posted (43-63 across them).
+ #
+ # THE MECHANISM, so this is re-derivable without a live PR. An earlier draft of this comment
+ # got it wrong in a way worth recording, because the wrong version is the intuitive one:
+ # it said `creator: null` means `CreatorID == 0`. It does not. At v1.27.1 an Actions job
+ # authenticates as the SYNTHETIC Actions user, `ActionsUserID = -2`
+ # (`models/user/user_system.go`), which is never a row in the `user` table, and
+ # `NewCommitStatus` stores `CreatorID = opts.Creator.ID` — so an Actions-posted status
+ # stores -2, NOT 0. `services/convert/status.go`'s `ToCommitStatus` therefore TAKES its
+ # `if status.CreatorID != 0` branch and the null comes from the next line:
+ # `creator, _ := user_model.GetUserByID(ctx, status.CreatorID)` is a bare lookup with NO
+ # system-user mapping (contrast `GetPossibleUserByID`, which handles `id < 0` explicitly),
+ # so -2 misses, THE ERROR IS DISCARDED, and `ToUser(ctx, nil, nil)` returns nil.
+ #
+ # It is still a property of the SERIALIZER, which is why it cannot differ between the two
+ # endpoints — the combined endpoint reaches the same `ToCommitStatus`.
+ #
+ # THE CONSEQUENCE FOR THIS GUARD, which the wrong mechanism hid: `creator: null` does not
+ # mean "a machine wrote this". Any row whose creator account was later DELETED serializes
+ # null by the same path. That does NOT weaken this guard, and the reason is the direction
+ # of the test below: it short-circuits only on a POSITIVE allow-list match, so both a
+ # machine write and a vanished author land on RE-DERIVE, the safe branch. It would break a
+ # guard written the other way round, which is one more reason not to invert it.
+ #
# BOTH conditions are required, and the DIRECTION of the test is the point: we short-circuit
# only on something POSITIVELY identified as a human verdict. Anything else, including anything
# we do not recognise, is RE-DERIVED. Written the other way round ("skip if it looks
diff --git a/docs/ci-cd.md b/docs/ci-cd.md
index 13624f68c..8c1dd5182 100644
--- a/docs/ci-cd.md
+++ b/docs/ci-cd.md
@@ -1747,6 +1747,16 @@ arranging two real merges, or adding a throwaway trigger; both cost more than th
because nothing branches on it. The `pull_request_target` half the fence actually relies on was **not**
re-measured either and is 1.25.4-dated too.
+The two facts this paragraph rests on — that no workflow triggers on a push to a non-`main` branch,
+and that `main` refuses direct pushes — were **re-checked on 2026-09-02 (ersatztv#869)** and both still
+hold. Note what that does and does not cover: it re-checks the REASON, not the claims. The
+`push`-supersession and `pull_request_target` measurements above remain 1.25.4-dated and were not
+re-run by #869 either. Re-checking the reason matters on its own, because a reason decays exactly like
+the claim it excuses. One limit is worth stating so this is not over-read: the enumeration covers
+`push:` triggers only. Four of the six workflows carry `workflow_dispatch:`, so a disposable run can
+still be raised on demand — what is gone is the cheap scratch-BRANCH push probe, not every route to a
+throwaway run.
+
Separately, after posting **any** status the job re-reads the per-POST status history and, if a human
`Review-verdict:` row appeared during the write window, overwrites its own status with `pending` and
logs an error. It ran only for a `success` write until ersatztv#849, on the claim that a `pending`
@@ -1818,6 +1828,18 @@ validate at most 950 rows, since the twentieth must be the empty terminator. The
per endpoint and they differ: `/statuses/{sha}` returns `[]`, `/issues/{n}/timeline` a bare `null`,
`/commits/{sha}/status` an object with `statuses: null`.
+**The two walks terminate by OPPOSITE rules, and that asymmetry is deliberate** (ersatztv#870, settled
+by ersatztv#893 on 2026-09-02). `count_pr_mutations` reads the timeline to its cap and trusts the
+counts only when the LAST page came back empty; `page_statuses` still returns on its FIRST empty page.
+They differ because the ENDPOINTS differ, established from the v1.27.1 source: `getCommitStatuses`
+serializes every row the paged query returns — an unconditional `append` loop, the only filter being a
+SQL `WHERE` the database evaluates in the same query as the `LIMIT`/`OFFSET` — so a serialized page is
+exactly as long as its database page, and an empty page really is the end. `ListIssueCommentsAndTimeline` instead drops
+`CommentTypeCode` rows AFTER paging, which is what makes a fully filtered page byte-identical to the
+end of the list. The same source explains the header: `/statuses/{sha}` sets `X-Total-Count` from a SQL
+COUNT, so it is a true total (page 1 of 50 on a head reporting 63, measured 2026-09-02), where the
+timeline reports its post-filter page length. **Do not "tidy" the two walks into agreement.**
+
**The page-2 probe is gone.** ersatztv#751 treated "there are rows I did not read" as "assume raced".
That fired on Renovate PR #761: a head that grew past one page over ordinary CI re-runs had its
exemption repaired away, with an `::error::` asserting a human verdict was overwritten when the head
diff --git a/docs/decisions/records/ci/actions-credential-scoping.md b/docs/decisions/records/ci/actions-credential-scoping.md
index ed03965ad..c528b1066 100644
--- a/docs/decisions/records/ci/actions-credential-scoping.md
+++ b/docs/decisions/records/ci/actions-credential-scoping.md
@@ -7,7 +7,7 @@ supersedes: none
superseded-by: none
rule: 'Any credential reachable from an Actions job is scoped to what that job needs. The container-registry secret `REGISTRY_PASSWORD` is a personal access token scoped `write:package` + `read:repository` — never an account PASSWORD. This matters because Gitea has NO `status` token scope: `POST /repos/{o}/{r}/statuses/{sha}` is gated by `reqRepoWriter(unit.TypeCode)`, so ANY credential that can write the repository can forge `review-verdict/h10`, the required context that is supposed to make merge-consent derived rather than assertable. Package-write IS a separate scope, so the registry credential can be made status-incapable at no cost: `scripts/ci-detect-already-validated.sh` only GETs. `permissions:` on a workflow/job DOES bind on this instance — MEASURED 2026-08-27 on 1.27.1 by matched scratch-base probe PRs differing only in one unit, the block carried at JOB level on `set-verdict-status` (`code: write` posted the probe status; `code: read` 403ed the POST, curl exit 22, no status written), so all six workflows here now declare it (#748). Two properties that make it usable: the declaration is EXHAUSTIVE, not additive — a unit omitted is not granted — and it binds while the owner-level default is `permissive`, which is what makes the five `code: read` declarations effective TODAY rather than only after a flip. NOT established: what a declared `code: write` does under a RESTRICTED default. GitHub semantics let `permissions:` only narrow, never widen past the default, and if Gitea copies that, Restricted would cap the gate job at read and `review-verdict/h10` would stop being writable — the exact catastrophic case. The probe ran under `permissive` and CANNOT rule that out. Flip the owner default only behind the scratch-base probe re-run under Restricted (server-management#714, still open for this reason). The earlier form of this rule said the opposite ("do NOT add a `permissions:` key on the assumption that it binds — below Gitea 1.26.0 it is silently a NO-OP"); that was correct at 1.25.4 and is retained here so a reader meeting the old advice recognises it as superseded. There is still no API surface for the owner-level default (`/api/v1/settings/actions` 404s at 1.27.1). The instance default HAS since been probed and is NOT unknown: it was set to Restricted, verified, and reverted to `permissive` on 2026-08-05 (#748), which is where it stands. Probe before relying on it; do not read the upgrade alone as the constraint now working. Scoping is necessary and not sufficient: it bounds what a job may DO, never whether attacker YAML runs at all, so a self-referencing trigger needs its own filter. That landed for `ci-image.yml` in #744 (`ci.toolchain-image-publish-is-a-dispatch`) — deliberately NOT bundled here, because until it also removed the file from `ci-image-pin`''s `expected`, editing it re-pointed that job at the editing commit and reddened a blocking check. A second, separate consequence of the same boundary: `actions/checkout` persists whatever the job token can do into `.git/config` unless `persist-credentials: false` is set — read-only everywhere since #748 declared `permissions:` on all six workflows — and all 16 of this repo''s checkouts now set it — 15 in #746 and `ci-image.yml`''s in #744, once `ci.toolchain-image-publish-is-a-dispatch` removed the two mechanical reasons it was excluded; the convention is held with no exemption list by `scripts/tests/test_workflow_persist_credentials.py` (#835). Ordering is part of the rule: unmask the dependent fetches FIRST, because until then a credential regression presents as an empty changed-file set and a silently skipped check rather than a red job. This record closes ONE route. It does not close the class, and the later sections say exactly what survives — read them before citing this record as a mitigation. The `workflow_dispatch` half of what survives is now settled rather than open: #853 probed Gitea 1.27.1 and ACCEPTED it (`ci.workflow-dispatch-ref-unrestricted`) — there is no ref restriction and no protected-environment concept to gate a secret behind, and restricting dispatch would close nothing anyway, because `docker-build.yml`''s head-resolved `pull_request:` runs attacker-authored YAML, which reaches EVERY secret in the store and not merely the ones the committed workflows name. The PR route and the `v*` tag push, not dispatch, are the live residuals (#885).'
signals: 'admin password in CI secrets, registry credential scope, ETV_STATUS_AUTH can write statuses, forge review-verdict/h10, head-resolved workflow holds credentials, persist-credentials on actions/checkout, credential left in .git/config, masked git fetch yields empty changed set, Gitea token scopes, no status scope, write:package vs write:repository, permissions key no-op, GITEA_TOKEN default read/write, Restricted default token permissions, orphan secret, deploy key in secret store, toolchain image overwrite, prod floating tag write · paths: `.gitea/workflows/docker-build.yml`, `.gitea/workflows/pr-checks.yml`, `.gitea/workflows/dependency-scan.yml`, `.gitea/workflows/ci-image.yml`, `.gitea/workflows/renovate.yml`, `scripts/ci-detect-already-validated.sh` · issues: #697, #672, #698, #742, #743, #746, #748, #835, #420, server-management#714'
-mechanics: 'PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. NOT measured with this token: the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, read at tag `v1.25.4`) — INFERRED. Note WHICH run proves which: only the `container:` pull is exercised by a PR. `cache-to`/`cache-from` and the base-image pull are confined to the `build` job, which carries `if: github.event_name != ''pull_request''`, so they are first exercised on the post-merge push to `main` — AFTER the merge gate has passed. A wrong inference there reddens main, not the PR.'
+mechanics: 'PAT `ci-registry-scoped-697`, scopes `write:package,read:repository`, stored as repo Actions secret `REGISTRY_PASSWORD`; `REGISTRY_USER` remains `timothy`. Verified 2026-08-05 on Gitea 1.25.4: registry push of a probe tag SUCCEEDED; `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` REFUSED HTTP 403 `token does not have at least one of required scope(s), required=[write:repository], token scope=write:package,read:repository`. Probe artifacts deleted, confirmed 404. RE-PROBED 2026-09-02 on Gitea 1.27.1 (#869) in a throwaway repo with a freshly minted PAT of the same two scopes: same 403 with the same message, plus a read control (same token GET -> 200) and a write control (admin POST -> 201); repo and token deleted, deletion confirmed. NOT COVERED BY EITHER PROBE (2026-08-05 or the #869 re-probe) — note this means not probed, NOT unexercised, and the `container:` pull is in fact exercised by the shipped credential on every PR: the `container:` pull, the buildcache write and the base-image pull. Those rest on Gitea''s scope model (write implies read per category, re-read at tag `v1.27.1` 2026-09-02) — INFERRED. Note WHICH run proves which: only the `container:` pull is exercised by a PR. `cache-to`/`cache-from` and the base-image pull are confined to the `build` job, which carries `if: github.event_name != ''pull_request''`, so they are first exercised on the post-merge push to `main` — AFTER the merge gate has passed. A wrong inference there reddens main, not the PR.'
---
**What was wrong.** `REGISTRY_USER`/`REGISTRY_PASSWORD` were the **admin account's** basic auth, and
@@ -102,16 +102,50 @@ count is now **16 of 16 with no exemption**. That is what let #835 ship the guar
exemption list at all: a one-entry list justified by a temporary condition elsewhere keeps passing
after that condition expires, leaving the guard permanently blind to the file it was written for.
-**Measured vs inferred.** Measured here: the `v1.25.4` scope enum (`access_token_scope.go`) has no
+**Measured vs inferred.** Measured here: the scope enum (`access_token_scope.go`) has no
`status` entry; the `reqRepoWriter` gate (`routers/api/v1/api.go`); the probes in `mechanics`. Read from
docs, NOT verified (2026-08-05): `permissions:` landed in 1.26.0 (Gitea PR #36173); no `app.ini` lever
at any version; Gitea rejects GitHub's `statuses`/`checks` scopes.
-**MEASURED 2026-08-27 on 1.27.1 (#748) — this block is NOT covered by the 1.25.4 caveat below.** It
-re-probes exactly ONE of that caveat's items, the `permissions:` question, and supersedes the
-"whether `permissions:` is honored here is UNPROBED" claim. It does NOT discharge the caveat: the
-scope enum, the `reqRepoWriter` gate and the `write:package` 403 are still 1.25.4-dated and
-un-re-run. Method: a matched pair of scratch-base probe PRs, identical except for one permission
+**RE-ESTABLISHED ON 1.27.1, 2026-09-02 (#869) — the three items the version caveat below NAMES.**
+It discharges those three and only those three. It does NOT discharge every dated statement in this
+record: "no container tag immutability in Gitea 1.25" (above) is still INFERRED and untouched, and
+the caveat is kept rather than deleted because it names what each item rests on.
+
+- **Scope enum — no `status` scope.** Read at tag `v1.27.1`, `models/auth/access_token_scope.go` —
+ the same path it occupies at `v1.25.4`, checked in both trees rather than assumed, because this
+ record had only ever named the file and not its directory.
+ Nine categories — ActivityPub, Admin, Misc, Notification, Organization, Package, Issue,
+ Repository, User — and the string `status` does not occur in the file at all. `write:package`,
+ `read:repository` and `write:repository` remain distinct scopes, which is what keeps the registry
+ credential scopable off status-write at no cost.
+- **The `reqRepoWriter` gate.** Read at tag `v1.27.1`, `routers/api/v1/api.go`: the `/statuses`
+ group registers `m.Combo("/{sha}").Get(repo.GetCommitStatuses).Post(reqToken(),
+ reqRepoWriter(unit.TypeCode), bind(api.CreateStatusOption{}), repo.NewCommitStatus)`. Unchanged in
+ substance from the 1.25.4 read: writing a commit status is still gated on repository-code WRITE,
+ so any credential that can write the repository can still forge `review-verdict/h10`.
+- **The `write:package` 403 — re-probed live, with both controls.** A PAT scoped exactly
+ `write:package,read:repository`, minted against the live 1.27.1 instance, `POST
+ /repos/{o}/{r}/statuses/{sha}` -> **403**, message byte-for-byte the 1.25.4 one: `token does not
+ have at least one of required scope(s), required=[write:repository], token
+ scope=write:package,read:repository`. Two controls, because a bare 403 is equally consistent with a
+ dead token or a dead route: the SAME token `GET`ting the same path -> **200** (the token is live and
+ the read scope works), and an admin credential `POST`ing the same path -> **201** (the route accepts
+ a POST). **Be precise about what the second control licenses** — it changes the credential, so scope
+ and identity vary together and the PAIR alone cannot separate a SCOPE refusal from a
+ unit-permission refusal. What actually discriminates is the 403 BODY, which names the scope it
+ wanted and the scope it got; the admin 201 only rules out "the route rejects every POST". Cite the
+ message as the evidence and the control as its backstop, not the other way round. Run in a
+ throwaway private repo created for the probe; repo and token both deleted afterwards and the
+ deletion confirmed (repo `GET` -> 404, token `DELETE` -> 204), so nothing was written to a real
+ head.
+
+**MEASURED 2026-08-27 on 1.27.1 (#748).** It re-probed exactly ONE of the old 1.25.4 caveat's items,
+the `permissions:` question, and supersedes the "whether `permissions:` is honored here is UNPROBED"
+claim. It did **not** by itself discharge that caveat — the scope enum, the `reqRepoWriter` gate and
+the `write:package` 403 were still 1.25.4-dated and un-re-run when this paragraph was written; #869
+re-established all three on 2026-09-02, which is what finally discharged it (see the block above and
+"Version history" below). Method: a matched pair of scratch-base probe PRs, identical except for one permission
unit, the block carried **at job level on `set-verdict-status`** — the same level and job as the
shipped `review-verdict.yml`, so the gate's own shape is what was measured.
@@ -137,24 +171,32 @@ controls ran under `permissive`); and the TOP-LEVEL declaration form the other f
which is inferred rather than measured. That inference fails safe (a top-level key Gitea ignored is a
security no-op, never a brick). Probe branches and PRs (#851, #852) deleted after reading.
-**Version caveat — this record's measurements are pinned to 1.25.4, the instance is now 1.27.1.**
-The instance was upgraded mid-session on 2026-08-05 (#743). Everything above measured on 1.25.4 is
-therefore a *dated* claim, not a current one: the scope enum, the `reqRepoWriter` gate and the 403
-probe were all taken pre-upgrade and have NOT been re-run. They are recorded honestly as of their
-date and are the best evidence available, but do not cite them as current behaviour without
-re-probing. #747 re-probed SOME of the 1.25.4-pinned CI claims on 2026-08-28 —
-`ci.docs-only-skip-steps`, `ci.shared-pr-file-enumeration`, the REST-route half of
-`ci.batch-pushes-no-cancel-route`, the `compare` half of `ci.exemption-provenance` and the
-`branches: [main]` item in `ci.gate-trigger-base-resolved` — and it was **not** a sweep of the
-corpus. Other 1.25.4-dated CI claims remain un-re-run, the `creator`-attribution measurement the H10
-allow-list rests on (`review-verdict.yml`) among them. It **did not** discharge this record's own
-remaining items either: the `v1.25.4` scope enum, the `reqRepoWriter` gate and the `write:package` 403
-all stay 1.25.4-dated. Only the 403 needs a scoped PAT minted against the live instance; the scope enum
-and the `reqRepoWriter` gate were established by reading Gitea's source at the version tag (see
-"Measured vs inferred" above), so re-running those needs only a source read at `v1.27.1`. #747 did
-neither. What #747 did settle elsewhere is the merge-path half — see `release.main-direct-push-disabled`
-for the measured
-`block_admin_merge_override` semantics and for why `enable_bypass_allowlist` is not a substitute.
+**Version history — the 1.25.4 caveat, and what discharged it.** The instance was upgraded mid-session
+on 2026-08-05 (#743), which left this record's measurements dated against a version it no longer ran.
+#747 re-probed SOME of the 1.25.4-pinned CI claims on 2026-08-28 — `ci.docs-only-skip-steps`,
+`ci.shared-pr-file-enumeration`, the REST-route half of `ci.batch-pushes-no-cancel-route`, the
+`compare` half of `ci.exemption-provenance` and the `branches: [main]` item in
+`ci.gate-trigger-base-resolved` — and it was **not** a sweep of the corpus. #869 then swept the corpus
+by deriving the population with `git ls-files` rather than from an issue's item list, and re-established
+most of it — but "finished" would be too strong. Several findings remain 1.25.4-dated by decision, and
+**nothing here tracks which ones, deliberately.** Two attempts at that tracking both failed inside the
+commit that wrote them: an enumerated list named three residuals and omitted three the same commit
+created, and its replacement — a `git grep` pointer — missed the residual that same commit created,
+because the commit had introduced a second spelling the grep did not match. A second copy of a set,
+whether written out or derived by a pattern, is one more thing to keep in step with the set. Each
+residual states its own reason where it lives, which is the copy that cannot drift from its subject.
+This record's own
+three items are re-established on 1.27.1 above (2026-09-02, #869), so they may now be cited as current. What #747 settled elsewhere is the merge-path half — see
+`release.main-direct-push-disabled` for the measured `block_admin_merge_override` semantics and for why
+`enable_bypass_allowlist` is not a substitute.
+
+**Read the two source items as source-attested, not instance-attested, and know the difference.** The
+scope enum and the `reqRepoWriter` gate are established by reading Gitea's source at tag `v1.27.1`,
+which is the version this instance reports (`GET /api/v1/version` -> `1.27.1`, 2026-09-02). That is a
+strong claim about the code and a weaker one about the deployment: a patched or vendored binary
+reporting `1.27.1` would satisfy the version read and not the source read. Nothing suggests one here,
+and the third item — the live 403, whose error string enumerates the required scope — is what ties the
+source to this instance. Cite the trio together rather than the source pair alone.
**Surviving routes — this record is not a mitigation for any of them.** `RENOVATE_TOKEN` is a
`write:repository` bot PAT in the same store and still cannot be scoped down, because Renovate needs
diff --git a/docs/decisions/records/ci/batch-pushes-no-cancel-route.md b/docs/decisions/records/ci/batch-pushes-no-cancel-route.md
index d0a8b3030..0717823e9 100644
--- a/docs/decisions/records/ci/batch-pushes-no-cancel-route.md
+++ b/docs/decisions/records/ci/batch-pushes-no-cancel-route.md
@@ -1,12 +1,12 @@
---
key: ci.batch-pushes-no-cancel-route
-title: '2026-07-21 — Batch your pushes: there is no agent-side cancel route on Gitea 1.25.4; REST route re-probed on 1.27.1 (#542)'
+title: '2026-07-21 — Batch your pushes: no agent-side cancel route; REST absence confirmed in the v1.27.1 router source, web UI still 1.25.4-dated (#542, #869)'
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: Hold review fixes, doc corrections and format fixes locally and push **once** — a superseded run cannot be cancelled from the agent side and holds a runner slot until it finishes.
-signals: 'cancel run 404 · Gitea 1.25.4 · Gitea 1.27.1 · `POST /api/v1/.../actions/runs/{id}/cancel` · MCP `actions_run_write` `cancel_run` · session+CSRF · `dispatch_workflow` · orphaned run · 4-slot runner · paths: n/a · issues: #542'
+signals: 'cancel run 404 · Gitea 1.25.4 · Gitea 1.27.1 · `POST /api/v1/.../actions/runs/{id}/cancel` · MCP `actions_run_write` `cancel_run` · session+CSRF · `routers/api/v1/api.go` · `dispatch_workflow` · orphaned run · 4-slot runner · paths: n/a · issues: #542, #869'
mechanics: Gitea Actions REST + MCP `actions_run_write`; operator-only cancel in the browser UI.
---
@@ -18,7 +18,9 @@ session cookie).
**Re-probed on 1.27.1, 2026-08-28 (#747) — the REST route only.** `POST .../cancel` still returns
**404**, and the live swagger shows the absence is structural rather than one 404: the only POST routes
under `actions/runs/{run}` are `rerun`, `rerun-failed-jobs` and `jobs/{job_id}/rerun`. The MCP
-`cancel_run` and web-UI/CSRF findings above were **not** re-run and stay 1.25.4-dated. Swagger also
+`cancel_run` and web-UI findings above were **not** re-run and stay 1.25.4-dated. What is said about
+each at the end of this record differs, deliberately: the MCP one has a stated but SESSION-LOCAL
+reason (#869), and the web-UI one has none beyond being unchanged in kind. Swagger also
lists `DELETE /actions/runs/{run}` (delete a run record); it was not probed — do not assume it cancels
a run or frees a runner slot. **Only the operator can cancel, in the browser** — so if you must supersede a live
run, say so explicitly instead of leaving it burning. (`dispatch_workflow` is a different route and
@@ -26,3 +28,34 @@ still works for re-triggering a **main** run.)
This corrects the older "superseded runs drain on their own" framing: they do finish, but they hold
one of the 4 runner slots while doing it, which is a real cost with several parallel sessions.
+
+**Confirmed from SOURCE at tag `v1.27.1`, 2026-09-02 (#869), which upgrades what the claim rests on.**
+`routers/api/v1/api.go` registers the entire `/repos/{o}/{r}/actions/runs/{run}` group and it holds no
+cancel route in any method: `GET ""`, `GET /attempts/{attempt}`, `GET /attempts/{attempt}/jobs`,
+`DELETE ""` (`repo.DeleteActionRun`), `POST /rerun`, `POST /rerun-failed-jobs`, `GET /jobs`,
+`POST /jobs/{job_id}/rerun`, `GET /artifacts`. The only occurrence of `cancel` in that entire router is
+`repo.CancelScheduledAutoMerge`, an auto-merge route and unrelated. Reading the source is what draws a
+distinction a status code cannot: a 404 is equally consistent with an absent route, a mistyped path and
+a permission refusal, and only the first justifies "there is no route".
+
+**Scope that read honestly: it covers the REST API surface and nothing else.** `routers/api/v1/api.go`
+cannot speak for the web UI's own routes, for the runner protocol, or for anything an agent driving an
+authenticated browser session could reach. "Operator-only in the browser" therefore still rests on the
+1.25.4 web-UI finding, not on this source read.
+
+**Why the other two stay dated (#869, 2026-09-02) — and the honest reason, which is not the tidy one.**
+The MCP `cancel_run` tool was NOT re-invoked this session because the agent harness refused the call.
+That is a session-local constraint, not a property of this repo, and it is recorded as such rather
+than dressed up as an impossibility.
+
+An earlier draft of this paragraph did dress it up — it claimed there was "no disposable run to spend"
+because nothing triggers on a non-`main` push. **That reason is false and cold review caught it.**
+Four of the six workflows carry `workflow_dispatch:`, and `dependency-scan.yml` is read-only while
+`renovate.yml` defaults its dispatch to `dryRun: full` (log only, no PRs), so either raises a
+disposable run on demand. A scratch PR raises runs too, which is how #747 and #748 probed. And the
+premise does not even need a disposable run: invoking `cancel_run` against an already-FINISHED run id
+costs nothing, and that is the right re-probe whenever a session can make the call.
+
+What IS established: the REST surface carries no cancel route at v1.27.1 (above), which explains the
+tool's 404 mechanistically — but that is an inference about a wrapper whose own source was not read
+here, so it is a strong explanation and not a measurement. The web-UI finding is unchanged in kind.
diff --git a/docs/decisions/records/ci/exemption-provenance.md b/docs/decisions/records/ci/exemption-provenance.md
index 92c5e64ad..2a8f759e1 100644
--- a/docs/decisions/records/ci/exemption-provenance.md
+++ b/docs/decisions/records/ci/exemption-provenance.md
@@ -23,9 +23,14 @@ behind a green required check. Closed unmerged, branches deleted, no forged `h10
**Why a base BINDING and not a pinned diff.** Diffing two immutable shas would close it outright;
Gitea cannot serve that: `compare/{base}...{head}` returns an object whose only keys are `commits`
and `total_commits`, no `files` — measured on 1.25.4 and **re-confirmed on 1.27.1, 2026-08-28 (#747)**,
-probed on `b16ec15d6c1e...9b4844c1a8a9`, 5 commits. Separately, and **not re-probed since 1.25.4**, a
-`--depth=1` fetch of the two shas has no merge base, so three-dot is impossible and two-dot
-over-reports everything `main` gained since the branch point. So the base is read before the first page
+probed on `b16ec15d6c1e...9b4844c1a8a9`, 5 commits. Separately, a `--depth=1` fetch of the two shas has
+no merge base, so three-dot is impossible and two-dot over-reports everything `main` gained since the
+branch point. **Re-probed 2026-09-02 (#869), and the re-probe corrected how it is FILED: this is a GIT
+property, not a Gitea one, so it was never 1.25.4-dated in the first place.** Carrying a Gitea version
+stamp on it invited a re-run against the wrong axis — a Gitea upgrade cannot change it, and a git
+upgrade can. Measured on git 2.55.0 in a disposable local repo: after `git fetch --depth=1 origin
+