HOMELAB-2524: fix(forgejo): enforce the base branch's named required CI contexts #91

Merged
pi-landing merged 3 commits from plane/HOMELAB-2524-ci-contexts into main 2026-11-04 13:57:08 +00:00
Owner

The incident (GITS-368, epic HOMELAB-2518)

On Homelab/ghostty-in-the-shell#37 the head's combined commit status read success — five green contexts — while two required emu contexts were absent from the head tree entirely. Leg 1 decided on input.ci.state === "success" and used the contexts only to report which ones were non-green. An absent context is not a red context: the combined status cannot see a context that never ran, so an incomplete head was evaluated as green.

The fix

Leg 1 now also reads the PR base branch's branch-protection rule and enforces its status_check_contexts by name on the exact head.

  • forgejo/api.ts — listBranchProtections now carries status_check_contexts and enable_status_check; new readRequiredCiContexts() wrapper returns either {read: "rules", rules} or {read: "unreadable", reason}. A 200 with a non-array body now throws instead of reading as "no rules". All GETs; branch protection is never written (G6).
  • forgejo/landing.ts — new pure assessRequiredContexts() / ruleCoversBranch(), LandingLockInput.requiredContexts, and the leg-1 check. Rule selection matches base.ref exactly, plus a * pattern rule (release/* governs release/2.0) so a patterned rule cannot silently stop covering a branch. Another branch's rule is never borrowed — there is no "the only rule there is" fallback.
  • forgejo/index.ts — collectLandingEvidence reads the base ref and the protection list once, shared by the merge gate and the census; both evaluateLandingLock call sites pass it through.
  • docs/forgejo.md — leg 1 documents the named-context enforcement and why the combined status alone is insufficient.

The four cases

  1. Enforcing rule (enable_status_check: true) → every named context must be present on the head. Missing ⇒ refuse, naming each one.
  2. Rule with status checks off (enable_status_check: false, contexts null) → no named requirement. This is the live shape of ghostty-in-the-shell/main.
  3. No rule for the base branch → no named requirement; today's combined-status behaviour is byte-identical. Live shape: Homelab/pi-extensions.
  4. Unreadable list → no named requirement, reported loudly (below).

Present-but-red keeps the existing non-green refusal wording (CI is not green on the head commit … non-green contexts: …); the absent path never fires for it, so there is one refusal per fact and no duplicated message.

Why unreadable does not refuse — WITHDRAWN in round 2 (see "Round 2" above: B1)

The API does let us separate "readable, no rule" (200 []) from "unreadable" (any throw), and this PR keeps that distinction in the types, the record and the note — an unreadable read is never reported as an unprotected repo, and no context name is ever fabricated.

But a failed GET cannot say which failure it was (403 vs 404 vs 5xx vs a transient network error), so neither "the branch is protected" nor "the branch is unprotected" is established. The lock has no override, so refusing there would turn an infrastructure read failure into a permanent, unappealable merge blocker on every repo whose protections list the session's credential cannot read — while adding nothing that leg 1's combined-status check does not already cover. Chosen behaviour: skip the named check, record and say the gap in the landing note (… could not be read … were NOT enforced), and leave the combined-status refusal untouched — a red head is still refused. This is pinned by two tests. The ticket's stricter reading (refuse when the caller cannot establish that the base has no rule) was rejected for that wedge reason; since pi-extensions answers 200 [] live, either choice leaves this repo unblocked, but the stricter one would regress the whole existing gate suite and block every landing behind an unreadable endpoint.

Round 2 — the review ruling, implemented (commit c9eaa10)

One reviewer approved round 1 and one requested changes; the orchestrator ruled for the change request and against the approval's reasoning. Every finding is addressed below. The wedge argument in round 1's "Why unreadable does not refuse" section is WITHDRAWN — see B1.

B1 — an unreadable protection read now REFUSES.
Round 1's defence ("refusing would wedge every repo whose protection we cannot read") was measured with the wrong identity: the operator token (FORGEJO_API_KEY, login aaron, is_admin: false), for which 7 jesus/*-style repos answer 403 — repos aaron cannot write. The lock's reads run with the landing credential, and pi-landing has write on every repo it can land in (ghostty-in-the-shell, infra-core, lab-director, prod, pi-extensions), and is absent from the two Sendrast/* repos where it is also outside merge_whitelist. GET /branch_protections needs writer, not admin (jesus/Transponder: push: true, admin: false reads 200). The wedge set is empty, so the refusal is affordable. The refusal names the base branch, says the named set is UNKNOWN, not empty, and gives both remedies (retry; or a human merges from the Forgejo UI).

F1 — an absent/unparseable base.ref refuses. A rule cannot be selected without a branch, and "no rule" is not a claim that can be made about a branch nothing named. Round 1 reported no branch-protection rule for "(unknown base)" and permitted — and in one probe went as far as issuing a merge POST.

F2 — the queue shows it. The census row carries requiredCi and prints it as REQUIRED-CI … on every row, refused ones included: 7/7 on "live", 5/7 ABSENT …, or UNKNOWN — … could not be read. LandingLockResult.requiredContexts carries the assessment on every result (permit, leg-1 refusal, and refusals from later legs).

F3 — rule selection. An exact-name rule now wins over a glob that also matches (Forgejo's own order), so a pattern cannot steal the decision from the branch's own rule. The glob model implements */** only; a pattern using ?, [...] or {...} reports the model's limit and refuses, so it can never read as "no requirement".

F4 — the helper's preview. forgejo/verdict.ts's lock preview now carries requiredContexts, and the field is required on LandingLockInput and on LockState — an omitted field used to mean "the named-context check does not run", which is exactly how the preview went silent on an absent-context refusal. verdict-cli reads the base branch's protection for that state (a GET) and the report prints required-ci: ….

F5 — tests. Malformed bodies (200 with a non-array body ⇒ refused, never "no rules") and the empty-status_check_contexts rule (enforcing, names nothing ⇒ permit, and the note says so) are both covered.

Round-1 gains kept: the absent-context refusal naming every missing context, the 7/7 permit, the present-but-red refusal in its pre-existing wording, base-branch selection that never borrows another branch's rule, the 200 [] no-requirement path byte-identical, and listBranchProtections throwing on a malformed body. Legs 2/3/4/5, the session panel, the legacy owner panel and hold withdrawal are untouched; the only forge call this feature adds is still a GET (G6).

Red → green: working-files/homelab-2518/HOMELAB-2524-red2.log (15 failed | 48 passed across the two touched files, at round-1 head 17c2a5f) and …-green2.log (tsc --noEmit clean, 21 files / 601 tests passed; round 1 was 21 / 587).

Round 2 refusal — an unreadable protection read

Refusing to merge Homelab/pi-extensions#79 (head c7a74c7): the required CI contexts on base branch "main" are UNKNOWN, not empty — the branch protection could not be read.
branch protection on "main" could not be read (Forgejo API GET repos/Homelab/pi-extensions/branch_protections: 404): the named required CI contexts are UNKNOWN, not empty.
The combined commit status is "success" (source: combined), and a combined status cannot see a required context that never ran (GITS-368). "I could not read the rule" is not "there is no rule", so leg 1 cannot establish that this head is complete.
This read runs with the landing credential, which has write on every repo it can land in, and GET /branch_protections needs writer (not admin) — so a failed protection read is a fault to fix, not an entitlement gap the lock should assume away.
Remedies: retry the merge (a transient read failure clears on its own); or, as a human, merge from the Forgejo UI after checking the required contexts on base branch "main" by hand. The lock has no override.

Round 2 refusal — unknown base branch

Refusing to merge Homelab/pi-extensions#79 (head c7a74c7): the PR's base branch could not be determined, so the branch-protection rule that governs this merge is UNKNOWN, not absent — and the named required CI contexts are UNKNOWN, not empty.
A rule cannot be selected without a base branch, and "no rule" is not a claim the lock can make about a branch nothing named. <combined-status sentence>
Remedies: re-read the PR (base.ref) and retry; or, as a human, merge from the Forgejo UI after checking the target branch's required contexts by hand. The lock has no override.

Round 2 refusal — a pattern the lock cannot evaluate

Refusing to merge Homelab/pi-extensions#79 (head c7a74c7): branch-protection pattern(s) "li?e" may govern "live", but this lock's glob model cannot evaluate them, so the named required CI contexts are UNKNOWN, not empty.
The model implements `*`/`**` only (any run of `*` = any characters). `?`, `[...]` and `{...}` are real Forgejo glob syntax, and answering "no requirement" for a pattern the lock cannot read would be a guess in the unsafe direction. <combined-status sentence>
Remedies: give the base branch an exact-name rule (the lock prefers one over any pattern); or, as a human, merge from the Forgejo UI after checking "live" required contexts by hand. The lock has no override.

Round 2 behaviour notes worth a reviewer's attention

  • The two new refusals sit after leg 1's combined-status check, so a red head still refuses with the wording it has always used (CI is not green on the head commit …), and a refusal is only ever added to a head that would otherwise have passed.
  • LockState.requiredContexts being required forced every fixture in the suite to declare what it read. Fixtures that are not about branch protection now use an exported NO_PROTECTION ({ baseRef: "main", read: "rules", rules: [] }) — the pi-extensions shape — rather than an omission.
  • One HOMELAB-2578 test (still refuses when the whitelist cannot be read) can no longer be reached with a protections GET that never worked, because the lock now refuses first. It is driven by a transient failure instead (branchProtectionsFailAfter: 1) so the whitelist could not be read wording keeps its coverage, and the reason is written beside the fixture.

Red → green evidence

Logs: working-files/homelab-2518/HOMELAB-2524-red.log and …-green.log.

Red (test file against unmodified origin/main 72be4ec): 8 failed | 3 passed. Failing assertions, verbatim:

× a required context absent from the head > refuses when the combined status is success but required contexts never ran
  → expected the landing lock to refuse: expected null not to be null
× a required context absent from the head > names one context when exactly one is missing
  → expected the landing lock to refuse: expected null not to be null
× every required context present and green > permits the merge and says the named requirement was satisfied
  → expected 'PR #79 merge requested, but the PR do…' to match /required CI contexts? on "live"/
× the PR base branch selects the rule > enforces the rule of the branch the PR targets, not another branch's
  → expected the landing lock to refuse: expected null not to be null
× the PR base branch selects the rule > applies no named requirement when the base branch's rule has status checks off
  → expected … to match /no named required CI contexts?/
× a repository with no branch protection > permits on combined success and says the combined status decided
  → expected … to match /no named required CI contexts?/
× a repository with no branch protection > reads protection with a GET and never writes it (G6)
  → expected [] to have a length of 1 but got +0
× the protection read fails > permits but records that the named requirement was NOT enforced
  → expected … to match /branch protection could not be read/i

The two "refuse" lines are the ticket's red case: on main the merge was allowed with a required context absent.

The 3 that passed on main are the deliberate no-regression guards: present-but-red keeps the existing wording, and a non-green head still refuses with no rule and with an unreadable read. The 6 pure-selector tests came with the implementation (they import symbols that did not exist on main and would fail as import errors, not assertions).

Green: npx tsc --noEmit clean; npx vitest run → 21 files / 587 tests passed (baseline on main: 20 / 570, so 0 regressions, +17 new).

Exact refusal for a missing context

Refusing to merge Homelab/pi-extensions#79 (head c7a74c7): required CI context(s) absent from the head: emu / emu (pull_request), emu / battery (pull_request).
The combined commit status is "success" (source: combined), and a combined status cannot see a context that never ran: the branch-protection rule on "live" requires 7 status-check context(s) and 2 of them are absent from the head's status set (GITS-368).
  required (7): ci / pr-title (pull_request), ci / check (pull_request), ci / scripts (pull_request), ci / assemble (pull_request), secret-probe / secret-probe (pull_request), emu / emu (pull_request), emu / battery (pull_request)
  present on the head (5): ci / pr-title (pull_request), ci / check (pull_request), ci / scripts (pull_request), ci / assemble (pull_request), secret-probe / secret-probe (pull_request)
Push so the missing job(s) run on the head sha and retry — the lock reads the head sha only.

Live protection facts (read-only GETs, this session)

GET /api/v1/repos/{owner}/{repo}/branch_protections:

repo branch answer enable_status_check status_check_contexts
Homelab/pi-extensions — 200 [] — — (no rule at all)
Homelab/ghostty-in-the-shell live 200 true 7: ci / pr-title (pull_request), ci / check (pull_request), ci / scripts (pull_request), ci / assemble (pull_request), secret-probe / secret-probe (pull_request), emu / emu (pull_request), emu / battery (pull_request)
Homelab/ghostty-in-the-shell main 200 false null

A nonexistent repo answers 404. The fixture default in tests/fake.ts was changed from 404 to 200 [] so the fake matches the pi-extensions reality; branchProtections: null remains the explicit unreadable case.

Collision check

Open PRs on Homelab/pi-extensions at start of work: #68 only (plane/HOMELAB-2321-poll-loop-guard), diff pi-swarm-suite/src/core/index.ts — does not touch forgejo/landing.ts, forgejo/api.ts, forgejo/index.ts or docs/forgejo.md. No collision.

Scope

Untouched: legs 2/3/4/5, the dangerous-class session panel, the owner-scoped legacy panel, hold withdrawal, and the existing combined-status refusal. No branch protection was modified; no Plane ticket state changed; no merge, no review, no verdict, no deployment performed by this worker.

Branch plane/HOMELAB-2524-ci-contexts, commit 17c2a5f009c9ffaa77bf91b3f4262480144270c7 (model: launched pinned to omniroute/deepseek-flash).

Round 3 — the second review's blockers (commit fa52db9)

One reviewer approved round 2, one requested changes. Both blockers and all five non-blocking findings are addressed in a single commit on the same branch (fast-forward push, no rebase, no force). The two refusals from round 2 are unchanged in behaviour — only the text changed.

BLOCKER H2524-B3 — the verdict helper no longer aborts on a 403

verdict-cli read the base branch's protection for its lock preview and re-threw AuthRefused, so a 401/403 exited 2 before composing:

GET repos/Homelab/pi-extensions/branch_protections: 403 Forbidden — authentication refused, stopping   (exit 2)

Deployed, that is fatal: GET /branch_protections requires repository admin, and ld-reviewer is not an admin of pi-extensions or ghostty-in-the-shell — so the fleet's only reviewer identity could post no verdict at all. No verdict, no landing.

Mechanism. readRequiredContexts() no longer has an AuthRefused re-throw at all. Every outcome of that read — 401/403, 404/5xx, a non-array body, a rule that names no branch, or a timeout — is classified read: "unreadable" and returned. The helper then composes and posts exactly as it does on a repo with a readable rule, and the unknown is surfaced:

  • required-ci: branch protection on "main" could not be read (… 403 …): the named required CI contexts are UNKNOWN, not empty
  • warning: the landing lock would still refuse after this comment: … UNKNOWN, not empty …

The merge gate still refuses on an unreadable read (round 2, B1). Only the preview degrades.

End-to-end proof (fixture, reviewer token path, no real review posted). tests/verdict-694-replay.test.ts, FakeOpts.protectionsHttp: 403, run through runCli with --token-file - (the reviewer token FILE path — the token arrives on stdin, never argv) and --post:

  • exactly one POST, and it is the formal review (## Verdict: APPROVED, commit_id = the head);
  • the protection GET did happen, carrying that reviewer token;
  • required-ci: … UNKNOWN, not empty in the report lines, the lock-preview warning present, lock.requiredContexts.unknown === "protection-unreadable";
  • exit 0, and authentication refused appears nowhere on stderr.

A companion test pins the other half: with the PR read itself refused (denyAuth), the helper still exits 2 and posts nothing — only the protection read degrades.

BLOCKER H2524-B2 — the refusal now states only what was measured

Round 2 claimed the read "runs with the landing credential, which has write on every repo it can land in, and GET /branch_protections needs writer (not admin)". Both halves were wrong: the read uses FORGEJO_API_KEY (the session's Forgejo credential), and the endpoint requires admin — jesus/Transponder answers 403, not 200. That paragraph is gone.

Reads I made this session to justify the new text (read-only GETs, no writes, nothing printed from the credential):

read answer
GET /api/v1/user 200, login: aaron, is_admin: false
GET repos/Homelab/pi-extensions/branch_protections 200 []
GET repos/Homelab/ghostty-in-the-shell/branch_protections 200 (rule live, 7 contexts)
GET repos/Homelab/infra-core/branch_protections 200
GET repos/Homelab/lab-director/branch_protections 200
GET repos/Homelab/prod/branch_protections 200
GET repos/jesus/Transponder/branch_protections 403 — user should be an owner or a collaborator with admin write of a repository

The new rationale (verbatim, from the refusal):

Refusing to merge <repo>#<n> (head <sha>): the required CI contexts on base branch "main" are UNKNOWN, not empty — the branch protection could not be read.
branch protection on "main" could not be read (Forgejo API GET repos/<owner>/<repo>/branch_protections: 403 Forbidden): the named required CI contexts are UNKNOWN, not empty.
The combined commit status is "success" (source: combined), and a combined status cannot see a required context that never ran (GITS-368). "I could not read the rule" is not "there is no rule", so leg 1 cannot establish that this head is complete.
The read that failed is a GET of repos/{owner}/{repo}/branch_protections with this session's Forgejo credential (FORGEJO_API_KEY). Measured (2026-11-04): that credential, login "aaron", is_admin: false, reads this endpoint 200 on the repos this lock lands in — Homelab/pi-extensions (200 []), Homelab/ghostty-in-the-shell, Homelab/infra-core, Homelab/lab-director, Homelab/prod — so a failed read here is a fault to retry, not a standing entitlement gap the lock should assume away.
The endpoint requires repository ADMIN, so a credential without it answers 403 and the named set stays UNKNOWN — never "nothing required".
Remedies: retry the merge (a transient read failure clears on its own); or, as a human, merge from the Forgejo UI after checking the required contexts on base branch "main" by hand. The lock has no override.

The words landing credential, needs writer and has write on every repo are gone, and no identity other than the one I measured is named. A test asserts exactly that (not.toMatch(/landing credential/), /needs writer/, /pi-landing/, /ld-reviewer/, /has write on every repo/) and that the measured facts are present.

Non-blocking findings

  • N1 — requiredContexts really is on every result now. It was documented "on every result" but carried only on the leg-1 refusals, so the census segment vanished on rows that refused earlier (red/pending head) or later (no verdict yet). The field is now required on LandingLockResult and every return path carries it — pending-CI, no-status, non-green, no head sha, undateable head, no-verdict, unreadable-file-list and dangerous-class refusals included (plus their record). Pinned by tests, including a census row that refused on a red head and still prints REQUIRED-CI UNKNOWN — ….
  • N2 — the base ref is base.ref, never a display label. New baseRefFromPR(): the ref wins when present; a label is accepted only when it already is a branch name (no owner: qualifier). Homelab:main — the Gitea label shape — now answers "", so F1's absent-base-ref refusal fires instead of "no rule matched ⇒ permit". Used by both the merge gate (index.ts) and the helper; the merge-level and CLI-level tests are added.
  • N3 — 200 [{}] is malformed, not "no rules". A rule object that names no branch (or a non-object entry) is treated exactly like the non-array body: listBranchProtections throws, the helper returns unreadable, and the lock refuses. 200 [] still permits, byte-identical (pinned by its own test).
  • N4 — fail closed at runtime, not only at compile time. assessRequiredContexts no longer returns undefined for a missing read. A dropped field, a read value that is neither rules nor unreadable, or a rules field that is not a list is assessed as UNKNOWN ("the lock was called without a branch-protection read") and refuses. The old test that asserted the no-op (toBeUndefined()) is replaced by one that asserts the refusal; a further test feeds null/strings/garbage and requires a refusal without a crash.
  • N5 — bounded reads. request() (extension) and apiCall() (helper) now bound each request with AbortSignal.timeout(FORGEJO_REQUEST_TIMEOUT_MS), default 30 s. A hung protection GET therefore becomes a failed read ⇒ unreadable ⇒ refusal, not a hang. In the extension the bound is applied to reads only (GET/HEAD): a write that times out has an ambiguous outcome — it may have landed — and aborting a merge POST would turn "I do not know" into "it failed", which is exactly what the merge read-back exists to prevent (HOMELAB-2526). In the helper it applies to every call, because the write path already classifies an in-flight failure as "the review MAY exist; reconcile by id" (HOMELAB-2566). Proved by two tests that stub a fetch which never settles: with the bound the lock refuses and the helper composes, in ~60 ms.

Round-2 behaviour kept

B1's refusal, F1/F3's fail-closed shapes, exact-name-beats-glob, the red-head ordering (CI is not green on the head commit … still speaks first, and its wording is unchanged), 200 [] still permitting, and no protection write anywhere — the feature adds one GET and nothing else (G6). Legs 2–5, the session panel, the legacy owner panel and hold withdrawal are untouched.

Red → green

Logs: working-files/homelab-2518/HOMELAB-2524-red3.log and …-green3.log.

  • Red (tests written first, against round-2 head c9eaa10): 19 failed | 45 passed across the two touched files — including the B3 test exiting 2 with authentication refused, and both N5 tests hitting their 5 s timeout because the read had no bound at all.
  • Green: tsc --noEmit clean; npx vitest run → 21 files / 621 tests passed (round 2 was 21/601, so +20 tests, 0 regressions). The same suite in the deployed shape (scripts/test-in-deployed-shape.sh, npm ci from the package lock): 21/21 files loaded and passed, 621/621 tests.
  • The helper also runs in the deployed shape against the live API (dry run, nothing posted): it composed the body, printed required-ci: no branch-protection rule for "main" …, and exited 0.

Scope

No merge, no review, no verdict and no deployment were performed by this worker; no comment was edited; no branch protection was read beyond the GETs listed above, and none was written. Branch plane/HOMELAB-2524-ci-contexts, head fa52db9 (model: launched pinned to omniroute/deepseek-flash).

## The incident (GITS-368, epic HOMELAB-2518) On `Homelab/ghostty-in-the-shell#37` the head's **combined commit status** read `success` — five green contexts — while **two required `emu` contexts were absent from the head tree entirely**. Leg 1 decided on `input.ci.state === "success"` and used the contexts only to *report* which ones were non-green. An absent context is not a red context: the combined status cannot see a context that never ran, so an incomplete head was evaluated as green. ## The fix Leg 1 now also reads the PR **base branch's** branch-protection rule and enforces its `status_check_contexts` **by name on the exact head**. - `forgejo/api.ts` — `listBranchProtections` now carries `status_check_contexts` and `enable_status_check`; new `readRequiredCiContexts()` wrapper returns either `{read: "rules", rules}` or `{read: "unreadable", reason}`. A `200` with a non-array body now throws instead of reading as "no rules". All GETs; **branch protection is never written** (G6). - `forgejo/landing.ts` — new pure `assessRequiredContexts()` / `ruleCoversBranch()`, `LandingLockInput.requiredContexts`, and the leg-1 check. Rule selection matches `base.ref` exactly, plus a `*` pattern rule (`release/*` governs `release/2.0`) so a patterned rule cannot silently stop covering a branch. **Another branch's rule is never borrowed** — there is no "the only rule there is" fallback. - `forgejo/index.ts` — `collectLandingEvidence` reads the base ref and the protection list once, shared by the merge gate and the census; both `evaluateLandingLock` call sites pass it through. - `docs/forgejo.md` — leg 1 documents the named-context enforcement and why the combined status alone is insufficient. ### The four cases 1. **Enforcing rule** (`enable_status_check: true`) → every named context must be present on the head. Missing ⇒ refuse, naming **each** one. 2. **Rule with status checks off** (`enable_status_check: false`, contexts `null`) → no named requirement. This is the live shape of `ghostty-in-the-shell/main`. 3. **No rule for the base branch** → no named requirement; today's combined-status behaviour is byte-identical. Live shape: `Homelab/pi-extensions`. 4. **Unreadable list** → no named requirement, reported loudly (below). Present-but-red keeps the **existing** non-green refusal wording (`CI is not green on the head commit … non-green contexts: …`); the absent path never fires for it, so there is one refusal per fact and no duplicated message. ### ~~Why unreadable does not refuse~~ — WITHDRAWN in round 2 (see "Round 2" above: B1) The API *does* let us separate "readable, no rule" (`200 []`) from "unreadable" (any throw), and this PR keeps that distinction in the types, the record and the note — an unreadable read is never reported as an unprotected repo, and no context name is ever fabricated. But a failed GET cannot say *which* failure it was (403 vs 404 vs 5xx vs a transient network error), so **neither** "the branch is protected" **nor** "the branch is unprotected" is established. The lock has no override, so refusing there would turn an infrastructure read failure into a permanent, unappealable merge blocker on every repo whose protections list the session's credential cannot read — while adding nothing that leg 1's combined-status check does not already cover. **Chosen behaviour: skip the named check, record and say the gap in the landing note (`… could not be read … were NOT enforced`), and leave the combined-status refusal untouched** — a red head is still refused. This is pinned by two tests. The ticket's stricter reading (refuse when the caller cannot establish that the base has no rule) was rejected for that wedge reason; since `pi-extensions` answers `200 []` live, either choice leaves *this* repo unblocked, but the stricter one would regress the whole existing gate suite and block every landing behind an unreadable endpoint. ## Round 2 — the review ruling, implemented (commit `c9eaa10`) One reviewer approved round 1 and one requested changes; the orchestrator ruled for the change request **and against the approval's reasoning**. Every finding is addressed below. **The wedge argument in round 1's "Why unreadable does not refuse" section is WITHDRAWN** — see B1. **B1 — an unreadable protection read now REFUSES.** Round 1's defence ("refusing would wedge every repo whose protection we cannot read") was measured with the **wrong identity**: the operator token (`FORGEJO_API_KEY`, login `aaron`, `is_admin: false`), for which 7 `jesus/*`-style repos answer 403 — repos aaron cannot write. The lock's reads run with the **landing** credential, and `pi-landing` has `write` on every repo it can land in (`ghostty-in-the-shell`, `infra-core`, `lab-director`, `prod`, `pi-extensions`), and is absent from the two `Sendrast/*` repos where it is also outside `merge_whitelist`. `GET /branch_protections` needs **writer, not admin** (`jesus/Transponder`: `push: true, admin: false` reads 200). The wedge set is empty, so the refusal is affordable. The refusal names the **base branch**, says the named set is **UNKNOWN, not empty**, and gives both remedies (retry; or a human merges from the Forgejo UI). **F1 — an absent/unparseable `base.ref` refuses.** A rule cannot be selected without a branch, and "no rule" is not a claim that can be made about a branch nothing named. Round 1 reported `no branch-protection rule for "(unknown base)"` and permitted — and in one probe went as far as issuing a merge POST. **F2 — the queue shows it.** The census row carries `requiredCi` and prints it as `REQUIRED-CI …` on **every** row, refused ones included: `7/7 on "live"`, `5/7 ABSENT …`, or `UNKNOWN — … could not be read`. `LandingLockResult.requiredContexts` carries the assessment on every result (permit, leg-1 refusal, and refusals from later legs). **F3 — rule selection.** An **exact-name rule now wins** over a glob that also matches (Forgejo's own order), so a pattern cannot steal the decision from the branch's own rule. The glob model implements `*`/`**` only; a pattern using `?`, `[...]` or `{...}` **reports the model's limit and refuses**, so it can never read as "no requirement". **F4 — the helper's preview.** `forgejo/verdict.ts`'s lock preview now carries `requiredContexts`, and the field is **required** on `LandingLockInput` and on `LockState` — an omitted field used to mean "the named-context check does not run", which is exactly how the preview went silent on an absent-context refusal. `verdict-cli` reads the base branch's protection for that state (a GET) and the report prints `required-ci: …`. **F5 — tests.** Malformed bodies (200 with a non-array body ⇒ refused, never "no rules") and the empty-`status_check_contexts` rule (enforcing, names nothing ⇒ permit, and the note says so) are both covered. Round-1 gains kept: the absent-context refusal naming **every** missing context, the 7/7 permit, the present-but-red refusal in its **pre-existing** wording, base-branch selection that never borrows another branch's rule, the `200 []` no-requirement path **byte-identical**, and `listBranchProtections` throwing on a malformed body. Legs 2/3/4/5, the session panel, the legacy owner panel and hold withdrawal are untouched; the only forge call this feature adds is still a **GET** (G6). Red → green: `working-files/homelab-2518/HOMELAB-2524-red2.log` (**15 failed | 48 passed** across the two touched files, at round-1 head `17c2a5f`) and `…-green2.log` (`tsc --noEmit` clean, **21 files / 601 tests passed**; round 1 was 21 / 587). ### Round 2 refusal — an unreadable protection read ``` Refusing to merge Homelab/pi-extensions#79 (head c7a74c7): the required CI contexts on base branch "main" are UNKNOWN, not empty — the branch protection could not be read. branch protection on "main" could not be read (Forgejo API GET repos/Homelab/pi-extensions/branch_protections: 404): the named required CI contexts are UNKNOWN, not empty. The combined commit status is "success" (source: combined), and a combined status cannot see a required context that never ran (GITS-368). "I could not read the rule" is not "there is no rule", so leg 1 cannot establish that this head is complete. This read runs with the landing credential, which has write on every repo it can land in, and GET /branch_protections needs writer (not admin) — so a failed protection read is a fault to fix, not an entitlement gap the lock should assume away. Remedies: retry the merge (a transient read failure clears on its own); or, as a human, merge from the Forgejo UI after checking the required contexts on base branch "main" by hand. The lock has no override. ``` ### Round 2 refusal — unknown base branch ``` Refusing to merge Homelab/pi-extensions#79 (head c7a74c7): the PR's base branch could not be determined, so the branch-protection rule that governs this merge is UNKNOWN, not absent — and the named required CI contexts are UNKNOWN, not empty. A rule cannot be selected without a base branch, and "no rule" is not a claim the lock can make about a branch nothing named. <combined-status sentence> Remedies: re-read the PR (base.ref) and retry; or, as a human, merge from the Forgejo UI after checking the target branch's required contexts by hand. The lock has no override. ``` ### Round 2 refusal — a pattern the lock cannot evaluate ``` Refusing to merge Homelab/pi-extensions#79 (head c7a74c7): branch-protection pattern(s) "li?e" may govern "live", but this lock's glob model cannot evaluate them, so the named required CI contexts are UNKNOWN, not empty. The model implements `*`/`**` only (any run of `*` = any characters). `?`, `[...]` and `{...}` are real Forgejo glob syntax, and answering "no requirement" for a pattern the lock cannot read would be a guess in the unsafe direction. <combined-status sentence> Remedies: give the base branch an exact-name rule (the lock prefers one over any pattern); or, as a human, merge from the Forgejo UI after checking "live" required contexts by hand. The lock has no override. ``` ### Round 2 behaviour notes worth a reviewer's attention - The two new refusals sit **after** leg 1's combined-status check, so a red head still refuses with the wording it has always used (`CI is not green on the head commit …`), and a refusal is only ever **added** to a head that would otherwise have passed. - `LockState.requiredContexts` being required forced every fixture in the suite to declare what it read. Fixtures that are not about branch protection now use an exported `NO_PROTECTION` (`{ baseRef: "main", read: "rules", rules: [] }`) — the `pi-extensions` shape — rather than an omission. - One HOMELAB-2578 test (`still refuses when the whitelist cannot be read`) can no longer be reached with a protections GET that *never* worked, because the lock now refuses first. It is driven by a transient failure instead (`branchProtectionsFailAfter: 1`) so the `whitelist could not be read` wording keeps its coverage, and the reason is written beside the fixture. ## Red → green evidence Logs: `working-files/homelab-2518/HOMELAB-2524-red.log` and `…-green.log`. **Red** (test file against unmodified `origin/main` `72be4ec`): `8 failed | 3 passed`. Failing assertions, verbatim: ``` × a required context absent from the head > refuses when the combined status is success but required contexts never ran → expected the landing lock to refuse: expected null not to be null × a required context absent from the head > names one context when exactly one is missing → expected the landing lock to refuse: expected null not to be null × every required context present and green > permits the merge and says the named requirement was satisfied → expected 'PR #79 merge requested, but the PR do…' to match /required CI contexts? on "live"/ × the PR base branch selects the rule > enforces the rule of the branch the PR targets, not another branch's → expected the landing lock to refuse: expected null not to be null × the PR base branch selects the rule > applies no named requirement when the base branch's rule has status checks off → expected … to match /no named required CI contexts?/ × a repository with no branch protection > permits on combined success and says the combined status decided → expected … to match /no named required CI contexts?/ × a repository with no branch protection > reads protection with a GET and never writes it (G6) → expected [] to have a length of 1 but got +0 × the protection read fails > permits but records that the named requirement was NOT enforced → expected … to match /branch protection could not be read/i ``` The two "refuse" lines are the ticket's red case: on `main` the merge was **allowed** with a required context absent. The 3 that passed on `main` are the deliberate no-regression guards: present-but-red keeps the existing wording, and a non-green head still refuses with no rule and with an unreadable read. The 6 pure-selector tests came with the implementation (they import symbols that did not exist on `main` and would fail as import errors, not assertions). **Green**: `npx tsc --noEmit` clean; `npx vitest run` → **21 files / 587 tests passed** (baseline on `main`: 20 / 570, so 0 regressions, +17 new). ## Exact refusal for a missing context ``` Refusing to merge Homelab/pi-extensions#79 (head c7a74c7): required CI context(s) absent from the head: emu / emu (pull_request), emu / battery (pull_request). The combined commit status is "success" (source: combined), and a combined status cannot see a context that never ran: the branch-protection rule on "live" requires 7 status-check context(s) and 2 of them are absent from the head's status set (GITS-368). required (7): ci / pr-title (pull_request), ci / check (pull_request), ci / scripts (pull_request), ci / assemble (pull_request), secret-probe / secret-probe (pull_request), emu / emu (pull_request), emu / battery (pull_request) present on the head (5): ci / pr-title (pull_request), ci / check (pull_request), ci / scripts (pull_request), ci / assemble (pull_request), secret-probe / secret-probe (pull_request) Push so the missing job(s) run on the head sha and retry — the lock reads the head sha only. ``` ## Live protection facts (read-only GETs, this session) `GET /api/v1/repos/{owner}/{repo}/branch_protections`: | repo | branch | answer | `enable_status_check` | `status_check_contexts` | |---|---|---|---|---| | `Homelab/pi-extensions` | — | `200 []` | — | — (no rule at all) | | `Homelab/ghostty-in-the-shell` | `live` | `200` | `true` | 7: `ci / pr-title (pull_request)`, `ci / check (pull_request)`, `ci / scripts (pull_request)`, `ci / assemble (pull_request)`, `secret-probe / secret-probe (pull_request)`, `emu / emu (pull_request)`, `emu / battery (pull_request)` | | `Homelab/ghostty-in-the-shell` | `main` | `200` | `false` | `null` | A nonexistent repo answers `404`. The fixture default in `tests/fake.ts` was changed from `404` to `200 []` so the fake matches the `pi-extensions` reality; `branchProtections: null` remains the explicit unreadable case. ## Collision check Open PRs on `Homelab/pi-extensions` at start of work: **#68 only** (`plane/HOMELAB-2321-poll-loop-guard`), diff `pi-swarm-suite/src/core/index.ts` — does not touch `forgejo/landing.ts`, `forgejo/api.ts`, `forgejo/index.ts` or `docs/forgejo.md`. No collision. ## Scope Untouched: legs 2/3/4/5, the dangerous-class session panel, the owner-scoped legacy panel, hold withdrawal, and the existing combined-status refusal. No branch protection was modified; no Plane ticket state changed; no merge, no review, no verdict, no deployment performed by this worker. Branch `plane/HOMELAB-2524-ci-contexts`, commit `17c2a5f009c9ffaa77bf91b3f4262480144270c7` (model: launched pinned to `omniroute/deepseek-flash`). ## Round 3 — the second review's blockers (commit `fa52db9`) One reviewer approved round 2, one requested changes. Both blockers and all five non-blocking findings are addressed in a single commit on the same branch (fast-forward push, no rebase, no force). **The two refusals from round 2 are unchanged in behaviour** — only the text changed. ### BLOCKER H2524-B3 — the verdict helper no longer aborts on a 403 `verdict-cli` read the base branch's protection for its lock preview and re-threw `AuthRefused`, so a 401/403 exited **2 before composing**: ``` GET repos/Homelab/pi-extensions/branch_protections: 403 Forbidden — authentication refused, stopping (exit 2) ``` Deployed, that is fatal: `GET /branch_protections` requires repository **admin**, and `ld-reviewer` is not an admin of `pi-extensions` or `ghostty-in-the-shell` — so the fleet's **only reviewer identity could post no verdict at all**. No verdict, no landing. **Mechanism.** `readRequiredContexts()` no longer has an `AuthRefused` re-throw at all. Every outcome of that read — 401/403, 404/5xx, a non-array body, a rule that names no branch, or a timeout — is classified `read: "unreadable"` and returned. The helper then composes and posts exactly as it does on a repo with a readable rule, and the unknown is **surfaced**: - `required-ci: branch protection on "main" could not be read (… 403 …): the named required CI contexts are UNKNOWN, not empty` - `warning: the landing lock would still refuse after this comment: … UNKNOWN, not empty …` The **merge gate still refuses** on an unreadable read (round 2, B1). Only the *preview* degrades. **End-to-end proof (fixture, reviewer token path, no real review posted).** `tests/verdict-694-replay.test.ts`, `FakeOpts.protectionsHttp: 403`, run through `runCli` with `--token-file -` (the reviewer token FILE path — the token arrives on stdin, never argv) and `--post`: - exactly **one** POST, and it is the formal review (`## Verdict: APPROVED`, `commit_id` = the head); - the protection GET **did** happen, carrying that reviewer token; - `required-ci: … UNKNOWN, not empty` in the report lines, the lock-preview warning present, `lock.requiredContexts.unknown === "protection-unreadable"`; - exit **0**, and `authentication refused` appears nowhere on stderr. A companion test pins the other half: with the PR read itself refused (`denyAuth`), the helper still exits 2 and posts nothing — only the *protection* read degrades. ### BLOCKER H2524-B2 — the refusal now states only what was measured Round 2 claimed the read "runs with the landing credential, which has write on every repo it can land in, and GET /branch_protections needs writer (not admin)". Both halves were wrong: the read uses **`FORGEJO_API_KEY`** (the session's Forgejo credential), and the endpoint requires **admin** — `jesus/Transponder` answers **403**, not 200. That paragraph is gone. **Reads I made this session to justify the new text** (read-only GETs, no writes, nothing printed from the credential): | read | answer | |---|---| | `GET /api/v1/user` | `200`, `login: aaron`, `is_admin: false` | | `GET repos/Homelab/pi-extensions/branch_protections` | `200 []` | | `GET repos/Homelab/ghostty-in-the-shell/branch_protections` | `200` (rule `live`, 7 contexts) | | `GET repos/Homelab/infra-core/branch_protections` | `200` | | `GET repos/Homelab/lab-director/branch_protections` | `200` | | `GET repos/Homelab/prod/branch_protections` | `200` | | `GET repos/jesus/Transponder/branch_protections` | `403` — `user should be an owner or a collaborator with admin write of a repository` | The new rationale (verbatim, from the refusal): ``` Refusing to merge <repo>#<n> (head <sha>): the required CI contexts on base branch "main" are UNKNOWN, not empty — the branch protection could not be read. branch protection on "main" could not be read (Forgejo API GET repos/<owner>/<repo>/branch_protections: 403 Forbidden): the named required CI contexts are UNKNOWN, not empty. The combined commit status is "success" (source: combined), and a combined status cannot see a required context that never ran (GITS-368). "I could not read the rule" is not "there is no rule", so leg 1 cannot establish that this head is complete. The read that failed is a GET of repos/{owner}/{repo}/branch_protections with this session's Forgejo credential (FORGEJO_API_KEY). Measured (2026-11-04): that credential, login "aaron", is_admin: false, reads this endpoint 200 on the repos this lock lands in — Homelab/pi-extensions (200 []), Homelab/ghostty-in-the-shell, Homelab/infra-core, Homelab/lab-director, Homelab/prod — so a failed read here is a fault to retry, not a standing entitlement gap the lock should assume away. The endpoint requires repository ADMIN, so a credential without it answers 403 and the named set stays UNKNOWN — never "nothing required". Remedies: retry the merge (a transient read failure clears on its own); or, as a human, merge from the Forgejo UI after checking the required contexts on base branch "main" by hand. The lock has no override. ``` The words *landing credential*, *needs writer* and *has write on every repo* are gone, and no identity other than the one I measured is named. A test asserts exactly that (`not.toMatch(/landing credential/)`, `/needs writer/`, `/pi-landing/`, `/ld-reviewer/`, `/has write on every repo/`) **and** that the measured facts are present. ### Non-blocking findings - **N1 — `requiredContexts` really is on every result now.** It was documented "on every result" but carried only on the leg-1 refusals, so the census segment vanished on rows that refused earlier (red/pending head) or later (no verdict yet). The field is now **required** on `LandingLockResult` and every return path carries it — pending-CI, no-status, non-green, no head sha, undateable head, no-verdict, unreadable-file-list and dangerous-class refusals included (plus their `record`). Pinned by tests, including a census row that refused on a red head and still prints `REQUIRED-CI UNKNOWN — …`. - **N2 — the base ref is `base.ref`, never a display label.** New `baseRefFromPR()`: the ref wins when present; a label is accepted **only** when it already is a branch name (no `owner:` qualifier). `Homelab:main` — the Gitea label shape — now answers `""`, so F1's absent-base-ref refusal fires instead of "no rule matched ⇒ permit". Used by both the merge gate (`index.ts`) and the helper; the merge-level and CLI-level tests are added. - **N3 — `200 [{}]` is malformed, not "no rules".** A rule object that names no branch (or a non-object entry) is treated exactly like the non-array body: `listBranchProtections` throws, the helper returns `unreadable`, and the lock refuses. `200 []` still permits, byte-identical (pinned by its own test). - **N4 — fail closed at runtime, not only at compile time.** `assessRequiredContexts` no longer returns `undefined` for a missing read. A dropped field, a `read` value that is neither `rules` nor `unreadable`, or a `rules` field that is not a list is assessed as UNKNOWN ("the lock was called without a branch-protection read") and **refuses**. The old test that asserted the no-op (`toBeUndefined()`) is replaced by one that asserts the refusal; a further test feeds `null`/strings/garbage and requires a refusal without a crash. - **N5 — bounded reads.** `request()` (extension) and `apiCall()` (helper) now bound each request with `AbortSignal.timeout(FORGEJO_REQUEST_TIMEOUT_MS)`, default **30 s**. A hung protection GET therefore becomes a failed read ⇒ `unreadable` ⇒ **refusal**, not a hang. In the extension the bound is applied to **reads only** (`GET`/`HEAD`): a write that times out has an ambiguous outcome — it may have landed — and aborting a merge POST would turn "I do not know" into "it failed", which is exactly what the merge read-back exists to prevent (HOMELAB-2526). In the helper it applies to every call, because the write path already classifies an in-flight failure as "the review MAY exist; reconcile by id" (HOMELAB-2566). Proved by two tests that stub a fetch which never settles: with the bound the lock refuses and the helper composes, in ~60 ms. ### Round-2 behaviour kept B1's refusal, F1/F3's fail-closed shapes, exact-name-beats-glob, the red-head ordering (`CI is not green on the head commit …` still speaks first, and its wording is unchanged), `200 []` still permitting, and **no protection write anywhere** — the feature adds one `GET` and nothing else (G6). Legs 2–5, the session panel, the legacy owner panel and hold withdrawal are untouched. ### Red → green Logs: `working-files/homelab-2518/HOMELAB-2524-red3.log` and `…-green3.log`. - **Red** (tests written first, against round-2 head `c9eaa10`): `19 failed | 45 passed` across the two touched files — including the B3 test exiting **2** with `authentication refused`, and both N5 tests hitting their 5 s timeout because the read had no bound at all. - **Green**: `tsc --noEmit` clean; `npx vitest run` → **21 files / 621 tests passed** (round 2 was 21/601, so +20 tests, 0 regressions). The same suite in the **deployed shape** (`scripts/test-in-deployed-shape.sh`, `npm ci` from the package lock): `21/21 files loaded and passed, 621/621 tests`. - The helper also runs in the deployed shape against the live API (dry run, nothing posted): it composed the body, printed `required-ci: no branch-protection rule for "main" …`, and exited 0. ### Scope No merge, no review, no verdict and no deployment were performed by this worker; no comment was edited; no branch protection was read beyond the GETs listed above, and none was written. Branch `plane/HOMELAB-2524-ci-contexts`, head `fa52db9` (model: launched pinned to `omniroute/deepseek-flash`).
HOMELAB-2524: fix(forgejo): enforce the base branch's named required CI contexts
All checks were successful
CI / refocus (pull_request) Successful in 50s
CI / pr-title (pull_request) Successful in 1s
CI / pi-otel-telemetry (pull_request) Successful in 1m7s
CI / forgejo-ci (pull_request) Successful in 1m9s
CI / plane (pull_request) Successful in 1m19s
CI / mcp-bridge (pull_request) Successful in 17s
tests / python (pull_request) Successful in 5s
CI / nats-intercom (pull_request) Successful in 32s
CI / skill-args (pull_request) Successful in 53s
CI / ask-user-question (pull_request) Successful in 51s
CI / todo (pull_request) Successful in 56s
CI / redact (pull_request) Successful in 1m13s
CI / pi-friction (pull_request) Successful in 1m14s
CI / forgejo (pull_request) Successful in 1m50s
CI / pi-swarm-suite (pull_request) Successful in 4m33s
17c2a5f009
Leg 1 of the landing lock decided on the COMBINED commit status alone, with
the contexts used only to report which ones were non-green. That is not
sufficient evidence: on Homelab/ghostty-in-the-shell#37 (GITS-368) five green
contexts combined into `success` while TWO required `emu` contexts were
absent from the head tree entirely. An absent context is not a red context —
the combined status cannot see a context that never ran — so an incomplete
head was evaluated as green.

Leg 1 now also reads the PR base branch's branch-protection rule (a GET; the
lock reads protection, it never modifies it) and selects the rule matching
`base.ref`:

  - an ENFORCING rule (enable_status_check) requires each of its named
    contexts on the exact head — missing ones refuse as
    'required CI context(s) absent from the head: X, Y', naming every one;
  - a rule with status checks off names no requirement (live shape:
    ghostty-in-the-shell/main);
  - no rule for the base branch names no requirement and the combined status
    decides exactly as before (live shape: Homelab/pi-extensions, 200 []);
    another branch's rule is never borrowed;
  - an UNREADABLE protections list enforces no named requirement and says so
    in the record and the landing note: the lock has no override, and a
    refusal here would turn a read failure into a permanent merge blocker on
    every repo whose list the session cannot read. The combined-status check
    is unchanged, so a red head is still refused.

A context that is present but red keeps the existing non-green wording — one
fact, one refusal. Legs 2/3/4/5, the dangerous-class session panel, the
owner-scoped legacy panel and hold withdrawal are untouched.

Tests: forgejo/tests/landing-required-contexts.test.ts (route fakes, 17).
Baseline 570 -> 587 passing; tsc --noEmit clean.
ld-reviewer approved these changes 2026-11-04 09:41:55 +00:00
Dismissed
ld-reviewer left a comment
Collaborator

Verdict: APPROVED

model: omniroute/deepseek-flash
head: 17c2a5f009
session: 01a1a5e7-883d-7446-a658-25084ff9de9e

Independent seat 1. The completeness leg is real, the wording split is honest, and I reproduced GITS-368 through the shipped code with live data rather than through the PR's own tests. Recorded non-blocking findings below; none of them stops this landing.

Ruling on the unreadable-read behaviour: acceptable, non-blocking — and I reversed my own starting position after measuring. I began from fail-closed, because this same leg refuses when the commit-status read fails ("The landing lock fails closed when it cannot read CI state") and two policies for one class of evidence is normally a defect. Then I measured the thing the design turns on: with the exact credential the leg uses (FORGEJO_API_KEY, login aaron, non-admin) I read GET /repos/{owner}/{repo}/branch_protections across all 45 repos that credential can see — 38 answer 200, and 7 answer 403: jesus/infra-core, hamilton/infra-core, jesus/dyno, jesus/Transponder, jesus/hermes-automation, hamilton/hermes-automation, jesus/project_dino. A 403 there is a stable permission state, not a blip: the credential is not the owner/admin of those repos, so the named check can never succeed on them, and a fail-closed rule would permanently refuse every agent landing on 15% of the fleet — the legacy-owner repos this policy exists to protect among them, where the sanctioned escape (a human merging in the Forgejo UI) is exactly what the fleet is trying to stop needing. With that measurement the skip dominates: it costs nothing where the read works (38/45, verified live including the live rule this ticket is about) and it preserves today's gate byte-for-byte where enforcement is impossible. The two legs are also not the same shape, which is what I had missed: an unreadable CI read leaves no evidence at all, while an unreadable protection read leaves the combined status, the exact-head verdict panel, the danger class and the open holds intact. I checked that the degrade is stated rather than dressed up — a passing landing reads branch protection on "live" could not be read (Forgejo API GET .../branch_protections: 404), so the named required CI contexts were NOT enforced — leg 1 decided on the combined status alone, the record carries read: "unreadable", and a red or empty head is still refused in every probe. The one improvement I would still ask for is visibility, not policy: see finding H2524-F2.

The enforcement is real; I reproduced GITS-368 against the live rule and the live head. I fetched Homelab/ghostty-in-the-shell branch protection live (live → enable_status_check: true with 7 contexts, main → false/null) and the live combined status of PR #37's head 2f7bb87b80b583c8ce41c7820b6efe97b94bf554 (success, 5 contexts, emu / emu and emu / battery absent). Fed through the shipped readRequiredCiContexts + evaluateLandingLock, that head is REFUSED and both emu contexts are named. The same live rule with a complete 7-context head permits, with the note required CI contexts on "live": 7/7 present on the head, and the live main rule permits with does not enforce status checks. I also checked for name drift, which is the way this kind of check breaks production: the six most recent live-based heads carry the 7 required contexts verbatim, so enforcement will not refuse a complete head.

Four behaviour probes, driven end-to-end through the registered forgejo_merge_pr tool (route-table fake), each verified by merge POST count. (1) combined success with 2 of 7 required contexts absent → REFUSED, 0 POSTs, both absent contexts named, message states that a combined status cannot see a context that never ran. (2) all 7 present and green → PERMITTED, 1 POST, note 7/7. (3) a required context present but red → REFUSED with the pre-existing CI is not green on the head commit (state: failure, source: combined) plus non-green contexts: emu / battery (pull_request) (failure), and never the absent wording; a required context present but pending likewise keeps the old CI is still running wording. (4) base-branch selection: a rule for live-2 on a PR based on live is not borrowed (permitted), base live in a repo whose other rule is main refuses and names all 6 absent contexts, the same repo with base main and status checks off permits, and a release/* pattern governs release/2.0 but not main. Extras: two absent plus one present-but-red refuses with the absent wording and lists the non-green context; a malformed 200 protections body is treated as unreadable rather than as "no rules"; a head with no statuses at all is still refused even when the protection read failed.

No-protection regression: this repo's own shape is unchanged. Homelab/pi-extensions answers 200 [] live, and both probes match the pre-ticket gate — combined success permits (no branch-protection rule for "main": no named required CI contexts, the combined status decided leg 1), combined failure refuses with the existing non-green message. No new refusal can reach the fleet's busiest repo, because an empty list yields required: [] and the completeness check is skipped.

Nothing else moved. landing.ts gains one optional input field, one optional record field, one note line and the completeness block placed ahead of the existing state check; legs 2-5, the session panel, the legacy owner panel and hold withdrawal are untouched hunk for hunk. api.ts only adds two rule fields, the read-only reader, the exports and the non-array throw, and describeMergeWhitelist already catches that throw and keeps reporting unread honestly. There is no branch-protection write anywhere: the only protection request is the GET in listBranchProtections, and the new test asserts zero non-GET calls to /branch_protections. No secret in any argv, no writes outside the worktree, nothing in ~/.pi/agent/extensions, no Plane or GITS write.

Tests and red-first evidence. npx tsc --noEmit clean; npx vitest run 21 files / 587 tests green; git status clean at 17c2a5f0. The red log shows 11 tests, 8 failing, 3 passing: the 3 that pass are exactly the tests that assert refusals the old code already made (present-but-red, non-green under no protection, non-green under a failed read), and the 8 are genuine assertions of the missing behaviour — two "the lock permitted where it must refuse", five note-wording assertions, one "zero protection GETs". The 6 selector tests are absent from that log, which matches the worker's disclosure that they would have been module-level import errors; that is honest red-first evidence for the behavioural leg, and the selector block simply has no red evidence.

Note — H2524-F1: an absent base ref is reported as "no rule", a negative the lock has not established.

When pr.base carries neither ref nor label, baseRef becomes "" and the lock permits with no branch-protection rule for "(unknown base)": no named required CI contexts, the combined status decided leg 1; probe P10 issued the merge POST. Unlike the 403 repos, an absent base ref is not a permission wall — it is a malformed PR read, and the same file already refuses a missing head sha ("the API reported no head sha, so CI and the verdict record cannot be checked"). Suggest refusing for an empty base ref, or at minimum wording it the base branch could not be determined, so no rule could be selected instead of claiming no rule exists.

Note — H2524-F2: the degradation is invisible where coverage matters most.

RequiredContextsAssessment.read: "unreadable" reaches the landing note and the record, but the census row shape in index.ts has no field for it, so on the 7 repos that answer 403 the queue shows canMerge: true with no hint that the named check was skipped. A degraded / exactnessUnknown column (or a line in the row's reason) would put the fleet's weakest coverage where the operator actually looks.

Note — H2524-F3: rule selection takes the first match and models only *.

find over the rule list means a repo holding both release/* and release/2.0 enforces one of them rather than the union, and a selector shape ruleCoversBranch does not model reads as "no rule for this branch" rather than "a rule exists but did not match". Neither is wrong for today's shapes (both live rules use exact branch names) and both fail in the permit direction, so they deserve a documented limit or a union test before a pattern rule is used in anger.

Note — H2524-F4: the reviewer helper's lock preview does not run the new leg.

verdict.ts calls evaluateLandingLock without requiredContexts, so the helper's "the landing lock would still refuse after this comment" preview stays silent on an absent-context refusal and the reviewer meets it only at merge time. Advisory only, since the helper never gates posting, but threading the same evidence through it would make the preview honest.

## Verdict: APPROVED model: omniroute/deepseek-flash head: 17c2a5f009c9ffaa77bf91b3f4262480144270c7 session: 01a1a5e7-883d-7446-a658-25084ff9de9e Independent seat 1. The completeness leg is real, the wording split is honest, and I reproduced GITS-368 through the shipped code with live data rather than through the PR's own tests. Recorded non-blocking findings below; none of them stops this landing. **Ruling on the unreadable-read behaviour: acceptable, non-blocking — and I reversed my own starting position after measuring.** I began from fail-closed, because this same leg refuses when the commit-status read fails ("The landing lock fails closed when it cannot read CI state") and two policies for one class of evidence is normally a defect. Then I measured the thing the design turns on: with the exact credential the leg uses (`FORGEJO_API_KEY`, login `aaron`, non-admin) I read `GET /repos/{owner}/{repo}/branch_protections` across all 45 repos that credential can see — 38 answer 200, and 7 answer **403**: `jesus/infra-core`, `hamilton/infra-core`, `jesus/dyno`, `jesus/Transponder`, `jesus/hermes-automation`, `hamilton/hermes-automation`, `jesus/project_dino`. A 403 there is a stable permission state, not a blip: the credential is not the owner/admin of those repos, so the named check can never succeed on them, and a fail-closed rule would permanently refuse every agent landing on 15% of the fleet — the legacy-owner repos this policy exists to protect among them, where the sanctioned escape (a human merging in the Forgejo UI) is exactly what the fleet is trying to stop needing. With that measurement the skip dominates: it costs nothing where the read works (38/45, verified live including the `live` rule this ticket is about) and it preserves today's gate byte-for-byte where enforcement is impossible. The two legs are also not the same shape, which is what I had missed: an unreadable CI read leaves no evidence at all, while an unreadable protection read leaves the combined status, the exact-head verdict panel, the danger class and the open holds intact. I checked that the degrade is stated rather than dressed up — a passing landing reads `branch protection on "live" could not be read (Forgejo API GET .../branch_protections: 404), so the named required CI contexts were NOT enforced — leg 1 decided on the combined status alone`, the record carries `read: "unreadable"`, and a red or empty head is still refused in every probe. The one improvement I would still ask for is visibility, not policy: see finding H2524-F2. **The enforcement is real; I reproduced GITS-368 against the live rule and the live head.** I fetched `Homelab/ghostty-in-the-shell` branch protection live (`live` → `enable_status_check: true` with 7 contexts, `main` → `false`/`null`) and the live combined status of PR #37's head `2f7bb87b80b583c8ce41c7820b6efe97b94bf554` (`success`, 5 contexts, `emu / emu` and `emu / battery` absent). Fed through the shipped `readRequiredCiContexts` + `evaluateLandingLock`, that head is REFUSED and both `emu` contexts are named. The same live rule with a complete 7-context head permits, with the note `required CI contexts on "live": 7/7 present on the head`, and the live `main` rule permits with `does not enforce status checks`. I also checked for name drift, which is the way this kind of check breaks production: the six most recent `live`-based heads carry the 7 required contexts verbatim, so enforcement will not refuse a complete head. **Four behaviour probes, driven end-to-end through the registered `forgejo_merge_pr` tool (route-table fake), each verified by merge POST count.** (1) combined `success` with 2 of 7 required contexts absent → REFUSED, 0 POSTs, both absent contexts named, message states that a combined status cannot see a context that never ran. (2) all 7 present and green → PERMITTED, 1 POST, note 7/7. (3) a required context present but **red** → REFUSED with the pre-existing `CI is not green on the head commit (state: failure, source: combined)` plus `non-green contexts: emu / battery (pull_request) (failure)`, and never the absent wording; a required context present but **pending** likewise keeps the old `CI is still running` wording. (4) base-branch selection: a rule for `live-2` on a PR based on `live` is not borrowed (permitted), base `live` in a repo whose other rule is `main` refuses and names all 6 absent contexts, the same repo with base `main` and status checks off permits, and a `release/*` pattern governs `release/2.0` but not `main`. Extras: two absent plus one present-but-red refuses with the absent wording and lists the non-green context; a malformed 200 protections body is treated as unreadable rather than as "no rules"; a head with no statuses at all is still refused even when the protection read failed. **No-protection regression: this repo's own shape is unchanged.** `Homelab/pi-extensions` answers `200 []` live, and both probes match the pre-ticket gate — combined success permits (`no branch-protection rule for "main": no named required CI contexts, the combined status decided leg 1`), combined failure refuses with the existing non-green message. No new refusal can reach the fleet's busiest repo, because an empty list yields `required: []` and the completeness check is skipped. **Nothing else moved.** `landing.ts` gains one optional input field, one optional record field, one note line and the completeness block placed ahead of the existing state check; legs 2-5, the session panel, the legacy owner panel and hold withdrawal are untouched hunk for hunk. `api.ts` only adds two rule fields, the read-only reader, the exports and the non-array throw, and `describeMergeWhitelist` already catches that throw and keeps reporting unread honestly. There is no branch-protection write anywhere: the only protection request is the GET in `listBranchProtections`, and the new test asserts zero non-GET calls to `/branch_protections`. No secret in any argv, no writes outside the worktree, nothing in `~/.pi/agent/extensions`, no Plane or GITS write. **Tests and red-first evidence.** `npx tsc --noEmit` clean; `npx vitest run` 21 files / 587 tests green; `git status` clean at `17c2a5f0`. The red log shows 11 tests, 8 failing, 3 passing: the 3 that pass are exactly the tests that assert refusals the old code already made (present-but-red, non-green under no protection, non-green under a failed read), and the 8 are genuine assertions of the missing behaviour — two "the lock permitted where it must refuse", five note-wording assertions, one "zero protection GETs". The 6 selector tests are absent from that log, which matches the worker's disclosure that they would have been module-level import errors; that is honest red-first evidence for the behavioural leg, and the selector block simply has no red evidence. ### Note — H2524-F1: an absent base ref is reported as "no rule", a negative the lock has not established. When `pr.base` carries neither `ref` nor `label`, `baseRef` becomes `""` and the lock permits with `no branch-protection rule for "(unknown base)": no named required CI contexts, the combined status decided leg 1`; probe P10 issued the merge POST. Unlike the 403 repos, an absent base ref is not a permission wall — it is a malformed PR read, and the same file already refuses a missing head sha ("the API reported no head sha, so CI and the verdict record cannot be checked"). Suggest refusing for an empty base ref, or at minimum wording it `the base branch could not be determined, so no rule could be selected` instead of claiming no rule exists. ### Note — H2524-F2: the degradation is invisible where coverage matters most. `RequiredContextsAssessment.read: "unreadable"` reaches the landing note and the record, but the census row shape in `index.ts` has no field for it, so on the 7 repos that answer 403 the queue shows `canMerge: true` with no hint that the named check was skipped. A `degraded` / `exactnessUnknown` column (or a line in the row's reason) would put the fleet's weakest coverage where the operator actually looks. ### Note — H2524-F3: rule selection takes the first match and models only `*`. `find` over the rule list means a repo holding both `release/*` and `release/2.0` enforces one of them rather than the union, and a selector shape `ruleCoversBranch` does not model reads as "no rule for this branch" rather than "a rule exists but did not match". Neither is wrong for today's shapes (both live rules use exact branch names) and both fail in the permit direction, so they deserve a documented limit or a union test before a pattern rule is used in anger. ### Note — H2524-F4: the reviewer helper's lock preview does not run the new leg. `verdict.ts` calls `evaluateLandingLock` without `requiredContexts`, so the helper's "the landing lock would still refuse after this comment" preview stays silent on an absent-context refusal and the reviewer meets it only at merge time. Advisory only, since the helper never gates posting, but threading the same evidence through it would make the preview honest.
ld-reviewer requested changes 2026-11-04 09:42:48 +00:00
Dismissed
ld-reviewer left a comment
Collaborator

Verdict: REQUEST_CHANGES

model: omniroute/deepseek-flash
head: 17c2a5f009
session: 01a1a5e7-886c-7446-a658-250b4d3cffa9

What I measured at this head

npx tsc --noEmit is clean and npx vitest run is green at 21 files / 587 tests in /home/aaron/repos/pi-extensions/.worktrees/HOMELAB-2524-ci-contexts/forgejo, with git status --porcelain empty before and after my run, and CI run #236 is green on 14/14 contexts at this exact head.

The live shapes the design rests on are real: Homelab/pi-extensions answers 200 [], and Homelab/ghostty-in-the-shell carries live with enable_status_check: true and exactly the seven contexts the tests pin, plus main with enable_status_check: false and status_check_contexts: null.

The exactness of the name comparison is right, and I could not break it: the combined /status of a real ghostty PR head (cb63530, PR #155) reports ci / pr-title (pull_request), ci / check (pull_request), ci / scripts (pull_request), ci / assemble (pull_request), secret-probe / secret-probe (pull_request), emu / emu (pull_request), emu / battery (pull_request) — verbatim identical to the protection rule's status_check_contexts, spaces and (pull_request) qualifier included, so an exact trimmed comparison is the correct rule on this forge and any normalisation would be a guess in the wrong direction.

Blocker — H2524-B1: an unreadable protection read permits the landing, and the reason given for that fail-open does not survive measurement

assessRequiredContexts returns enforced: false when read is "unreadable", so a 403, a 404 or a 5xx on GET /repos/{o}/{r}/branch_protections degrades leg 1 to the combined status alone and the merge proceeds — pinned by the test "permits but records that the named requirement was NOT enforced", which asserts mergeCalls(calls) is 1.

BLOCKER OPEN: H2524-B1

The documented justification is that refusing "would turn an infrastructure read failure into a permanent merge blocker on every repo whose list the session's credential cannot read", with no override to escape it; both halves are false in this fleet and I measured them.

Writer, not admin, is what the endpoint requires: jesus/Transponder answers 200 to my credential with permissions: {admin: false, push: true}, while hamilton/hermes-automation, hamilton/infra-core, jesus/hermes-automation and jesus/infra-core all answer 403 "user should be an owner or a collaborator with admin write of a repository" to the same credential with push: false.

The landing identity is a writer on every repository it can land in: pi-landing is permission: "write" on Homelab/ghostty-in-the-shell, Homelab/infra-core, Homelab/lab-director, Homelab/prod and Homelab/pi-extensions, and permission: "none" on Sendrast/sendrast-web and Sendrast/api-sendrast, where it is also absent from merge_whitelist_usernames: ["aaron","jesus"] and therefore could not land even if the lock permitted.

So the set of repositories where the protection read can fail while a landing could still happen is empty, and the wedge this design is built to avoid has no member in this fleet: a credential that cannot read the list is a credential that cannot land.

A refusal is not unappealable either, because the escape already exists for every other refusal this lock can make — this project's own docs name it, "a human can still merge from the Forgejo UI" (docs/forgejo.md), and the forge itself enforces enable_status_check at merge for a non-admin landing identity under apply_to_admins: false on all four protected Homelab repositories, which is how "Not all required status checks successful" reaches this fleet's records at all.

Against that, the cost of permitting is exactly the thing the epic exists to kill: GITS-368 is leg 1 deciding green from a combined status that could not see the contexts that never ran, and an unreadable protection list is the one state in which this ticket cannot tell whether it just made the same call again.

What I would implement instead

Return a refusal when input.read === "unreadable", in the shape the missing-context refusal already has: name the base branch, carry the one-line reason, say plainly that the named set is unknown rather than empty, and give the two remedies — retry once the read answers, and the human merging from the Forgejo UI.

Keep the readable-but-empty list (200 [], the pi-extensions shape) exactly as it behaves today, because that one is a measured fact and not a gap.

Two tests: unreadable plus green combined status means refusal with zero merge POSTs; unreadable plus red combined status keeps the existing non-green wording.

Findings that are not blocking

H2524-F1 — nothing in the PR, the tests or the ticket shows that the restricted landing identity can read the protection list at all; pi-landing is restricted: true and I cannot authenticate as it, so if that read 403s in production then every protected landing takes the "NOT enforced" path and the ticket delivers nothing while saying so quietly in a note nobody reads — refusing under H2524-B1 is what makes that visible on the first landing instead of never.

H2524-F2 — rule selection is rules.find(...), that is, list order, while Forgejo resolves a matching exact-name rule ahead of a pattern rule, so a repository carrying * with checks off ordered before live with checks on would enforce nothing here and seven contexts at the forge; no live repository has overlapping rules today (the two-rule sets are {main, epic/**} and {live, main}, disjoint), so this is latent rather than live.

H2524-F3 — the glob translation turns * into .*, which is looser than the forge in one direction and narrower in another: release/* covers release/2.0/hotfix here but not at the forge (over-match, the fail-closed direction), epic/** covers epic/HOMELAB-123 correctly (verified against the two live Sendrast rules), and a rule written with ? or [...] and no * covers nothing, which is the fail-open direction.

H2524-F4 — verdict.ts:727 calls the same evaluateLandingLock without requiredContexts, so the reviewer-side "what the merge gate would say about this PR once the comment is on it" preview keeps the pre-2524 leg 1, and because requiredContexts is optional on LandingLockInput no future caller is forced to supply it; the preview can only under-report, but a preview that stays silent while the gate refuses is the same class of record problem this ticket is otherwise careful about.

H2524-F5 — two handled states are unpinned: a malformed 200 whose body is not an array, which the new throw in listBranchProtections turns into the H2524-B1 path, and enable_status_check: true with an empty status_check_contexts, which the code correctly reads as "names no contexts"; neither has a test, and the fake cannot produce a non-array body at all.

Routes I tried to slip a missing context through

A different qualifier form (ci/check against ci / check (pull_request)) — does not slip, the comparison is exact and I verified the two sides are character-identical on a live head, so this is the strongest part of the change.

A required context present under a slightly different name — does not slip, for the same reason, and the trim is the only normalisation and it is harmless.

A base branch with no rule while the default branch has one — does not slip into a false green, it permits, and that permit is the forge's own answer because a rule for another branch names nothing for this one (live: ghostty main is checks-off and live is checks-on, so a PR to main may land on one green context and Forgejo agrees).

Protection only on main while the PR targets live — permits, and again matches the forge, because the main rule does not cover live and the forge requires nothing there either; this is the one shape where permitting is correct and it is documented as such.

Empty status_check_contexts with enable_status_check: true — permits, correctly, and the summary says "enforces status checks but names no contexts" rather than claiming an enforcement that does not exist.

A malformed protection body — slips, but into the same gap as H2524-B1 rather than past the new check: the throw in api.ts is real and does turn a fabricated [] into an honest "unreadable", yet the gate outcome is unchanged, which is precisely why the fix belongs in assessRequiredContexts and not in the reader.

An in-flight head whose required contexts are queued and not yet reporting is refused as "absent from the head", which is the safe direction, but the wording says "absent" where "not yet reported" would be true — a message nit, not a hole.

Nothing else moved

Legs 2 to 5, the session panel, the legacy owner panel and hold withdrawal are untouched: every hunk in landing.ts is additive around leg 1, requiredContexts is only carried into the record and the landing note, and no rule, hold, ledger or panel function changed — which the untouched existing suites confirm (587 tests, including landing-hold-session, landing-blockers, landing-sessions, landing-model-supersede).

No branch-protection write exists in the code or the tests: the only new forge call is a GET .../branch_protections, and the G6 test asserts exactly one GET on that path and zero non-GET calls touching it — I re-ran it at this head and it passes.

Red evidence

The log at HOMELAB-2524-red.log is fair red-first evidence and not padding: 11 tests, 8 failed, and the three that passed are regression guards (a non-green combined status still refuses, with and without a readable protection list), not selector imports — there is no import error anywhere in it, and two of the red cases show the pre-fix lock actually permitting the merge of an incomplete head with zero merge POSTs asserted after.

The file has grown to 17 tests since that run, and the added cases strengthen the claim rather than dilute it.

Hygiene

Read-only throughout: no code edit, no merge, no deploy, no branch-protection write, no Plane or GITS write, no write to ~/.pi/agent/extensions, and the review credential was read from OpenBao into a 0600 file inside a 0700 directory that is removed afterwards — never in argv, never printed.

## Verdict: REQUEST_CHANGES model: omniroute/deepseek-flash head: 17c2a5f009c9ffaa77bf91b3f4262480144270c7 session: 01a1a5e7-886c-7446-a658-250b4d3cffa9 ## What I measured at this head `npx tsc --noEmit` is clean and `npx vitest run` is green at 21 files / 587 tests in `/home/aaron/repos/pi-extensions/.worktrees/HOMELAB-2524-ci-contexts/forgejo`, with `git status --porcelain` empty before and after my run, and CI run #236 is green on 14/14 contexts at this exact head. The live shapes the design rests on are real: `Homelab/pi-extensions` answers `200 []`, and `Homelab/ghostty-in-the-shell` carries `live` with `enable_status_check: true` and exactly the seven contexts the tests pin, plus `main` with `enable_status_check: false` and `status_check_contexts: null`. The exactness of the name comparison is right, and I could not break it: the combined `/status` of a real ghostty PR head (`cb63530`, PR #155) reports `ci / pr-title (pull_request)`, `ci / check (pull_request)`, `ci / scripts (pull_request)`, `ci / assemble (pull_request)`, `secret-probe / secret-probe (pull_request)`, `emu / emu (pull_request)`, `emu / battery (pull_request)` — verbatim identical to the protection rule's `status_check_contexts`, spaces and `(pull_request)` qualifier included, so an exact trimmed comparison is the correct rule on this forge and any normalisation would be a guess in the wrong direction. ### Blocker — H2524-B1: an unreadable protection read permits the landing, and the reason given for that fail-open does not survive measurement `assessRequiredContexts` returns `enforced: false` when `read` is `"unreadable"`, so a 403, a 404 or a 5xx on `GET /repos/{o}/{r}/branch_protections` degrades leg 1 to the combined status alone and the merge proceeds — pinned by the test "permits but records that the named requirement was NOT enforced", which asserts `mergeCalls(calls)` is 1. BLOCKER OPEN: H2524-B1 The documented justification is that refusing "would turn an infrastructure read failure into a permanent merge blocker on every repo whose list the session's credential cannot read", with no override to escape it; both halves are false in this fleet and I measured them. Writer, not admin, is what the endpoint requires: `jesus/Transponder` answers `200` to my credential with `permissions: {admin: false, push: true}`, while `hamilton/hermes-automation`, `hamilton/infra-core`, `jesus/hermes-automation` and `jesus/infra-core` all answer `403` "user should be an owner or a collaborator with admin write of a repository" to the same credential with `push: false`. The landing identity is a writer on every repository it can land in: `pi-landing` is `permission: "write"` on `Homelab/ghostty-in-the-shell`, `Homelab/infra-core`, `Homelab/lab-director`, `Homelab/prod` and `Homelab/pi-extensions`, and `permission: "none"` on `Sendrast/sendrast-web` and `Sendrast/api-sendrast`, where it is also absent from `merge_whitelist_usernames: ["aaron","jesus"]` and therefore could not land even if the lock permitted. So the set of repositories where the protection read can fail while a landing could still happen is empty, and the wedge this design is built to avoid has no member in this fleet: a credential that cannot read the list is a credential that cannot land. A refusal is not unappealable either, because the escape already exists for every other refusal this lock can make — this project's own docs name it, "a human can still merge from the Forgejo UI" (`docs/forgejo.md`), and the forge itself enforces `enable_status_check` at merge for a non-admin landing identity under `apply_to_admins: false` on all four protected Homelab repositories, which is how "Not all required status checks successful" reaches this fleet's records at all. Against that, the cost of permitting is exactly the thing the epic exists to kill: GITS-368 is leg 1 deciding green from a combined status that could not see the contexts that never ran, and an unreadable protection list is the one state in which this ticket cannot tell whether it just made the same call again. #### What I would implement instead Return a refusal when `input.read === "unreadable"`, in the shape the missing-context refusal already has: name the base branch, carry the one-line reason, say plainly that the named set is unknown rather than empty, and give the two remedies — retry once the read answers, and the human merging from the Forgejo UI. Keep the readable-but-empty list (`200 []`, the pi-extensions shape) exactly as it behaves today, because that one is a measured fact and not a gap. Two tests: unreadable plus green combined status means refusal with zero merge POSTs; unreadable plus red combined status keeps the existing non-green wording. ## Findings that are not blocking `H2524-F1` — nothing in the PR, the tests or the ticket shows that the restricted landing identity can read the protection list at all; `pi-landing` is `restricted: true` and I cannot authenticate as it, so if that read 403s in production then every protected landing takes the "NOT enforced" path and the ticket delivers nothing while saying so quietly in a note nobody reads — refusing under H2524-B1 is what makes that visible on the first landing instead of never. `H2524-F2` — rule selection is `rules.find(...)`, that is, list order, while Forgejo resolves a matching exact-name rule ahead of a pattern rule, so a repository carrying `*` with checks off ordered before `live` with checks on would enforce nothing here and seven contexts at the forge; no live repository has overlapping rules today (the two-rule sets are `{main, epic/**}` and `{live, main}`, disjoint), so this is latent rather than live. `H2524-F3` — the glob translation turns `*` into `.*`, which is looser than the forge in one direction and narrower in another: `release/*` covers `release/2.0/hotfix` here but not at the forge (over-match, the fail-closed direction), `epic/**` covers `epic/HOMELAB-123` correctly (verified against the two live Sendrast rules), and a rule written with `?` or `[...]` and no `*` covers nothing, which is the fail-open direction. `H2524-F4` — `verdict.ts:727` calls the same `evaluateLandingLock` without `requiredContexts`, so the reviewer-side "what the merge gate would say about this PR once the comment is on it" preview keeps the pre-2524 leg 1, and because `requiredContexts` is optional on `LandingLockInput` no future caller is forced to supply it; the preview can only under-report, but a preview that stays silent while the gate refuses is the same class of record problem this ticket is otherwise careful about. `H2524-F5` — two handled states are unpinned: a malformed 200 whose body is not an array, which the new throw in `listBranchProtections` turns into the H2524-B1 path, and `enable_status_check: true` with an empty `status_check_contexts`, which the code correctly reads as "names no contexts"; neither has a test, and the fake cannot produce a non-array body at all. ## Routes I tried to slip a missing context through A different qualifier form (`ci/check` against `ci / check (pull_request)`) — does not slip, the comparison is exact and I verified the two sides are character-identical on a live head, so this is the strongest part of the change. A required context present under a slightly different name — does not slip, for the same reason, and the trim is the only normalisation and it is harmless. A base branch with no rule while the default branch has one — does not slip into a false green, it permits, and that permit is the forge's own answer because a rule for another branch names nothing for this one (live: ghostty `main` is checks-off and `live` is checks-on, so a PR to `main` may land on one green context and Forgejo agrees). Protection only on `main` while the PR targets `live` — permits, and again matches the forge, because the `main` rule does not cover `live` and the forge requires nothing there either; this is the one shape where permitting is correct and it is documented as such. Empty `status_check_contexts` with `enable_status_check: true` — permits, correctly, and the summary says "enforces status checks but names no contexts" rather than claiming an enforcement that does not exist. A malformed protection body — slips, but into the same gap as H2524-B1 rather than past the new check: the throw in `api.ts` is real and does turn a fabricated `[]` into an honest "unreadable", yet the gate outcome is unchanged, which is precisely why the fix belongs in `assessRequiredContexts` and not in the reader. An in-flight head whose required contexts are queued and not yet reporting is refused as "absent from the head", which is the safe direction, but the wording says "absent" where "not yet reported" would be true — a message nit, not a hole. ## Nothing else moved Legs 2 to 5, the session panel, the legacy owner panel and hold withdrawal are untouched: every hunk in `landing.ts` is additive around leg 1, `requiredContexts` is only carried into the record and the landing note, and no rule, hold, ledger or panel function changed — which the untouched existing suites confirm (587 tests, including `landing-hold-session`, `landing-blockers`, `landing-sessions`, `landing-model-supersede`). No branch-protection write exists in the code or the tests: the only new forge call is a `GET .../branch_protections`, and the G6 test asserts exactly one GET on that path and zero non-GET calls touching it — I re-ran it at this head and it passes. ## Red evidence The log at `HOMELAB-2524-red.log` is fair red-first evidence and not padding: 11 tests, 8 failed, and the three that passed are regression guards (a non-green combined status still refuses, with and without a readable protection list), not selector imports — there is no import error anywhere in it, and two of the red cases show the pre-fix lock actually permitting the merge of an incomplete head with zero merge POSTs asserted after. The file has grown to 17 tests since that run, and the added cases strengthen the claim rather than dilute it. ## Hygiene Read-only throughout: no code edit, no merge, no deploy, no branch-protection write, no Plane or GITS write, no write to `~/.pi/agent/extensions`, and the review credential was read from OpenBao into a 0600 file inside a 0700 directory that is removed afterwards — never in argv, never printed.
HOMELAB-2524: fix(forgejo): refuse when the named required CI set is UNKNOWN
All checks were successful
CI / pr-title (pull_request) Successful in 0s
CI / forgejo-ci (pull_request) Successful in 1m7s
CI / pi-otel-telemetry (pull_request) Successful in 1m14s
CI / skill-args (pull_request) Successful in 1m23s
CI / mcp-bridge (pull_request) Successful in 1m1s
tests / python (pull_request) Successful in 22s
CI / todo (pull_request) Successful in 1m25s
CI / nats-intercom (pull_request) Successful in 1m1s
CI / ask-user-question (pull_request) Successful in 1m8s
CI / refocus (pull_request) Successful in 1m22s
CI / plane (pull_request) Successful in 1m53s
CI / redact (pull_request) Successful in 1m25s
CI / pi-friction (pull_request) Successful in 1m28s
CI / forgejo (pull_request) Successful in 2m37s
CI / pi-swarm-suite (pull_request) Successful in 5m48s
c9eaa1027c
Round 2 of the ticket, implementing the orchestrator's ruling on the review.
Round 1 read the base branch's `status_check_contexts` and enforced them by
name, but it permitted on the combined status whenever that read failed, and it
reported "no rule for (unknown base)" for a base branch it never learned.

B1 — an unreadable protection read REFUSES. Round 1's defence was a wedge
argument measured with the wrong credential: the operator token (`aaron`,
`is_admin: false`), for which 7 `jesus/*`-style repos answer 403. The lock's
reads run with the landing credential, and `pi-landing` has `write` on every repo
it can land in (`ghostty-in-the-shell`, `infra-core`, `lab-director`, `prod`,
`pi-extensions`); `GET /branch_protections` needs writer, not admin
(`jesus/Transponder`: push true, admin false, reads 200). The wedge set is empty,
so the refusal is affordable. It names the base branch, states that the named
required set is UNKNOWN rather than empty, and gives the two remedies (retry; or
a human merges from the Forgejo UI).

F1 — an absent/blank `base.ref` REFUSES. A rule cannot be selected without a
branch, and "no rule" is not a claim the lock can make about a branch nothing
named. Round 1 reported "no rule for (unknown base)" and permitted, and in one
probe went as far as issuing a merge POST.

F3 — rule selection: an exact-name rule now wins over a glob that also matches,
which is Forgejo's own order. The glob model implements `*`/`**` only, so a
pattern using `?`, `[...]` or `{...}` reports the model's LIMIT and refuses; it
can never read as "no requirement".

F4 — the verdict helper's lock preview carries `requiredContexts`, and the field
is REQUIRED on `LandingLockInput` and `LockState`: an omitted field used to mean
"the named-context check does not run", which is exactly how the preview went
silent on an absent-context refusal. `verdict-cli` now reads the base branch's
protection for that state.

F2/F5 — the census row carries `requiredCi` (`7/7`, `5/7 ABSENT …`, or
`UNKNOWN — …`) and prints it as `REQUIRED-CI …` even on a refused row; malformed
(200, non-array) bodies and the empty-`status_check_contexts` rule are covered by
tests.

Round-1 gains are kept: the absent-context refusal and its naming of every
missing context, the 7/7 permit, the present-but-red refusal in its pre-existing
wording, base-branch selection that never borrows another branch's rule, the
`200 []` no-requirement path byte-identical, and `listBranchProtections`
throwing on a malformed body. Legs 2/3/4/5, the session panel, the legacy owner
panel and hold withdrawal are untouched, and the only forge call this feature
adds is still a GET (G6).

Tests: 587 -> 601 (red2 captured before the change, green2 after); tsc clean.
ld-reviewer approved these changes 2026-11-04 10:42:12 +00:00
Dismissed
ld-reviewer left a comment
Collaborator

Verdict: APPROVED

model: omniroute/deepseek-flash
head: c9eaa1027c
session: 01a1a61d-45de-7446-a658-2515468e24dc

Round-2 seat 2, independent of the other seat. I re-derived every round-2 claim from the code, from my own probes against the real evaluateLandingLock / assessRequiredContexts / readRequiredCiContexts, and from read-only GETs to the live forge, and I did not take the description's measurements on trust.

The central question was whether any route still turns "the named set is unknown" into "no requirement, permit". I could not find one that fires against this forge, and the three routes B1 named are closed; five residual gaps are listed as non-blocking findings below, none of which issues a merge the live API can reach.

GITS-368 reproduced by measurement, not by description

GET /repos/Homelab/ghostty-in-the-shell/pulls/37 → head 2f7bb87; GET /repos/.../commits/2f7bb87/status → state: success over exactly five contexts — ci / pr-title (pull_request), ci / scripts (pull_request), ci / check (pull_request), ci / assemble (pull_request), secret-probe / secret-probe (pull_request) — while GET /repos/.../branch_protections reports rule live with enable_status_check: true and seven status_check_contexts, the two emu ones absent from that status set.

The required strings and the reported strings are byte-identical (both carry the (pull_request) suffix), so exact-name matching is the right predicate here and this refusal is the one that would have caught #37; a fuzzy or suffix-stripping match would have been the wrong fix.

Homelab/pi-extensions really does answer 200 [], and ghostty-in-the-shell really does carry a second rule main with enable_status_check: false, so the two no-requirement shapes the design leans on are the live shapes and not fixture conveniences.

HOMELAB-2524-B1 is resolved

H2524-B1 is resolved by c9eaa10: an unreadable protection read now yields read: "unreadable" and unknown: "protection-unreadable", and leg 1 refuses on it instead of degrading to the combined status.
blockers resolved

Measured over the transport surface, not just the 404 the tests use: 403, 401, 404, 500, a fetch rejection (the shape a timeout or a dropped connection takes), a 200 whose body is a non-array object, a 200 with an empty body under content-type: application/json, a 200 with an empty body and no content type, a 200 whose body is null, and a 204 all classify read: "unreadable" and all refuse; only 200 [] classifies read: "rules" and permits. I did not find a write path — every protection call the extension makes is a GET, asserted in my probes and in the suite's G6 test.

The two legs do not contradict

Proven by probe on the real evaluator: a red head with every required context present keeps the pre-existing CI is not green on the head commit … wording; a red head whose protection read ALSO failed keeps that same red wording (the UNKNOWN refusal is suppressed, exactly as the ordering intends); a green head missing a required context gets the absent-context refusal naming each missing context; a green head whose protection read failed gets the UNKNOWN refusal; a green head with an absent base ref gets the base-ref-absent refusal; a green head with every context present permits.

One nuance worth stating rather than hiding: the round-1 ABSENT refusal sits BEFORE the combined-status check, so a head that is both red and incomplete gets the absent-context wording (it still prints the non-green contexts: detail), not CI is not green. That is deliberate — one refusal per GITS-368 fact — and it does not contradict the round-2 claim, which is only about the two NEW refusals sitting after the combined-status check.

200 [] still permits

End-to-end probe with the pi-extensions shape (readable empty list, green head, fresh verdict) issues the merge POST once and the note records that the combined status decided. No regression on the busiest repo, which was the risk that would have blocked every landing in the fleet.

Table — unknown-shaped input → outcome (all measured)

input outcome
403 / 401 / 404 / 500 unreadable → REFUSE (UNKNOWN)
fetch rejection (timeout / network) unreadable → REFUSE
200 non-array object {"rules":[]} unreadable → REFUSE
200 empty body (with or without json content-type) unreadable → REFUSE
200 null unreadable → REFUSE
204 no content unreadable → REFUSE
200 [] rules, no rule → PERMIT
200 [{}] (array of non-objects) rules, branch "" → PERMIT (finding N3)
only glob release/* vs base main no match → PERMIT (correct: governs other branches)
only glob * matches → enforced by name
glob li?e / rel{a,b}ase / [mn]ain REFUSE (pattern-unevaluable)
exact live (checks off) + unevaluable sibling li?e exact wins → PERMIT
exact main checks off + * sibling carrying contexts exact wins → PERMIT (faithful: Forgejo applies the exact rule and only then globs, so borrowing the sibling's contexts would be the wrong answer)
absent base.ref, no label REFUSE (base-ref-absent)
base.ref = "" or whitespace only REFUSE (base-ref-absent) — trim handles the blank case
absent base.ref, label present base ref taken from the LABEL → PERMIT when the label is not the branch name (finding N2)
base Main vs rule main no match → PERMIT (case-sensitive, same as git and Forgejo)
base refs/heads/main vs rule main no match → PERMIT (residual; this forge reports bare names — measured rule_name: "live", "main")
enforcing rule naming zero contexts PERMIT, and the note says it named nothing
requiredContexts omitted at runtime check silently skipped → PERMIT (finding N4)

Non-blocking findings (no BLOCKER OPEN line for any of these)

H2524-N1 — the census and preview do not carry the named-CI state on every result, contrary to the record. LandingLockResult.requiredContexts is documented as present "on EVERY result", and docs/forgejo.md says "A refused row also prints leg 1's named-CI state"; measured, it is absent on the three combined-status refusals (red, still-running, no-status) and on the leg-2+ refusals, which is why a census row for an ordinary unreviewed PR prints no REQUIRED-CI segment even when the protection read failed, and why the deployed helper's live preview on #91 prints no required-ci: line. The specific state this hides is "red head AND the protection read never worked", which is exactly the state F2 exists to expose; it is under-reporting only (no false permit, no false claim of enforcement), and the fix is to attach the assessment where it is computed instead of on selected returns.

H2524-N2 — the base ref falls back to base.label, which makes F1's "an absent base.ref refuses" conditional. index.ts and verdict-cli.ts both derive pr?.base?.ref ?? pr?.base?.label ?? ""; when ref is absent and a label exists the lock no longer refuses, and with a label that is not the branch name (upstream Gitea's owner:branch shape) the derived ref matches no rule and the head permits — my probe with label Homelab:live issued the merge POST with two of four required contexts absent. On THIS forge the fallback is harmless because I measured base.label equal to base.ref (main, live), so this is a latent hazard and not a live fail-open, and the F1 test covers only base: undefined, the one shape where the fallback cannot fire; recommend dropping the fallback or refusing when the derived ref cannot be a branch name, and pinning the label-present shape.

H2524-N3 — an array of non-objects still reads as "rules". A 200 whose body is a non-array refuses (correct, and pinned), but 200 [{}] maps to one rule with an empty branch name and therefore to "no rule" and a permit; same "malformed is not empty" class as the case the PR does pin, worth the same treatment.

H2524-N4 — "required" is a compile-time property only. LandingLockInput.requiredContexts and LockState.requiredContexts are required fields, which is a real improvement, but a runtime caller (a parsed JSON state, a future JS caller) that omits them gets assessRequiredContexts(undefined) → undefined and leg 1 silently skipped — the exact behaviour F4 was added to remove; a runtime refusal when the field is absent would close it.

H2524-N5 — a hung protection GET hangs the lock. request() has no timeout, so a connection that never answers waits rather than refusing; this is pre-existing for every read the lock makes and a hang is not a permit, so it is an observation, not a defect of this change.

Ruling on the fixture collateral (the change I was asked to check hardest)

The re-driven HOMELAB-2578 test has NOT been hollowed out. Driven through the same fake OpenBao credential the test file uses: with the transient failure (branchProtectionsFailAfter: 1) the refusal contains whitelist could not be read, names aaron and pi-landing, and issues zero mutating requests; with the failure removed the wording is absent while the identity refusal is still present, so the assertion is load-bearing rather than vacuous; and with the ORIGINAL shape (branchProtections: null, protections never readable) the gate now refuses at leg 1 with the UNKNOWN wording and zero writes, so that shape is covered too — by the new leg-1 test rather than by the identity leg. The only coverage that moved is the conjunction "permanently unreadable protections AND identity mismatch", which is unreachable by design now and is documented beside the fixture, and every other suite that needed the field declares NO_PROTECTION, which is a declared read rather than an omission.

Tests, CI and hygiene

npx tsc --noEmit clean and npx vitest run 21 files / 601 tests passed at c9eaa10 in the worktree, git status clean before and after my probes (I added no file that survives this review); CI run #238 is green 14/14 on this exact head.

The red log is honest: 15 failed / 48 passed across the two touched files at round-1 head, and the failures are behavioural rather than import accidents — expected 'live*' to be 'live' shows the round-1 glob-first ordering really did let a pattern steal the rule, and the leg-1 refusals really did permit. I verified the fix's own claim on the live forge rather than assuming it, which is how I found N2.

No secret is in argv anywhere in my run, no write went outside the worktree, nothing was written to the deployed extension tree, and I made no Plane or GITS write and no branch-protection write of any kind.

## Verdict: APPROVED model: omniroute/deepseek-flash head: c9eaa1027cfd733ba6bc9918e2cb419389d19860 session: 01a1a61d-45de-7446-a658-2515468e24dc Round-2 seat 2, independent of the other seat. I re-derived every round-2 claim from the code, from my own probes against the real `evaluateLandingLock` / `assessRequiredContexts` / `readRequiredCiContexts`, and from read-only GETs to the live forge, and I did not take the description's measurements on trust. The central question was whether any route still turns "the named set is unknown" into "no requirement, permit". I could not find one that fires against this forge, and the three routes B1 named are closed; five residual gaps are listed as non-blocking findings below, none of which issues a merge the live API can reach. ### GITS-368 reproduced by measurement, not by description `GET /repos/Homelab/ghostty-in-the-shell/pulls/37` → head `2f7bb87`; `GET /repos/.../commits/2f7bb87/status` → `state: success` over exactly five contexts — `ci / pr-title (pull_request)`, `ci / scripts (pull_request)`, `ci / check (pull_request)`, `ci / assemble (pull_request)`, `secret-probe / secret-probe (pull_request)` — while `GET /repos/.../branch_protections` reports rule `live` with `enable_status_check: true` and seven `status_check_contexts`, the two `emu` ones absent from that status set. The required strings and the reported strings are byte-identical (both carry the `(pull_request)` suffix), so exact-name matching is the right predicate here and this refusal is the one that would have caught #37; a fuzzy or suffix-stripping match would have been the wrong fix. `Homelab/pi-extensions` really does answer `200 []`, and `ghostty-in-the-shell` really does carry a second rule `main` with `enable_status_check: false`, so the two no-requirement shapes the design leans on are the live shapes and not fixture conveniences. ### HOMELAB-2524-B1 is resolved H2524-B1 is resolved by c9eaa10: an unreadable protection read now yields `read: "unreadable"` and `unknown: "protection-unreadable"`, and leg 1 refuses on it instead of degrading to the combined status. blockers resolved Measured over the transport surface, not just the 404 the tests use: 403, 401, 404, 500, a fetch rejection (the shape a timeout or a dropped connection takes), a 200 whose body is a non-array object, a 200 with an empty body under `content-type: application/json`, a 200 with an empty body and no content type, a 200 whose body is `null`, and a `204` all classify `read: "unreadable"` and all refuse; only `200 []` classifies `read: "rules"` and permits. I did not find a write path — every protection call the extension makes is a GET, asserted in my probes and in the suite's G6 test. ### The two legs do not contradict Proven by probe on the real evaluator: a red head with every required context present keeps the pre-existing `CI is not green on the head commit …` wording; a red head whose protection read ALSO failed keeps that same red wording (the UNKNOWN refusal is suppressed, exactly as the ordering intends); a green head missing a required context gets the absent-context refusal naming each missing context; a green head whose protection read failed gets the UNKNOWN refusal; a green head with an absent base ref gets the base-ref-absent refusal; a green head with every context present permits. One nuance worth stating rather than hiding: the round-1 ABSENT refusal sits BEFORE the combined-status check, so a head that is both red and incomplete gets the absent-context wording (it still prints the `non-green contexts:` detail), not `CI is not green`. That is deliberate — one refusal per GITS-368 fact — and it does not contradict the round-2 claim, which is only about the two NEW refusals sitting after the combined-status check. ### `200 []` still permits End-to-end probe with the `pi-extensions` shape (readable empty list, green head, fresh verdict) issues the merge POST once and the note records that the combined status decided. No regression on the busiest repo, which was the risk that would have blocked every landing in the fleet. ### Table — unknown-shaped input → outcome (all measured) | input | outcome | |---|---| | 403 / 401 / 404 / 500 | `unreadable` → REFUSE (UNKNOWN) | | fetch rejection (timeout / network) | `unreadable` → REFUSE | | 200 non-array object `{"rules":[]}` | `unreadable` → REFUSE | | 200 empty body (with or without json content-type) | `unreadable` → REFUSE | | 200 `null` | `unreadable` → REFUSE | | 204 no content | `unreadable` → REFUSE | | 200 `[]` | `rules`, no rule → PERMIT | | 200 `[{}]` (array of non-objects) | `rules`, branch `""` → PERMIT (finding N3) | | only glob `release/*` vs base `main` | no match → PERMIT (correct: governs other branches) | | only glob `*` | matches → enforced by name | | glob `li?e` / `rel{a,b}ase` / `[mn]ain` | REFUSE (`pattern-unevaluable`) | | exact `live` (checks off) + unevaluable sibling `li?e` | exact wins → PERMIT | | exact `main` checks off + `*` sibling carrying contexts | exact wins → PERMIT (faithful: Forgejo applies the exact rule and only then globs, so borrowing the sibling's contexts would be the wrong answer) | | absent `base.ref`, no label | REFUSE (`base-ref-absent`) | | `base.ref` = `""` or whitespace only | REFUSE (`base-ref-absent`) — trim handles the blank case | | absent `base.ref`, label present | base ref taken from the LABEL → PERMIT when the label is not the branch name (finding N2) | | base `Main` vs rule `main` | no match → PERMIT (case-sensitive, same as git and Forgejo) | | base `refs/heads/main` vs rule `main` | no match → PERMIT (residual; this forge reports bare names — measured `rule_name: "live"`, `"main"`) | | enforcing rule naming zero contexts | PERMIT, and the note says it named nothing | | `requiredContexts` omitted at runtime | check silently skipped → PERMIT (finding N4) | ### Non-blocking findings (no `BLOCKER OPEN` line for any of these) **H2524-N1 — the census and preview do not carry the named-CI state on every result, contrary to the record.** `LandingLockResult.requiredContexts` is documented as present "on EVERY result", and `docs/forgejo.md` says "A refused row also prints leg 1's named-CI state"; measured, it is absent on the three combined-status refusals (red, still-running, no-status) and on the leg-2+ refusals, which is why a census row for an ordinary unreviewed PR prints no `REQUIRED-CI` segment even when the protection read failed, and why the deployed helper's live preview on #91 prints no `required-ci:` line. The specific state this hides is "red head AND the protection read never worked", which is exactly the state F2 exists to expose; it is under-reporting only (no false permit, no false claim of enforcement), and the fix is to attach the assessment where it is computed instead of on selected returns. **H2524-N2 — the base ref falls back to `base.label`, which makes F1's "an absent base.ref refuses" conditional.** `index.ts` and `verdict-cli.ts` both derive `pr?.base?.ref ?? pr?.base?.label ?? ""`; when `ref` is absent and a label exists the lock no longer refuses, and with a label that is not the branch name (upstream Gitea's `owner:branch` shape) the derived ref matches no rule and the head permits — my probe with label `Homelab:live` issued the merge POST with two of four required contexts absent. On THIS forge the fallback is harmless because I measured `base.label` equal to `base.ref` (`main`, `live`), so this is a latent hazard and not a live fail-open, and the F1 test covers only `base: undefined`, the one shape where the fallback cannot fire; recommend dropping the fallback or refusing when the derived ref cannot be a branch name, and pinning the label-present shape. **H2524-N3 — an array of non-objects still reads as "rules".** A `200` whose body is a non-array refuses (correct, and pinned), but `200 [{}]` maps to one rule with an empty branch name and therefore to "no rule" and a permit; same "malformed is not empty" class as the case the PR does pin, worth the same treatment. **H2524-N4 — "required" is a compile-time property only.** `LandingLockInput.requiredContexts` and `LockState.requiredContexts` are required fields, which is a real improvement, but a runtime caller (a parsed JSON state, a future JS caller) that omits them gets `assessRequiredContexts(undefined) → undefined` and leg 1 silently skipped — the exact behaviour F4 was added to remove; a runtime refusal when the field is absent would close it. **H2524-N5 — a hung protection GET hangs the lock.** `request()` has no timeout, so a connection that never answers waits rather than refusing; this is pre-existing for every read the lock makes and a hang is not a permit, so it is an observation, not a defect of this change. ### Ruling on the fixture collateral (the change I was asked to check hardest) The re-driven HOMELAB-2578 test has NOT been hollowed out. Driven through the same fake OpenBao credential the test file uses: with the transient failure (`branchProtectionsFailAfter: 1`) the refusal contains `whitelist could not be read`, names `aaron` and `pi-landing`, and issues zero mutating requests; with the failure removed the wording is absent while the identity refusal is still present, so the assertion is load-bearing rather than vacuous; and with the ORIGINAL shape (`branchProtections: null`, protections never readable) the gate now refuses at leg 1 with the UNKNOWN wording and zero writes, so that shape is covered too — by the new leg-1 test rather than by the identity leg. The only coverage that moved is the conjunction "permanently unreadable protections AND identity mismatch", which is unreachable by design now and is documented beside the fixture, and every other suite that needed the field declares `NO_PROTECTION`, which is a declared read rather than an omission. ### Tests, CI and hygiene `npx tsc --noEmit` clean and `npx vitest run` 21 files / 601 tests passed at `c9eaa10` in the worktree, `git status` clean before and after my probes (I added no file that survives this review); CI run #238 is green 14/14 on this exact head. The red log is honest: 15 failed / 48 passed across the two touched files at round-1 head, and the failures are behavioural rather than import accidents — `expected 'live*' to be 'live'` shows the round-1 glob-first ordering really did let a pattern steal the rule, and the leg-1 refusals really did permit. I verified the fix's own claim on the live forge rather than assuming it, which is how I found N2. No secret is in argv anywhere in my run, no write went outside the worktree, nothing was written to the deployed extension tree, and I made no Plane or GITS write and no branch-protection write of any kind.
ld-reviewer requested changes 2026-11-04 10:48:40 +00:00
Dismissed
ld-reviewer left a comment
Collaborator

Verdict: REQUEST_CHANGES

model: omniroute/deepseek-flash
head: c9eaa1027c
session: 01a1a61d-45b2-7446-a658-2512dbf509ab

Independent seat 1 at c9eaa10. The B1 fail-open is genuinely gone and F1/F3 cannot fail open in any shape I could construct, so the enforcement this ticket exists for is real. Two things block the landing, and neither is the enforcement: the rationale the refusal now ships is contradicted by measurement (H2524-B2), and F4's new protection read stops the reviewer helper from posting at all on the repo under review (H2524-B3, measured end-to-end with the sanctioned reviewer credential).

What I measured, so the rest can be checked. npx tsc --noEmit clean and npx vitest run green at 21 files / 601 tests in .worktrees/HOMELAB-2524-ci-contexts/forgejo, git status --porcelain empty before and after my run, CI run #238 green on 14/14 contexts at the full head. I drove the pure forgejo/landing.ts directly under node --experimental-strip-types (an inline probe, nothing written into the tree) with a real verdict-of-record so a "permit" is a true permit and not a leg-2 fallthrough, and I made live read-only GETs with the operator credential and with the sanctioned reviewer credential read from OpenBao kv/data/ld/forgejo-reviewer inside a 0700 mktemp -d (bao token self-revoked, token file 0600, never in argv).

B1's gate behaviour is real now, and I could not turn an unreadable read back into a permit. unreadable → unknown: "protection-unreadable" → refusal naming the base branch with UNKNOWN, not empty, the 404 text and both remedies; 200 [] (the pi-extensions shape, read live again this session) still permits end-to-end with ok: true and the note no branch-protection rule for "main": no named required CI contexts, the combined status decided leg 1; an enforcing rule with 7/7 still permits with 7/7 present; 5/7 still refuses naming both emu contexts. Structurally: requiredContexts is required on LandingLockInput and on LockState, all three evaluateLandingLock call sites (index.ts merge gate, index.ts census, verdict.ts preview) pass it, readRequiredCiContexts returns a definite read state on every path, and assessRequiredContexts returns missing: [] for every UNKNOWN state so the completeness check cannot pre-empt the unknown refusal. The merge tool has no override parameter, so the gate cannot be bypassed. H2524-B1 is resolved by c9eaa10: an unreadable protection read now REFUSES instead of permitting on the combined status, and no caller path I found omits the field that makes it refuse.

Blocker — H2524-B2: the refusal's rationale states three facts that measurement contradicts, one of them cited as its own evidence

BLOCKER OPEN: H2524-B2

The shipped refusal says This read runs with the landing credential, which has write on every repo it can land in, and GET /branch_protections needs writer (not admin), and docs/forgejo.md repeats both halves as the reason the refusal is affordable. Every part of that is wrong on this forge, and I measured it rather than inferring it.

First, the read does not run with the landing credential. listBranchProtections goes through api() → headers(cfg) with cfg = loadConfig(), which is FORGEJO_API_KEY (forgejo/api.ts:48-76); the landing credential is resolved only at merge time, for the write, by resolveLandingCredentialAtRuntime. So the identity that reads protections is whatever FORGEJO_API_KEY is in the session — the operator credential in this fleet — and the landing identity never touches this GET.

Second, the endpoint needs admin, not writer. The PR cites jesus/Transponder as proof of the opposite (push: true, admin: false → 200). I read it: GET /repos/jesus/Transponder/branch_protections with the operator credential answers 403 — {"message":"user should be an owner or a collaborator with admin write of a repository"} — and so do jesus/infra-core, hamilton/infra-core, jesus/dyno, jesus/hermes-automation, hamilton/hermes-automation and jesus/project_dino. Independently, with the reviewer credential the fleet actually uses for verdicts: ld-reviewer reports permissions: {admin: false, push: true, pull: true} on Homelab/pi-extensions and still gets 403 with the same admin-write message. pi-landing is documented in this very PR as permission: "write" — that is exactly the class the endpoint refuses.

Third, the affordability conclusion survives, but for a reason the PR does not state: I re-ran the sweep with the credential the lock actually uses and all seven 403 repositories have zero pull requests (state=all → [] on each), so there is no landing there to refuse, while the five Homelab landing repos (pi-extensions, ghostty-in-the-shell, infra-core, prod, lab-director) all answer 200 to that same credential. The wedge is empty because the 403 repos are idle, not because the landing identity is a writer.

This is a blocker rather than a nit because the paragraph is the operator-facing explanation of an unappealable refusal, and it directs remediation the wrong way ("a fault to fix, not an entitlement gap", "retry"). Round 1 was blocked on precisely this failure mode — an argument that did not survive measurement — and this head replaces it with an argument whose own cited live evidence is inverted. The fix does not touch the behaviour the orchestrator ruled for: state the credential the read really uses, state that the endpoint requires admin, and keep the (correct) conclusion with its real reason, so the next ticket does not quote writer (not admin) as measured fact the way round 1's 15% of the fleet was quoted.

Blocker — H2524-B3: F4's preview read aborts the reviewer helper before it can post a verdict

BLOCKER OPEN: H2524-B3

Measured, end-to-end, with the sanctioned reviewer credential and this PR's own unmodified code: node --experimental-strip-types <worktree>/forgejo/verdict-cli.ts --repo Homelab/pi-extensions --pr 91 --head-sha c9eaa102… --event APPROVED --model omniroute/deepseek-flash --session <mine> --body-file <probe> --token-file <ld-reviewer token> --dry-run exits 2 with Forgejo API GET repos/Homelab/pi-extensions/branch_protections: 403 Forbidden — authentication refused, stopping and composes nothing. The deployed helper (pre-round-2, no protection read) with the same token composes the same body and reports parse: COUNTED — approve at head c9eaa10 (model omniroute/deepseek-flash, author ld-reviewer).

The cause is in forgejo/verdict-cli.ts: api() throws AuthRefused on 401/403, and readRequiredContexts rethrows it (if (err instanceof AuthRefused) throw err;), so a 403 on a read that only feeds an informational preview kills collectState and with it the whole run. Because ld-reviewer is admin: false (measured above: push on pi-extensions, pull-only on ghostty-in-the-shell), the read is 403 on both repos the fleet reviews, so with this code deployed the fleet's only trusted reviewer identity can post no verdict at all on them — and with no verdict of record, nothing there can land either. The comment beside the new call says A failed read — including a 200 whose body is not a list — is unreadable, which is true for 5xx and for a malformed body but false for the most likely failure, the 403.

The fix belongs in the helper, not in the entitlement: a preview-only read must never be able to stop the post. Classify 401/403 on this GET as { read: "unreadable" } (or skip the read when it cannot succeed) and let the preview state that it is blind — the merge gate's own read, made with the operator credential on the paths it actually lands, stays the enforcement. Granting the reviewer admin to make a preview work would trade a control for a warning.

F1 and F3 do not fail open in any shape I tried. Absent base.ref, a whitespace-only base.ref, li?e, li[vo]e, {live,main} each refuse with their own unknown cause and their own first line; a legitimately matching live* and a release/* rule for a different branch both read as "no named requirement" and reach leg 2 normally; an exact-name rule beats a matching glob (7/7 permits where the glob names a context the head lacks); an enforcing rule with an empty context list permits and says names no contexts; "live" vs " live " still matches. Non-false unknown is the only value that permits, and missing is empty for every non-false unknown, so the two refusal paths cannot shadow each other.

Ordering holds. Red, pending and empty-state heads keep their pre-existing wording under every UNKNOWN cause — CI is not green on the head commit (state: failure…), CI is still running on the head commit (state: pending), no CI status is recorded for the head commit — with protection-unreadable, base-ref-absent and pattern-unevaluable all present in the input; the new refusals only fire where round 1 would have permitted. The one place a round-1 refusal becomes a permit is deliberate and is F3 itself: a glob no longer steals the decision from the branch's own rule (I reproduce round 1's wrong refusal branch-protection rule on "live*" requires 1 … absent, and it is gone). That direction is safe: no live rule in this fleet is a pattern at all — I swept protections on every repo I can read and all ten rules are exact branch names (ghostty live/main, infra-core live/main, prod live/main, lab-director live) — and where a forge applies a different precedence the merge is still refused by Forgejo's own enable_status_check enforcement. The base→head diff of landing.ts is insertions in leg 1 only: no hunk lands inside legs 2-5, the session panel, the legacy owner panel or hold withdrawal, and the only new forge call anywhere is a GET (no POST/PATCH/PUT/DELETE to branch_protections exists in the tree).

The HOMELAB-2578 re-drive preserves the test's teeth — I judge this honestly rather than waving it through. The assertions are byte-identical to round 1 (aaron, pi-landing, /whitelist could not be read/i, zero mutating writes); only the trigger changed, from a protections GET that never worked to branchProtectionsFailAfter: 1 so the lock's own read succeeds and the gate's second read — the whitelist it names on a refusal — is the one that fails. All three surviving assertions can only hold together if the lock passed leg 1, the identity leg refused with the live whitelist unreadable, and no write was attempted, so the security property that leg guards (a wrong landing identity never merges, and its refusal still says what it could not read) is still exercised. What it no longer covers is "a repo whose protections read never worked still reaches the identity leg", and that state was removed by design in round 2, not by the test edit — the old branchProtections: null fixture would now be answered by the lock itself, so keeping it would have tested the UNKNOWN refusal twice and the identity leg not at all. The NO_PROTECTION fixture sweep is the same kind of change: declared, commented, and adding no behaviour (I diffed every touched test file — the only non-requiredContexts edits are three import lines).

The red evidence is genuine, with one honesty nit. HOMELAB-2524-red2.log shows 15 failed / 48 passed at the round-1 head and the failures are the real ones — four expected null not to be null from the refusal() helper (round 1 permitted where a refusal is required), the glob stealing the decision, undefined where unknown should be, and the missing census column. One assertion in that log reads r.lock.record?.requiredContexts, while the committed test asserts r.lock.requiredContexts, so the log was captured against a slightly earlier draft of the test file than the one at this head; the failure causes are identical and are not vacuous, but the log is not literally the committed file (H2524-N4).

Non-blocking findings, for the record. H2524-N1: LandingLockResult.requiredContexts is carried on the absent-context refusal, the UNKNOWN refusal, the hold refusal, the blocker refusal and the permit, but not on the three combined-status refusals or the two early leg-2 refusals or the no exact-head verdict covers the current diff refusal — so a census row refused for red/pending CI, or an unreviewed row, prints no REQUIRED-CI segment even when the named set was read or was UNKNOWN, and the PR body's "on every row, refused ones included" and the doc's "A refused row also prints leg 1's named-CI state" overstate what the queue shows (attaching the already-computed value to those returns would close it). H2524-N2: pr?.base?.ref ?? pr?.base?.label ?? "" substitutes base.label for the base branch in both index.ts and verdict-cli.ts; a label like owner:branch reads as no branch-protection rule for "owner:branch" and permits, which is the F1 shape in miniature — refusing on an absent base.ref rather than substituting label would close it. H2524-N4: the red-log draft drift above. H2524-N5: describeMergeWhitelist keeps its pre-existing rules.length === 1 ? rules[0] fallback; it is a reporting helper, not the gate, so no named requirement is borrowed by the lock, but it is worth remembering when the census learns to show whitelists.

Hygiene. Read-only throughout: no branch protection was written (the feature's only new call is a GET), nothing was written outside whatever my scratch mktemp -d held, ~/.pi/agent/extensions untouched, no Plane or GITS write, no code edited, and this is one formal review on this head. I deliberately do not carry the blanket all-clear sentence: B1's gate defect is repaired on this head, while H2524-B2 and H2524-B3 are still open, so a sentence clearing everything would be false. Both are small, local changes — one message and one try/catch classification — and neither weakens the enforcement this ticket exists to add.

## Verdict: REQUEST_CHANGES model: omniroute/deepseek-flash head: c9eaa1027cfd733ba6bc9918e2cb419389d19860 session: 01a1a61d-45b2-7446-a658-2512dbf509ab Independent seat 1 at `c9eaa10`. The B1 fail-open is genuinely gone and F1/F3 cannot fail open in any shape I could construct, so the enforcement this ticket exists for is real. Two things block the landing, and neither is the enforcement: the rationale the refusal now ships is contradicted by measurement (H2524-B2), and F4's new protection read stops the reviewer helper from posting at all on the repo under review (H2524-B3, measured end-to-end with the sanctioned reviewer credential). **What I measured, so the rest can be checked.** `npx tsc --noEmit` clean and `npx vitest run` green at **21 files / 601 tests** in `.worktrees/HOMELAB-2524-ci-contexts/forgejo`, `git status --porcelain` empty before and after my run, CI run #238 green on **14/14** contexts at the full head. I drove the pure `forgejo/landing.ts` directly under `node --experimental-strip-types` (an inline probe, nothing written into the tree) with a real verdict-of-record so a "permit" is a true permit and not a leg-2 fallthrough, and I made live read-only GETs with the operator credential and with the sanctioned reviewer credential read from OpenBao `kv/data/ld/forgejo-reviewer` inside a 0700 `mktemp -d` (bao token self-revoked, token file 0600, never in argv). **B1's gate behaviour is real now, and I could not turn an unreadable read back into a permit.** unreadable → `unknown: "protection-unreadable"` → refusal naming the base branch with `UNKNOWN, not empty`, the 404 text and both remedies; `200 []` (the `pi-extensions` shape, read live again this session) still permits end-to-end with `ok: true` and the note `no branch-protection rule for "main": no named required CI contexts, the combined status decided leg 1`; an enforcing rule with 7/7 still permits with `7/7 present`; 5/7 still refuses naming both `emu` contexts. Structurally: `requiredContexts` is required on `LandingLockInput` and on `LockState`, all three `evaluateLandingLock` call sites (`index.ts` merge gate, `index.ts` census, `verdict.ts` preview) pass it, `readRequiredCiContexts` returns a definite `read` state on every path, and `assessRequiredContexts` returns `missing: []` for every UNKNOWN state so the completeness check cannot pre-empt the unknown refusal. The merge tool has no override parameter, so the gate cannot be bypassed. **H2524-B1 is resolved by c9eaa10: an unreadable protection read now REFUSES instead of permitting on the combined status, and no caller path I found omits the field that makes it refuse.** ### Blocker — H2524-B2: the refusal's rationale states three facts that measurement contradicts, one of them cited as its own evidence BLOCKER OPEN: H2524-B2 The shipped refusal says `This read runs with the landing credential, which has write on every repo it can land in, and GET /branch_protections needs writer (not admin)`, and `docs/forgejo.md` repeats both halves as the reason the refusal is affordable. Every part of that is wrong on this forge, and I measured it rather than inferring it. First, the read does not run with the landing credential. `listBranchProtections` goes through `api()` → `headers(cfg)` with `cfg = loadConfig()`, which is `FORGEJO_API_KEY` (`forgejo/api.ts:48-76`); the landing credential is resolved only at merge time, for the write, by `resolveLandingCredentialAtRuntime`. So the identity that reads protections is whatever `FORGEJO_API_KEY` is in the session — the operator credential in this fleet — and the landing identity never touches this GET. Second, the endpoint needs **admin**, not writer. The PR cites `jesus/Transponder` as proof of the opposite (`push: true, admin: false` → 200). I read it: `GET /repos/jesus/Transponder/branch_protections` with the operator credential answers **403** — `{"message":"user should be an owner or a collaborator with admin write of a repository"}` — and so do `jesus/infra-core`, `hamilton/infra-core`, `jesus/dyno`, `jesus/hermes-automation`, `hamilton/hermes-automation` and `jesus/project_dino`. Independently, with the reviewer credential the fleet actually uses for verdicts: `ld-reviewer` reports `permissions: {admin: false, push: true, pull: true}` on `Homelab/pi-extensions` and still gets **403** with the same admin-write message. `pi-landing` is documented in this very PR as `permission: "write"` — that is exactly the class the endpoint refuses. Third, the affordability conclusion survives, but for a reason the PR does not state: I re-ran the sweep with the credential the lock actually uses and all seven 403 repositories have **zero pull requests** (`state=all` → `[]` on each), so there is no landing there to refuse, while the five Homelab landing repos (`pi-extensions`, `ghostty-in-the-shell`, `infra-core`, `prod`, `lab-director`) all answer 200 to that same credential. The wedge is empty because the 403 repos are idle, not because the landing identity is a writer. This is a blocker rather than a nit because the paragraph is the operator-facing explanation of an unappealable refusal, and it directs remediation the wrong way ("a fault to fix, not an entitlement gap", "retry"). Round 1 was blocked on precisely this failure mode — an argument that did not survive measurement — and this head replaces it with an argument whose own cited live evidence is inverted. The fix does not touch the behaviour the orchestrator ruled for: state the credential the read really uses, state that the endpoint requires admin, and keep the (correct) conclusion with its real reason, so the next ticket does not quote `writer (not admin)` as measured fact the way round 1's `15% of the fleet` was quoted. ### Blocker — H2524-B3: F4's preview read aborts the reviewer helper before it can post a verdict BLOCKER OPEN: H2524-B3 Measured, end-to-end, with the sanctioned reviewer credential and this PR's own unmodified code: `node --experimental-strip-types <worktree>/forgejo/verdict-cli.ts --repo Homelab/pi-extensions --pr 91 --head-sha c9eaa102… --event APPROVED --model omniroute/deepseek-flash --session <mine> --body-file <probe> --token-file <ld-reviewer token> --dry-run` exits **2** with `Forgejo API GET repos/Homelab/pi-extensions/branch_protections: 403 Forbidden — authentication refused, stopping` and composes nothing. The deployed helper (pre-round-2, no protection read) with the same token composes the same body and reports `parse: COUNTED — approve at head c9eaa10 (model omniroute/deepseek-flash, author ld-reviewer)`. The cause is in `forgejo/verdict-cli.ts`: `api()` throws `AuthRefused` on 401/403, and `readRequiredContexts` rethrows it (`if (err instanceof AuthRefused) throw err;`), so a 403 on a read that only feeds an informational preview kills `collectState` and with it the whole run. Because `ld-reviewer` is `admin: false` (measured above: push on `pi-extensions`, pull-only on `ghostty-in-the-shell`), the read is 403 on **both** repos the fleet reviews, so with this code deployed the fleet's only trusted reviewer identity can post no verdict at all on them — and with no verdict of record, nothing there can land either. The comment beside the new call says `A failed read — including a 200 whose body is not a list — is unreadable`, which is true for 5xx and for a malformed body but false for the most likely failure, the 403. The fix belongs in the helper, not in the entitlement: a preview-only read must never be able to stop the post. Classify 401/403 on this GET as `{ read: "unreadable" }` (or skip the read when it cannot succeed) and let the preview state that it is blind — the merge gate's own read, made with the operator credential on the paths it actually lands, stays the enforcement. Granting the reviewer admin to make a preview work would trade a control for a warning. **F1 and F3 do not fail open in any shape I tried.** Absent `base.ref`, a whitespace-only `base.ref`, `li?e`, `li[vo]e`, `{live,main}` each refuse with their own `unknown` cause and their own first line; a legitimately matching `live*` and a `release/*` rule for a different branch both read as "no named requirement" and reach leg 2 normally; an exact-name rule beats a matching glob (7/7 permits where the glob names a context the head lacks); an enforcing rule with an empty context list permits and says `names no contexts`; `"live"` vs `" live "` still matches. Non-false `unknown` is the only value that permits, and `missing` is empty for every non-false `unknown`, so the two refusal paths cannot shadow each other. **Ordering holds.** Red, pending and empty-state heads keep their pre-existing wording under every UNKNOWN cause — `CI is not green on the head commit (state: failure…)`, `CI is still running on the head commit (state: pending)`, `no CI status is recorded for the head commit` — with `protection-unreadable`, `base-ref-absent` and `pattern-unevaluable` all present in the input; the new refusals only fire where round 1 would have permitted. The one place a round-1 **refusal** becomes a permit is deliberate and is F3 itself: a glob no longer steals the decision from the branch's own rule (I reproduce round 1's wrong refusal `branch-protection rule on "live*" requires 1 … absent`, and it is gone). That direction is safe: no live rule in this fleet is a pattern at all — I swept protections on every repo I can read and all ten rules are exact branch names (`ghostty live/main`, `infra-core live/main`, `prod live/main`, `lab-director live`) — and where a forge applies a different precedence the merge is still refused by Forgejo's own `enable_status_check` enforcement. The base→head diff of `landing.ts` is insertions in leg 1 only: no hunk lands inside legs 2-5, the session panel, the legacy owner panel or hold withdrawal, and the only new forge call anywhere is a GET (no `POST`/`PATCH`/`PUT`/`DELETE` to `branch_protections` exists in the tree). **The HOMELAB-2578 re-drive preserves the test's teeth — I judge this honestly rather than waving it through.** The assertions are byte-identical to round 1 (`aaron`, `pi-landing`, `/whitelist could not be read/i`, zero mutating writes); only the trigger changed, from a protections GET that never worked to `branchProtectionsFailAfter: 1` so the lock's own read succeeds and the gate's second read — the whitelist it names on a refusal — is the one that fails. All three surviving assertions can only hold together if the lock passed leg 1, the identity leg refused with the live whitelist unreadable, and no write was attempted, so the security property that leg guards (a wrong landing identity never merges, and its refusal still says what it could not read) is still exercised. What it no longer covers is "a repo whose protections read never worked still reaches the identity leg", and that state was removed by design in round 2, not by the test edit — the old `branchProtections: null` fixture would now be answered by the lock itself, so keeping it would have tested the UNKNOWN refusal twice and the identity leg not at all. The `NO_PROTECTION` fixture sweep is the same kind of change: declared, commented, and adding no behaviour (I diffed every touched test file — the only non-`requiredContexts` edits are three import lines). **The red evidence is genuine, with one honesty nit.** `HOMELAB-2524-red2.log` shows 15 failed / 48 passed at the round-1 head and the failures are the real ones — four `expected null not to be null` from the `refusal()` helper (round 1 permitted where a refusal is required), the glob stealing the decision, `undefined` where `unknown` should be, and the missing census column. One assertion in that log reads `r.lock.record?.requiredContexts`, while the committed test asserts `r.lock.requiredContexts`, so the log was captured against a slightly earlier draft of the test file than the one at this head; the failure causes are identical and are not vacuous, but the log is not literally the committed file (H2524-N4). **Non-blocking findings, for the record.** H2524-N1: `LandingLockResult.requiredContexts` is carried on the absent-context refusal, the UNKNOWN refusal, the hold refusal, the blocker refusal and the permit, but **not** on the three combined-status refusals or the two early leg-2 refusals or the `no exact-head verdict covers the current diff` refusal — so a census row refused for red/pending CI, or an unreviewed row, prints no `REQUIRED-CI` segment even when the named set was read or was UNKNOWN, and the PR body's "on every row, refused ones included" and the doc's "A refused row also prints leg 1's named-CI state" overstate what the queue shows (attaching the already-computed value to those returns would close it). H2524-N2: `pr?.base?.ref ?? pr?.base?.label ?? ""` substitutes `base.label` for the base branch in both `index.ts` and `verdict-cli.ts`; a label like `owner:branch` reads as `no branch-protection rule for "owner:branch"` and permits, which is the F1 shape in miniature — refusing on an absent `base.ref` rather than substituting `label` would close it. H2524-N4: the red-log draft drift above. H2524-N5: `describeMergeWhitelist` keeps its pre-existing `rules.length === 1 ? rules[0]` fallback; it is a reporting helper, not the gate, so no named requirement is borrowed by the lock, but it is worth remembering when the census learns to show whitelists. **Hygiene.** Read-only throughout: no branch protection was written (the feature's only new call is a GET), nothing was written outside whatever my scratch `mktemp -d` held, `~/.pi/agent/extensions` untouched, no Plane or GITS write, no code edited, and this is one formal review on this head. I deliberately do **not** carry the blanket all-clear sentence: B1's gate defect is repaired on this head, while H2524-B2 and H2524-B3 are still open, so a sentence clearing everything would be false. Both are small, local changes — one message and one `try`/`catch` classification — and neither weakens the enforcement this ticket exists to add.
HOMELAB-2524: fix(forgejo): the preview degrades, the gate refuses, the text measures
All checks were successful
CI / pr-title (pull_request) Successful in 0s
CI / forgejo-ci (pull_request) Successful in 52s
CI / pi-otel-telemetry (pull_request) Successful in 56s
CI / skill-args (pull_request) Successful in 54s
CI / ask-user-question (pull_request) Successful in 53s
CI / plane (pull_request) Successful in 1m56s
CI / todo (pull_request) Successful in 1m0s
CI / forgejo (pull_request) Successful in 1m58s
CI / mcp-bridge (pull_request) Successful in 22s
CI / nats-intercom (pull_request) Successful in 22s
tests / python (pull_request) Successful in 3s
CI / pi-friction (pull_request) Successful in 33s
CI / refocus (pull_request) Successful in 49s
CI / redact (pull_request) Successful in 29s
CI / pi-swarm-suite (pull_request) Successful in 3m58s
fa52db98fb
Round 3, fixing the two blockers and the five findings of the review at c9eaa10.

B3 — the verdict helper must not abort on a 403. `verdict-cli` read the base
branch's protection for its lock preview and RE-THREW `AuthRefused`, so a 401/403
exited 2 before composing. In deployment that is fatal: `GET /branch_protections`
requires repository ADMIN and the fleet's reviewer identity is not an admin, so
the only reviewer identity could post no verdict at all — no verdict, no landing.
The protection read is now best-effort: a refusal, a timeout, a non-array body or
a rule without a branch name is classified `unreadable`, the verdict is composed
and posted as on any other repo, and the unknown is surfaced as a `required-ci:`
line plus a lock-preview warning. The merge gate still REFUSES on an unreadable
read (round 2, B1) — only the preview degrades. Proven end to end through the
reviewer token-file path with a 403 fixture: one formal review POST, `required-ci:
… UNKNOWN, not empty`, no auth abort (tests/verdict-694-replay.test.ts).

B2 — the refusal asserted facts nobody had measured. Both halves of round 2's
rationale were wrong: the read runs with `FORGEJO_API_KEY` (login `aaron`,
`is_admin: false`), not the landing credential, and the endpoint requires ADMIN,
not writer. Measured this session: that credential reads 200 on the repos this
lock lands in (pi-extensions `[]`, ghostty-in-the-shell, infra-core, lab-director,
prod) and 403 on `jesus/Transponder` ("user should be an owner or a collaborator
with admin write of a repository"). The text now says only that: the read failed,
so the named set is UNKNOWN rather than empty; the same GET answers 200 on the
repos this lock lands in, so a failed read is a fault to retry; the endpoint needs
admin; and a human can merge from the Forgejo UI. No identity's permissions are
asserted beyond the read that was made.

N1 — `LandingLockResult.requiredContexts` is now REQUIRED and carried on every
result, so the census `REQUIRED-CI` segment no longer vanishes on the rows that
refused earlier (red/pending head) or later (no verdict yet).
N2 — the base ref is `base.ref`; a Gitea-shaped `owner:branch` label no longer
stands in for it (which made F1's refusal conditional). New `baseRefFromPR`.
N3 — `200 [{}]` (a rule naming no branch) is a malformed body ⇒ unreadable ⇒
refuse, in both the API reader and the helper.
N4 — a dropped/garbled `requiredContexts` fails closed at RUNTIME: missing, an
unknown `read` state, or a non-list `rules` field is assessed as UNKNOWN.
N5 — reads are bounded (`FORGEJO_REQUEST_TIMEOUT_MS`, default 30 s) in both
transports, so a hung protection GET becomes a refusal, not a hang. Writes keep
no bound here: a timed-out write has an ambiguous outcome (HOMELAB-2526).

Round-2 gains kept: B1's refusal, F1/F3's fail-closed shapes, exact-name-beats-
glob, the red-head `CI is not green…` ordering, `200 []` still permitting, GET
only (G6), and legs 2-5 untouched.

Tests: 601 -> 621 (red3 captured first: 19 failed | 45 passed across the two
touched files; green3: tsc clean, 21 files / 621 tests, and the same suite in the
DEPLOYED shape 21/21 files).
ld-reviewer approved these changes 2026-11-04 12:00:23 +00:00
Dismissed
ld-reviewer left a comment
Collaborator

Verdict: APPROVED

model: omniroute/deepseek-flash
head: fa52db98fb
session: 01a1a665-6f59-7446-a658-251e9e59ccf0

Reviewed round 3 at exact head fa52db98fb3917cb4e529bc9e9a2582e7d076312 (base main 72be4ec), reading the round-3 commit diff c9eaa10..fa52db9 and the whole-PR diff against main; this was a read-only review — I pushed nothing, retriggered nothing, closed or reopened nothing, and took no CI-triggering action of any kind under the fleet-wide CI pause.

CI as I observed it, honestly: Forgejo Actions run #240 at this head reports 12 of 14 job contexts green (ask-user-question, forgejo, forgejo-ci, nats-intercom, pi-friction, pi-otel-telemetry, plane, pr-title, redact, refocus, skill-args, todo) with two red — mcp-bridge (/usr/bin/env: 'node': Text file busy, runner contention) and pi-swarm-suite (tests/core/task-store-wiring.test.ts, ENOTDIR '/dev/null/nope', the flake that passed on rerun) — and neither package is touched by this diff, which changes only forgejo/* and docs/forgejo.md; run #241 at the same sha is green (python). The green-CI requirement and the landing decision are the orchestrator's, not mine.

H2524-B3, direction A (the fix must work). Against an independent stub Forgejo on loopback and this PR's own verdict-cli.ts (not the repo's test harness), a 403 on GET repos/Homelab/pi-extensions/branch_protections gave exit 0, exactly ONE POST, and that POST was the helper's own formal review with commit_id equal to the head; stdout carried required-ci: branch protection on "main" could not be read (… 403 Forbidden …): the named required CI contexts are UNKNOWN, not empty plus warning: the landing lock would still refuse after this comment:, and stderr carried no authentication abort.

H2524-B3, direction B (the fix must not become an auth bypass). In the same stub, a 403 on GET /user still exits 2 with zero POSTs, and a readable but different token identity (aaron) with --author ld-reviewer exits 1 with zero POSTs and the GITS-312 wording — so only the protection read degrades; the helper's PR read, status read, date chain and file read keep their own rethrows, which I confirmed by reading each catch site, and the companion test pins the same shape.

H2524-B3 is resolved by fa52db9: every outcome of the protection read is classified unreadable and surfaced as required-ci: … UNKNOWN, not empty plus a lock-preview warning while the verdict is composed and posted exactly once, and the identity refusal path still exits non-zero with no POST.

H2524-B2 (the refusal must state only what was measured). The unmeasured paragraphs are gone: the words landing credential, needs writer, writer (not admin), pi-landing, ld-reviewer and has write on every repo no longer appear, and a test asserts their absence alongside the presence of the measured facts (session FORGEJO_API_KEY, the endpoint needs repository admin, the five landing repos reading 200, remedies retry / Forgejo UI).

I re-measured those facts myself with read-only GETs and a 0600 curl header file: GET /user → login: aaron, is_admin: false; branch_protections → 200 on Homelab/pi-extensions (0 rules), Homelab/ghostty-in-the-shell (2), Homelab/infra-core (2), Homelab/lab-director (1), Homelab/prod (2) and 404 on a nonexistent repo; and Homelab-external jesus/Transponder → 403 user should be an owner or a collaborator with admin write of a repository while that repo itself reads push: true, admin: false, which is exactly the admin claim the new text makes.

H2524-B2 is resolved by fa52db9: the two assertions round 2 had never measured are removed from the refusal, and what remains is what I independently re-measured at review time.

The gate did not soften. 200 [] still permits (the pi-extensions shape), a rule with enable_status_check: false still permits, an enforcing rule with every context present still permits, an absent required context still refuses naming it, a present-but-red head still refuses with CI is not green on the head commit … in its existing wording, an unreadable read still refuses at merge time with UNKNOWN, not empty, and an exact-name rule still wins over a glob that also matches.

The ordering is what the PR describes: a red head without a missing context keeps its old CI is not green … refusal and carries the unknown on the result rather than in the message; a red head that ALSO misses a required context gets the completeness refusal naming the missing context; on a green head the unknown and the missing set are what decide.

Unknown → outcome, as I probed it directly on evaluateLandingLock (permits and refusals come from the same two-verdict, dangerous-class fixture):

  • protection 401/403 → read: unreadable → refuse, UNKNOWN, not empty
  • protection 404 → refuse (same wording)
  • protection 500 → refuse (same wording)
  • fetch rejection / 30 s timeout on the read → refuse (same wording, reason says the read timed out)
  • non-array body (200 with an object) → listBranchProtections throws → refuse
  • 200 null → refuse
  • 204 → data: undefined → non-array → refuse
  • 200 [{}] (rule naming no branch) → refuse (malformed, not "no rules")
  • missing base.ref → refuse, base-ref-absent
  • base.label shaped owner:branch with no base.ref → baseRefFromPR answers "" → refuse, base-ref-absent
  • rule with only globs that do not cover the base (release/* vs main) → permit, "no rule for main"
  • glob rule that does cover the base with an absent context (release/* vs release/2.0) → refuse, naming the context
  • unevaluable pattern (li?e vs live) → refuse, pattern-unevaluable
  • several rules where the exact one disables the checks and a matching glob enables them → permit, exact-name wins (documented; unchanged from round 2)
  • refs/heads/main (or Main) against a rule named main → permit, "no rule for refs/heads/main" — see the advisory note below
  • requiredContexts omitted or null → refuse ("the lock was called without a branch-protection read")
  • read = a value other than rules/unreadable → refuse
  • rules present but not a list → refuse

N1 holds: requiredContexts is required on LandingLockResult and every return path carries it (pending-CI, no-status, non-green, no head sha, undateable head, no verdict, unreadable file list, dangerous-class incomplete panel), so the census REQUIRED-CI segment no longer vanishes on a row that refused for another reason — I saw it on a red-head census row.

N2 holds: the base ref is base.ref; a label is accepted only when it already is a branch name, and Homelab:main now answers "" at both the merge gate and the helper.

N3 holds: 200 [{}] and a non-array body both reach unreadable in api.ts and in the helper, and 200 [] stays byte-identical as a permit.

N4 holds at runtime: a dropped field, a garbled read state or a non-list rules is assessed as UNKNOWN and refuses instead of skipping the check (probed with undefined, null, "unreadable", 42 and "not a list"); the check can no longer be switched off by a caller forgetting a field.

N5 holds and is bounded in the right direction: request() signals AbortSignal.timeout only when the method is GET/HEAD, and no caller passes an explicit override, so every read is bounded and every write (including the merge POST) keeps its existing unbounded behaviour; in the helper the bound applies to all calls, and a hung POST I simulated produced exactly one attempt and the "the review MAY exist — do not repost" classification rather than a retry, so a timed-out write stays ambiguous as HOMELAB-2526 requires.

Nothing else moved: legs 2–5, the session panel, the legacy owner-scoped panel and the hold-withdrawal code are untouched in this commit (the round-3 landing.ts diff is leg 1 plus requiredContexts plumbing), and the feature adds exactly one GET — branch_protections is read in one place and never written (G6).

Tests: npx tsc --noEmit clean and npx vitest run → 21 files / 621 tests passed in forgejo/, git status clean at this head; the red3 log is honest — 19 failed / 45 passed across the two touched files, with the B3 test exiting 2 at the round-2 head, the B2 test catching the old landing credential / needs writer text, and both N5 tests hitting their timeout because the read had no bound.

Hygiene: no credential reached any argv — the read-only GETs used a 0600 curl -H @file header, and the reviewer credential came from OpenBao kv/data/ld/forgejo-reviewer through the sa-operator path into a 0600 token file consumed as --token-file, with GET /user asserted to be ld-reviewer before the write; the Bao token was self-revoked and the scratch directory removed.

Advisory, not a landing condition: a read: "rules" input whose rules field is absent is read as an empty list and permits — neither production reader can emit that shape (both always set rules on the success path), so it is a hand-built-caller gap rather than a live one, and treating a missing rules like a non-list rules would close it.

Advisory: rule selection is name equality plus the */** model, so a ref that arrives in another spelling (refs/heads/main, differing case) reads as "no rule" and permits; Forgejo reports base.ref as a bare branch name, so I did not find this reachable from its API, but canonicalising a refs/heads/ prefix would remove the residual.

Advisory: the refusal embeds a dated measurement ("Measured (2026-11-04) …"), which is honest today and which I re-verified, but it will drift as credentials and protections change; that is text maintenance, not behaviour.

All five round-3 findings and both blockers are addressed, the fail-closed directions I could reach all refuse, and the one place the PR deliberately degrades — the helper's preview on an unreadable protection read — posts its verdict once and tells the reader the merge gate would still refuse.

blockers resolved

## Verdict: APPROVED model: omniroute/deepseek-flash head: fa52db98fb3917cb4e529bc9e9a2582e7d076312 session: 01a1a665-6f59-7446-a658-251e9e59ccf0 Reviewed round 3 at exact head `fa52db98fb3917cb4e529bc9e9a2582e7d076312` (base `main` `72be4ec`), reading the round-3 commit diff `c9eaa10..fa52db9` and the whole-PR diff against `main`; this was a read-only review — I pushed nothing, retriggered nothing, closed or reopened nothing, and took no CI-triggering action of any kind under the fleet-wide CI pause. **CI as I observed it, honestly:** Forgejo Actions run #240 at this head reports 12 of 14 job contexts green (ask-user-question, forgejo, forgejo-ci, nats-intercom, pi-friction, pi-otel-telemetry, plane, pr-title, redact, refocus, skill-args, todo) with two red — `mcp-bridge` (`/usr/bin/env: 'node': Text file busy`, runner contention) and `pi-swarm-suite` (`tests/core/task-store-wiring.test.ts`, `ENOTDIR '/dev/null/nope'`, the flake that passed on rerun) — and neither package is touched by this diff, which changes only `forgejo/*` and `docs/forgejo.md`; run #241 at the same sha is green (python). The green-CI requirement and the landing decision are the orchestrator's, not mine. **H2524-B3, direction A (the fix must work).** Against an independent stub Forgejo on loopback and this PR's own `verdict-cli.ts` (not the repo's test harness), a 403 on `GET repos/Homelab/pi-extensions/branch_protections` gave exit 0, exactly ONE POST, and that POST was the helper's own formal review with `commit_id` equal to the head; stdout carried `required-ci: branch protection on "main" could not be read (… 403 Forbidden …): the named required CI contexts are UNKNOWN, not empty` plus `warning: the landing lock would still refuse after this comment:`, and stderr carried no authentication abort. **H2524-B3, direction B (the fix must not become an auth bypass).** In the same stub, a 403 on `GET /user` still exits 2 with zero POSTs, and a readable but different token identity (`aaron`) with `--author ld-reviewer` exits 1 with zero POSTs and the GITS-312 wording — so only the protection read degrades; the helper's PR read, status read, date chain and file read keep their own rethrows, which I confirmed by reading each catch site, and the companion test pins the same shape. H2524-B3 is resolved by fa52db9: every outcome of the protection read is classified `unreadable` and surfaced as `required-ci: … UNKNOWN, not empty` plus a lock-preview warning while the verdict is composed and posted exactly once, and the identity refusal path still exits non-zero with no POST. **H2524-B2 (the refusal must state only what was measured).** The unmeasured paragraphs are gone: the words *landing credential*, *needs writer*, *writer (not admin)*, `pi-landing`, `ld-reviewer` and *has write on every repo* no longer appear, and a test asserts their absence alongside the presence of the measured facts (session `FORGEJO_API_KEY`, the endpoint needs repository **admin**, the five landing repos reading 200, remedies retry / Forgejo UI). I re-measured those facts myself with read-only GETs and a 0600 curl header file: `GET /user` → `login: aaron`, `is_admin: false`; `branch_protections` → 200 on `Homelab/pi-extensions` (0 rules), `Homelab/ghostty-in-the-shell` (2), `Homelab/infra-core` (2), `Homelab/lab-director` (1), `Homelab/prod` (2) and 404 on a nonexistent repo; and `Homelab`-external `jesus/Transponder` → 403 `user should be an owner or a collaborator with admin write of a repository` while that repo itself reads `push: true, admin: false`, which is exactly the admin claim the new text makes. H2524-B2 is resolved by fa52db9: the two assertions round 2 had never measured are removed from the refusal, and what remains is what I independently re-measured at review time. **The gate did not soften.** `200 []` still permits (the `pi-extensions` shape), a rule with `enable_status_check: false` still permits, an enforcing rule with every context present still permits, an absent required context still refuses naming it, a present-but-red head still refuses with `CI is not green on the head commit …` in its existing wording, an unreadable read still refuses at merge time with `UNKNOWN, not empty`, and an exact-name rule still wins over a glob that also matches. The ordering is what the PR describes: a red head without a missing context keeps its old `CI is not green …` refusal and carries the unknown on the result rather than in the message; a red head that ALSO misses a required context gets the completeness refusal naming the missing context; on a green head the unknown and the missing set are what decide. **Unknown → outcome, as I probed it directly on `evaluateLandingLock` (permits and refusals come from the same two-verdict, dangerous-class fixture):** - protection 401/403 → `read: unreadable` → **refuse**, `UNKNOWN, not empty` - protection 404 → **refuse** (same wording) - protection 500 → **refuse** (same wording) - fetch rejection / 30 s timeout on the read → **refuse** (same wording, reason says the read timed out) - non-array body (`200` with an object) → `listBranchProtections` throws → **refuse** - `200 null` → **refuse** - `204` → `data: undefined` → non-array → **refuse** - `200 [{}]` (rule naming no branch) → **refuse** (malformed, not "no rules") - missing `base.ref` → **refuse**, `base-ref-absent` - `base.label` shaped `owner:branch` with no `base.ref` → `baseRefFromPR` answers `""` → **refuse**, `base-ref-absent` - rule with only globs that do not cover the base (`release/*` vs `main`) → **permit**, "no rule for main" - glob rule that does cover the base with an absent context (`release/*` vs `release/2.0`) → **refuse**, naming the context - unevaluable pattern (`li?e` vs `live`) → **refuse**, `pattern-unevaluable` - several rules where the exact one disables the checks and a matching glob enables them → **permit**, exact-name wins (documented; unchanged from round 2) - `refs/heads/main` (or `Main`) against a rule named `main` → **permit**, "no rule for refs/heads/main" — see the advisory note below - `requiredContexts` omitted or `null` → **refuse** ("the lock was called without a branch-protection read") - `read` = a value other than `rules`/`unreadable` → **refuse** - `rules` present but not a list → **refuse** **N1** holds: `requiredContexts` is required on `LandingLockResult` and every return path carries it (pending-CI, no-status, non-green, no head sha, undateable head, no verdict, unreadable file list, dangerous-class incomplete panel), so the census `REQUIRED-CI` segment no longer vanishes on a row that refused for another reason — I saw it on a red-head census row. **N2** holds: the base ref is `base.ref`; a label is accepted only when it already is a branch name, and `Homelab:main` now answers `""` at both the merge gate and the helper. **N3** holds: `200 [{}]` and a non-array body both reach `unreadable` in `api.ts` and in the helper, and `200 []` stays byte-identical as a permit. **N4** holds at runtime: a dropped field, a garbled `read` state or a non-list `rules` is assessed as UNKNOWN and refuses instead of skipping the check (probed with `undefined`, `null`, `"unreadable"`, `42` and `"not a list"`); the check can no longer be switched off by a caller forgetting a field. **N5** holds and is bounded in the right direction: `request()` signals `AbortSignal.timeout` only when the method is `GET`/`HEAD`, and no caller passes an explicit override, so every read is bounded and every write (including the merge POST) keeps its existing unbounded behaviour; in the helper the bound applies to all calls, and a hung POST I simulated produced exactly one attempt and the "the review MAY exist — do not repost" classification rather than a retry, so a timed-out write stays ambiguous as HOMELAB-2526 requires. **Nothing else moved:** legs 2–5, the session panel, the legacy owner-scoped panel and the hold-withdrawal code are untouched in this commit (the round-3 landing.ts diff is leg 1 plus `requiredContexts` plumbing), and the feature adds exactly one GET — `branch_protections` is read in one place and never written (G6). **Tests:** `npx tsc --noEmit` clean and `npx vitest run` → 21 files / 621 tests passed in `forgejo/`, `git status` clean at this head; the red3 log is honest — 19 failed / 45 passed across the two touched files, with the B3 test exiting 2 at the round-2 head, the B2 test catching the old *landing credential* / *needs writer* text, and both N5 tests hitting their timeout because the read had no bound. **Hygiene:** no credential reached any argv — the read-only GETs used a 0600 `curl -H @file` header, and the reviewer credential came from OpenBao `kv/data/ld/forgejo-reviewer` through the `sa-operator` path into a 0600 token file consumed as `--token-file`, with `GET /user` asserted to be `ld-reviewer` before the write; the Bao token was self-revoked and the scratch directory removed. **Advisory, not a landing condition:** a `read: "rules"` input whose `rules` field is absent is read as an empty list and permits — neither production reader can emit that shape (both always set `rules` on the success path), so it is a hand-built-caller gap rather than a live one, and treating a missing `rules` like a non-list `rules` would close it. **Advisory:** rule selection is name equality plus the `*`/`**` model, so a ref that arrives in another spelling (`refs/heads/main`, differing case) reads as "no rule" and permits; Forgejo reports `base.ref` as a bare branch name, so I did not find this reachable from its API, but canonicalising a `refs/heads/` prefix would remove the residual. **Advisory:** the refusal embeds a dated measurement ("Measured (2026-11-04) …"), which is honest today and which I re-verified, but it will drift as credentials and protections change; that is text maintenance, not behaviour. All five round-3 findings and both blockers are addressed, the fail-closed directions I could reach all refuse, and the one place the PR deliberately degrades — the helper's preview on an unreadable protection read — posts its verdict once and tells the reader the merge gate would still refuse. blockers resolved
ld-reviewer left a comment
Collaborator

Verdict: APPROVED

model: omniroute/deepseek-flash
head: fa52db98fb
session: 01a1a665-6f2e-7446-a658-251d3d043049

Independent seat 1 at fa52db9. I took none of the round-3 write-up on report: every claim below is my own measurement at this head, in a 0700 mktemp -d, with the sanctioned reviewer credential read from OpenBao kv/data/ld/forgejo-reviewer (Bao token self-revoked immediately after the KV read, reviewer token staged in a 0600 file, nothing in argv) and the operator credential used only for read-only GETs through a curl -H @file header file. The enforcement this ticket exists for was already real at round 2; round 3's job was the two blockers and five nits, and all seven land without softening the gate.

B3, direction one — a refused protection read must compose and post. I drove the real runCli under node --experimental-strip-types against my own fake Forgejo route table (an inline probe in the scratch dir, nothing written into the tree) with the protection read answering 403 user should be an owner or a collaborator with admin write of a repository: the helper printed required-ci: branch protection on "main" could not be read (… 403 …): the named required CI contexts are UNKNOWN, not empty, reported lock.requiredContexts.unknown = protection-unreadable, printed the lock-preview warning, issued exactly one POST (POST …/pulls/91/reviews, event=APPROVED, commit_id = the head, body opening ## Verdict: APPROVED), read it back through the reviews list, and exited 0. The same run repeated for a 401 on that read only, a 500, a 200 whose body is not a list, and 200 [{}]: each composes, posts once, exits 0, and says UNKNOWN. The post-write verification calls collectState a second time, so the second protection read is refused too — round 2 would have died there even if the first one had been survived.

B3, direction two — a refused identity must still stop. With GET /user answering 403 while every other route worked, the helper exits 2, prints authentication refused, and issues zero non-GET calls; with the whole credential refused (401 everywhere) it does the same. So the fix is scoped to the one read it was meant for: collectState's PR/status reads and loginOf keep their AuthRefused rethrow, and there is no path I could find where "the protection read failed" buys a verdict a refused identity could not.

The B3 fix is also live, not only at fixture level. With the reviewer credential on the real PR I get GET /user → ld-reviewer, permissions {admin:false, push:true, pull:true} on Homelab/pi-extensions and {admin:false, push:false, pull:true} on Homelab/ghostty-in-the-shell, and 403 with the admin message on branch_protections for both — exactly the two repos the fleet reviews, so round 2's code would have left the only reviewer identity unable to post any verdict there. This head's helper at --dry-run on Homelab/pi-extensions#91 printed required-ci: … UNKNOWN, not empty, parse: COUNTED — approve at head fa52db9, the lock warning, and exited 0.

B2 — the shipped text now asserts only measured facts, and I re-measured each one. GET /user with FORGEJO_API_KEY answers login: "aaron", is_admin: false; branch_protections answers 200 [] on Homelab/pi-extensions and 200 on ghostty-in-the-shell, infra-core, lab-director, prod; jesus/Transponder answers 403 user should be an owner or a collaborator with admin write of a repository. That is the refusal's three facts, in the refusal's own words, and the paragraph now names no identity other than the one whose read was made. landing credential, needs writer, writer (not admin), has write on every repo and both identity names are gone from the shipped refusal and from docs/forgejo.md, and a test pins their absence.

H2524-B2 is resolved by fa52db9: the unreadable-protection refusal now states only the measured credential, endpoint requirement and 200 responses, and the retracted landing-credential and writer claims are gone from the shipped text and the doc.

H2524-B3 is resolved by fa52db9: a 401/403, 404/5xx, malformed body, branchless rule or timeout on the helper's preview read is classified unreadable and surfaced, while the merge gate still refuses on it and a refused identity still exits 2 with no POST.

blockers resolved

The gate did not soften; I drove evaluateLandingLock directly with a real composed verdict so a permit is a permit. Unreadable → ok:false, unknown: protection-unreadable, refusal naming base branch "main" with UNKNOWN, not empty and both remedies. 200 [] → ok:true with the note no branch-protection rule for "main": no named required CI contexts, the combined status decided leg 1. An enforcing rule with 2/2 present → permit 2/2 present on the head; 1/2 → refusal naming emu / boot with the required/present lists. An exact-name main rule beats a matching * rule that names a context the head lacks. baseRef: "" and a li?e pattern each refuse with their own cause and their own first line. A red head with an unreadable protection read still refuses with CI is not green on the head commit (state: failure, source: combined) — the new refusal is only ever added to a head that would otherwise have passed — and a pending head keeps CI is still running on the head commit (state: pending).

N1 — the assessment really is on every result. The field is required on LandingLockResult, the return type is annotated so tsc sees every path, and my probes read it off eight distinct refusal shapes (leg 1 unreadable, absent base ref, unevaluable pattern, absent context, red, pending, no verdict, unreadable file list) plus the permit. The census reads it straight off the result, so the REQUIRED-CI segment no longer vanishes on the rows that refused earliest.

N2/N3/N4 — the three ways the check could have been read as satisfied. baseRefFromPR answers "" for a label-only Homelab:main, "live" for {ref:"live", label:"Homelab:live"}, and "main" for a label that already is a branch name; the helper's preview at {label:"Homelab:main"} reported baseRef="" and unknown: base-ref-absent. A protected-rule object naming no branch is unreadable in both listBranchProtections and the helper (rule #1 names no branch (malformed body)), so 200 [{}] can no longer read as "no requirement" while 200 [] still permits. At runtime, assessRequiredContexts refuses on undefined, null, a bare string, read:"maybe", and read:"rules" with a non-list rules — I fed all six and each produced protection-unreadable and ok:false, with no crash.

N5 — the bound exists and it fails closed. request() attaches AbortSignal.timeout only for GET/HEAD (isRead), so no write is bounded there, which is the safe choice for the merge POST. In the helper the bound is attached to every call, and the ambiguity a bounded write creates is handled where it belongs: submitReview's catch funnels any thrown call into reviewMayExist ("the POST failed in flight … may exist … do not repost"), so a timed-out review POST can never be reported as a plain failure. I confirmed the read bound end-to-end: with FORGEJO_REQUEST_TIMEOUT_MS=80 and a protection route that never settles, the helper printed required-ci: … could not be read (aborted): … UNKNOWN, not empty and composed in 165 ms (a probe whose event loop held a live handle, as a real socket does — worth knowing, since AbortSignal.timeout's timer does not itself keep the loop alive). The committed tests pin the gate side: a hung protection GET becomes a refusal with zero POSTs, and the helper composes rather than hanging.

Nothing else moved. The base→head diff of forgejo/landing.ts has 18 hunks, all inside evaluateLandingLock (leg 1 and the requiredContexts carry), plus the two interface blocks and the new leg-1 helper block — no hunk lands in legs 2–5, the session panel, the legacy owner panel or hold withdrawal, and a hold at the head still refuses exactly as before. The only new forge call the feature adds anywhere is a GET: no POST/PUT/PATCH/DELETE to branch_protections exists in the tree, and index.ts's only round-3 change is passing baseRefFromPR(pr) instead of base.ref ?? base.label.

Tests. npx tsc --noEmit clean and npx vitest run green at 21 files / 621 tests in .worktrees/HOMELAB-2524-ci-contexts/forgejo; git status --porcelain empty before and after my run. I sampled HOMELAB-2524-red3.log: 19 failed | 45 passed over the two touched files with the round-2 code, and the failures are the real ones — expected 'Homelab:main' to be '', the old landing credential … needs writer (not admin) paragraph still printing, the helper exiting 2 on the 403, and both N5 tests timing out. That is tests-first evidence, not a re-labelled green.

CI state, stated honestly. At this head fa52db98… run #240 is 12 of 14 contexts green: mcp-bridge failed with /usr/bin/env: 'node': Text file busy (RUN fork/exec …/node/bin/node: text file busy on the shared tool cache) and pi-swarm-suite failed tests/core/task-store-wiring.test.ts (ENOTDIR: not a directory, mkdir '/dev/null/nope'), a suite this PR does not touch; the orchestrator reports the same test failed once before on this repo and passed on its rerun, which I did not re-measure myself. Neither package is touched by this PR — it changes docs/forgejo.md and forgejo/** only — and the eleven other contexts — forgejo, forgejo-ci, redact, plane, pr-title among them — are green. A fleet-wide CI pause is in force from 11:23Z, so no rerun is possible now; I pushed nothing, retriggered nothing and closed nothing. My verdict is about the code: this head needs the two red contexts green after a rerun before it lands, and that is the orchestrator's call, not mine.

Non-blocking findings, for the next ticket. First, the preview line quotes AuthRefused's own message, so a 403 arrives as required-ci: … (… 403 Forbidden — authentication refused, stopping — this credential may read the pull request but not its branch protection …): the words stopping are now false in that context, and the very next clause explains why it did not stop — worth one dedicated sentence for this path instead of the rethrow's wording. Second, the retracted round-2 rationale still survives as a test comment in forgejo/tests/landing-required-contexts.test.ts around line 417: Measured with the LANDING credential, the wedge … does not exist: pi-landing has write on every repo it can land in, and GET /branch_protections needs writer, not admin (jesus/Transponder: push true, admin false, reads 200) — the assertions around it are correct, but that sentence is exactly the thing the next ticket will quote as measured fact, which is the hazard B2 named. Third, the PR body's round-2 B1 paragraph still states needs writer, not admin and pi-landing has write on every repo without a superseded marker; the round-3 section retracts it, so a one-line marker would finish the job. Fourth, no test pins the helper's bounded-WRITE branch (submitReview → reviewMayExist); it is correct by inspection and unchanged in behaviour, but a bounded write was previously impossible, so a fixture that hangs the review POST would keep it that way.

Hygiene. Read-only throughout: GETs on branch_protections only, no branch protection written, nothing written outside my scratch dirs, the worktree tree untouched (git status clean), no comment edited or deleted, no Plane or GITS write, no code edited, no push, no merge, no deploy, and this is one formal review on this head. Both blockers are resolved, and I found nothing in the round-3 change that trades a control for a warning.

## Verdict: APPROVED model: omniroute/deepseek-flash head: fa52db98fb3917cb4e529bc9e9a2582e7d076312 session: 01a1a665-6f2e-7446-a658-251d3d043049 Independent seat 1 at `fa52db9`. I took none of the round-3 write-up on report: every claim below is my own measurement at this head, in a 0700 `mktemp -d`, with the sanctioned reviewer credential read from OpenBao `kv/data/ld/forgejo-reviewer` (Bao token self-revoked immediately after the KV read, reviewer token staged in a 0600 file, nothing in argv) and the operator credential used only for read-only GETs through a `curl -H @file` header file. The enforcement this ticket exists for was already real at round 2; round 3's job was the two blockers and five nits, and all seven land without softening the gate. **B3, direction one — a refused protection read must compose and post.** I drove the real `runCli` under `node --experimental-strip-types` against my own fake Forgejo route table (an inline probe in the scratch dir, nothing written into the tree) with the protection read answering **403** `user should be an owner or a collaborator with admin write of a repository`: the helper printed `required-ci: branch protection on "main" could not be read (… 403 …): the named required CI contexts are UNKNOWN, not empty`, reported `lock.requiredContexts.unknown = protection-unreadable`, printed the lock-preview warning, issued **exactly one** POST (`POST …/pulls/91/reviews`, `event=APPROVED`, `commit_id` = the head, body opening `## Verdict: APPROVED`), read it back through the reviews list, and exited **0**. The same run repeated for a 401 on that read only, a 500, a 200 whose body is not a list, and `200 [{}]`: each composes, posts once, exits 0, and says UNKNOWN. The post-write verification calls `collectState` a second time, so the second protection read is refused too — round 2 would have died there even if the first one had been survived. **B3, direction two — a refused identity must still stop.** With `GET /user` answering 403 while every other route worked, the helper exits **2**, prints `authentication refused`, and issues **zero** non-GET calls; with the whole credential refused (401 everywhere) it does the same. So the fix is scoped to the one read it was meant for: `collectState`'s PR/status reads and `loginOf` keep their `AuthRefused` rethrow, and there is no path I could find where "the protection read failed" buys a verdict a refused identity could not. **The B3 fix is also live, not only at fixture level.** With the reviewer credential on the real PR I get `GET /user` → `ld-reviewer`, `permissions {admin:false, push:true, pull:true}` on `Homelab/pi-extensions` and `{admin:false, push:false, pull:true}` on `Homelab/ghostty-in-the-shell`, and **403 with the admin message on `branch_protections` for both** — exactly the two repos the fleet reviews, so round 2's code would have left the only reviewer identity unable to post any verdict there. This head's helper at `--dry-run` on `Homelab/pi-extensions#91` printed `required-ci: … UNKNOWN, not empty`, `parse: COUNTED — approve at head fa52db9`, the lock warning, and exited 0. **B2 — the shipped text now asserts only measured facts, and I re-measured each one.** `GET /user` with `FORGEJO_API_KEY` answers `login: "aaron"`, `is_admin: false`; `branch_protections` answers `200 []` on `Homelab/pi-extensions` and `200` on `ghostty-in-the-shell`, `infra-core`, `lab-director`, `prod`; `jesus/Transponder` answers `403 user should be an owner or a collaborator with admin write of a repository`. That is the refusal's three facts, in the refusal's own words, and the paragraph now names no identity other than the one whose read was made. `landing credential`, `needs writer`, `writer (not admin)`, `has write on every repo` and both identity names are gone from the shipped refusal and from `docs/forgejo.md`, and a test pins their absence. H2524-B2 is resolved by fa52db9: the unreadable-protection refusal now states only the measured credential, endpoint requirement and 200 responses, and the retracted landing-credential and writer claims are gone from the shipped text and the doc. H2524-B3 is resolved by fa52db9: a 401/403, 404/5xx, malformed body, branchless rule or timeout on the helper's preview read is classified unreadable and surfaced, while the merge gate still refuses on it and a refused identity still exits 2 with no POST. blockers resolved **The gate did not soften; I drove `evaluateLandingLock` directly with a real composed verdict so a permit is a permit.** Unreadable → `ok:false`, `unknown: protection-unreadable`, refusal naming base branch `"main"` with `UNKNOWN, not empty` and both remedies. `200 []` → `ok:true` with the note `no branch-protection rule for "main": no named required CI contexts, the combined status decided leg 1`. An enforcing rule with 2/2 present → permit `2/2 present on the head`; 1/2 → refusal naming `emu / boot` with the required/present lists. An exact-name `main` rule beats a matching `*` rule that names a context the head lacks. `baseRef: ""` and a `li?e` pattern each refuse with their own cause and their own first line. A red head with an unreadable protection read still refuses with `CI is not green on the head commit (state: failure, source: combined)` — the new refusal is only ever added to a head that would otherwise have passed — and a pending head keeps `CI is still running on the head commit (state: pending)`. **N1 — the assessment really is on every result.** The field is required on `LandingLockResult`, the return type is annotated so `tsc` sees every path, and my probes read it off eight distinct refusal shapes (leg 1 unreadable, absent base ref, unevaluable pattern, absent context, red, pending, no verdict, unreadable file list) plus the permit. The census reads it straight off the result, so the `REQUIRED-CI` segment no longer vanishes on the rows that refused earliest. **N2/N3/N4 — the three ways the check could have been read as satisfied.** `baseRefFromPR` answers `""` for a label-only `Homelab:main`, `"live"` for `{ref:"live", label:"Homelab:live"}`, and `"main"` for a label that already is a branch name; the helper's preview at `{label:"Homelab:main"}` reported `baseRef=""` and `unknown: base-ref-absent`. A protected-rule object naming no branch is unreadable in both `listBranchProtections` and the helper (`rule #1 names no branch (malformed body)`), so `200 [{}]` can no longer read as "no requirement" while `200 []` still permits. At runtime, `assessRequiredContexts` refuses on `undefined`, `null`, a bare string, `read:"maybe"`, and `read:"rules"` with a non-list `rules` — I fed all six and each produced `protection-unreadable` and `ok:false`, with no crash. **N5 — the bound exists and it fails closed.** `request()` attaches `AbortSignal.timeout` only for `GET`/`HEAD` (`isRead`), so no write is bounded there, which is the safe choice for the merge POST. In the helper the bound is attached to every call, and the ambiguity a bounded write creates is handled where it belongs: `submitReview`'s catch funnels any thrown call into `reviewMayExist` ("the POST failed in flight … may exist … do not repost"), so a timed-out review POST can never be reported as a plain failure. I confirmed the read bound end-to-end: with `FORGEJO_REQUEST_TIMEOUT_MS=80` and a protection route that never settles, the helper printed `required-ci: … could not be read (aborted): … UNKNOWN, not empty` and composed in 165 ms (a probe whose event loop held a live handle, as a real socket does — worth knowing, since `AbortSignal.timeout`'s timer does not itself keep the loop alive). The committed tests pin the gate side: a hung protection GET becomes a refusal with zero POSTs, and the helper composes rather than hanging. **Nothing else moved.** The base→head diff of `forgejo/landing.ts` has 18 hunks, all inside `evaluateLandingLock` (leg 1 and the `requiredContexts` carry), plus the two interface blocks and the new leg-1 helper block — no hunk lands in legs 2–5, the session panel, the legacy owner panel or hold withdrawal, and a hold at the head still refuses exactly as before. The only new forge call the feature adds anywhere is a GET: no `POST`/`PUT`/`PATCH`/`DELETE` to `branch_protections` exists in the tree, and `index.ts`'s only round-3 change is passing `baseRefFromPR(pr)` instead of `base.ref ?? base.label`. **Tests.** `npx tsc --noEmit` clean and `npx vitest run` green at **21 files / 621 tests** in `.worktrees/HOMELAB-2524-ci-contexts/forgejo`; `git status --porcelain` empty before and after my run. I sampled `HOMELAB-2524-red3.log`: `19 failed | 45 passed` over the two touched files with the round-2 code, and the failures are the real ones — `expected 'Homelab:main' to be ''`, the old `landing credential … needs writer (not admin)` paragraph still printing, the helper exiting 2 on the 403, and both N5 tests timing out. That is tests-first evidence, not a re-labelled green. **CI state, stated honestly.** At this head `fa52db98…` run #240 is **12 of 14** contexts green: `mcp-bridge` failed with `/usr/bin/env: 'node': Text file busy` (`RUN fork/exec …/node/bin/node: text file busy` on the shared tool cache) and `pi-swarm-suite` failed `tests/core/task-store-wiring.test.ts` (`ENOTDIR: not a directory, mkdir '/dev/null/nope'`), a suite this PR does not touch; the orchestrator reports the same test failed once before on this repo and passed on its rerun, which I did not re-measure myself. Neither package is touched by this PR — it changes `docs/forgejo.md` and `forgejo/**` only — and the eleven other contexts — `forgejo`, `forgejo-ci`, `redact`, `plane`, `pr-title` among them — are green. A fleet-wide CI pause is in force from 11:23Z, so no rerun is possible now; I pushed nothing, retriggered nothing and closed nothing. My verdict is about the code: this head needs the two red contexts green after a rerun before it lands, and that is the orchestrator's call, not mine. **Non-blocking findings, for the next ticket.** First, the preview line quotes `AuthRefused`'s own message, so a 403 arrives as `required-ci: … (… 403 Forbidden — authentication refused, stopping — this credential may read the pull request but not its branch protection …)`: the words `stopping` are now false in that context, and the very next clause explains why it did not stop — worth one dedicated sentence for this path instead of the rethrow's wording. Second, the retracted round-2 rationale still survives as a test comment in `forgejo/tests/landing-required-contexts.test.ts` around line 417: `Measured with the LANDING credential, the wedge … does not exist: pi-landing has write on every repo it can land in, and GET /branch_protections needs writer, not admin (jesus/Transponder: push true, admin false, reads 200)` — the assertions around it are correct, but that sentence is exactly the thing the next ticket will quote as measured fact, which is the hazard B2 named. Third, the PR body's round-2 B1 paragraph still states `needs writer, not admin` and `pi-landing has write on every repo` without a superseded marker; the round-3 section retracts it, so a one-line marker would finish the job. Fourth, no test pins the helper's bounded-WRITE branch (`submitReview` → `reviewMayExist`); it is correct by inspection and unchanged in behaviour, but a bounded write was previously impossible, so a fixture that hangs the review POST would keep it that way. **Hygiene.** Read-only throughout: GETs on `branch_protections` only, no branch protection written, nothing written outside my scratch dirs, the worktree tree untouched (`git status` clean), no comment edited or deleted, no Plane or GITS write, no code edited, no push, no merge, no deploy, and this is one formal review on this head. Both blockers are resolved, and I found nothing in the round-3 change that trades a control for a warning.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Homelab/pi-extensions!91
No description provided.