Second half of #28, which fixed the cookie collision, the 5-minute session and the missing metrics listener in one renderer function and deliberately declined --api-route — because the config model could not express the difference between two apps, and I have preserved that reasoning rather than worked around it.
The defect
oauth2-proxy answers an unauthenticated request with a 302 to Keycloak. Its built-in AJAX guard would send a 401 instead, but it fires only on an exactAccept: application/json, which these apps' JavaScript does not consistently send. So a browser XHR gets a 302, follows it invisibly, has it satisfied silently by the live SSO session, and resolves 200 carrying the app's HTML where the JavaScript expected JSON — no prompt, no console error, nothing logged as a failure. On a write it is worse: a browser following a 302 re-issues a POST as a GET and drops the body, so the operator's change is discarded and the UI reports success.
Measured, on two apps and two different paths — which is the whole argument:
App
Measurement
jackett, 2026-09-30
33 × 302 against 2 × 401 on /api/ paths, and 4 of 6 POSTs answered by the proxy and never reaching Jackett (access-log upstream field -, not /)
radarr, 2026-10-01, 23 h after #28 deployed (radarr-7d55cbffb9-42hmh, 0 restarts)
232 unauthenticated requests in the pod's entire lifetime, and all 232 are the same call: POST /signalr/messages/negotiate, browser UA, 302, upstream -
That Radarr number is the live proof this change is needed: a left-open browser tab whose session the cookie rename orphaned, polling SignalR forever, silently redirected every time, and never re-prompting the operator. That is the reported symptom — "reloading never fixes it and I am never re-prompted" — still happening in production, on a service nobody was looking at.
So radarr's broken path is /signalr/, jackett's is /api/, and PiHole serves no /api/ at all. One function renders all five sidecars, so the cheap fix is a renderer constant — and a constant applies one app's measurement to four apps nobody measured. The auth block was additionalProperties: false, so there was no per-app hook. Creating the hook is the change; setting the flag is the easy part.
optional auth.apiRoutes: a list of anchored path regexes, uniqueItems, default []; plus the invariant that a non-empty list requires mode: forward-auth
rejects a too-broad regex where a human types it — ci/l0.sh validates catalog/*.yaml against the schema on every gate run
catalog/render.py
emits one --api-route=<r> per entry, in order; raises on an unanchored entry
render-golden runs on every gate invocation even when the schema stage skips
cross-checks each rendered sidecar against its own ServiceDefinition
catches a renderer constant, which neither of the above can see
The auth block stays additionalProperties: false — one named, documented key, not an opened door. That property is what made "there is no override hook" a fact rather than a guess for the two engineers who hit it before me.
A list, not a single string, because Radarr needs two and --api-route is natively repeatable. A single string would have forced alternation into the value, and alternation is how breadth gets smuggled past a prefix-only reading of a regex.
The flag is --api-route, singular — verified against the pinned binary rather than taken from the brief: podman run --rm quay.io/oauth2-proxy/oauth2-proxy:v7.8.1 --help → --api-route strings return HTTP 401 instead of redirecting to authentication server if token is not valid. Format: path_regex. There is no --api-routes in the help output.
Which apps, and the measurement behind each
App
auth.apiRoutes
Basis
jackett
^/api/
its own 33 × 302 / 2 × 401 split and 4 dropped POSTs
radarr
^/api/, ^/signalr/
^/signalr/ is my measurement of the live post-fix pod (232/232). ^/api/ is #30's measurement of radarr's pre-fix pod (62 × /api/v3/command, 46 × /api/v3/queue/details) — this app's own evidence, by another engineer, not an analogy. The current pod has logged zero /api/ traffic, so I did not re-measure it.
pihole
(none — deliberate)
serves no /api/; it is the reason the field is per-app
sonarr
(none — not measured)
13,643 log lines, 0 unauthenticated requests
qbittorrent
(none — not measured)
13,649 log lines, 0 unauthenticated requests
Sonarr and qBittorrent were measured and deliberately left alone. Sonarr is nearly Radarr's codebase, which is exactly what makes ^/api/ + ^/signalr/ a tempting guess and still a guess. Nobody has opened either UI since #28 rolled, so their logs are empty — evidence of no traffic, not of no need. Each catalog file now carries a comment saying so, with the numbers and what to measure first.
Breadth is the dangerous direction, and it is not symmetric
A missing--api-route is a silent 302 — bad, invisible, recoverable. A too broad one answers a plain page load with 401: a logged-out operator gets a bare error page and is never offered a sign-in, so the service looks permanently broken and no reload recovers it. That is a worse outcome than the defect the flag fixes.
So every entry must match ^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$ — anchored at ^/, then literal path characters. Rejected and all five provoked: ^/, ^/.*, .*, /api/ (unanchored), ^/api/|^/. Alternation is refused rather than supported, because the flag is repeatable so one entry per route costs nothing. The same pattern lives in all three surfaces and assertion (5) of the new check fails if they drift apart — if they do, the loosest wins silently.
Goldens
Two goldens change, not five, and that is the per-app property proving itself on the real tree:
pihole, qbittorrent and sonarr are byte-identical to main. No L1 fixture needed regenerating — all three conformance/tests/l1-rendering/*/fixtures/svcdef*.yaml are forward-auth with no apiRoutes, so they render no flag. #28 touched 5 goldens and 3 fixtures because it changed flags on every sidecar; this changes flags only where a measurement says so.
The new L0 check, and why it is a sibling rather than an extension
Not folded into oauth2-proxy-cookie-names-distinct. That check's subject is cookie identity — its inputs are names and domains, its failure mode is mutual session eviction, its register is the shared proxy's claim on the default name. --api-route shares none of those, so bundling them would make its name a lie and let a future api-route decision be expressed only by weakening a cookie check. That is #28's own stated discipline, applied to my change rather than quietly dropped.
And "nowhere" was wrong for exactly one reason:render-golden compares the render to the goldens, so a renderer constant plus regenerated goldens agree with each other while both being wrong. Provoked — the regen exits 0, i.e. render-golden is happy, and the new check exits 1 with 15 failures naming pihole, qbittorrent, radarr and sonarr individually. Assertion (2) compares each rendered sidecar against its own ServiceDefinition, which is the only reading that fails it.
Tested in both directions on throwaway trees outside the repo, never by mutating the branch:
Tree
Result
this branch (control)
rc 0, 43 assertions, 6 sidecars audited
pre-change tree (git archive origin/main + the test)
rc 1, 11 failures
pihole's golden hand-edited to carry a route it never declared
rc 1, 2 failures
radarr's golden widened to --api-route=^/
rc 1, 2 failures
renderer made a constant, every golden regenerated to match
regen rc 0; check rc 1, 15 failures
schema items.pattern deleted
rc 1, 1 failure (the drift guard)
Declares conformance.home.k3s/scope: whole-repo, so ci/l0.sh's --include-scope union runs it on every gate invocation. No bespoke stage was added — whole-repo-invariants-wired would fail that. Selection confirmed: it is listed among 21 tests (was 20) with a requirement filter that matches nothing.
Also folded in: the selftest could exit 1 with ZERO diagnostics
Not originally mine — @Desmond found it while wiring catalog/render.py --selftest into the render-golden stage (#33, which touches only ci/render-golden.sh and is not in this base; origin/main at cdf3e14 drives the selftest from no stage, which I checked rather than assumed). The coordinator asked me to fold the fix in because the defect is in the file I own.
_selftest() reached _cookie_name() through a bare next(genexpr) with no default. Delete the --cookie-name emission — exactly the mutation that assertion exists to catch — and it raises StopIteration, which escapes _selftest()before its collected SELFTEST FAIL: lines are printed. Right verdict, no diagnostics, and it masks every other assertion the same mutation broke.
Provoking it exposed three more instances of the same failure class in the same block, which is why this is not literally one line: side = fa_containers[1], side["env"][0][...] and the fa_svc lookup all raise rather than assert when the sidecar is absent entirely. All four are now None-tolerant, and the two fixtures I added for --api-route were written that way from the start.
Mutation
Before
After
--cookie-name emission deleted
rc 1, 0SELFTEST FAIL lines, 1 StopIteration
rc 1, 3SELFTEST FAIL lines, 0 tracebacks
the oauth2-proxy sidecar not appended at all
rc 1, 0 lines, IndexError
rc 1, 20SELFTEST FAIL lines, 0 tracebacks
Goldens are unchanged by this part; it touches _selftest() only. The two PRs are independent but complementary: #33 makes the stage real, this makes its output readable when it fires. Worth noting for reviewers of #33 — .gitea/workflows/gate.yml:138 runs ./ci/gate.sh || [ "$?" -eq 3 ], so rc 1 is not tolerated in CI the way rc 3 is.
Not in this PR, deliberately: the metrics-ingress NetworkPolicy
@Ines verified that #28's metrics listener genuinely serves (port-forward: http=200, 10,208 bytes, oauth2_proxy_requests_total{code="302"} 2) but that the scrape will be dropped — ns jackett's default-deny has no ingress key at all, and jackett-allow-gateway-ingress admits fromEntities: [ingress] only. The fix belongs in each app's own rendered policy, i.e. in my file and my goldens, and the coordinator asked whether to bundle it. My answer is: queue it as the next PR, not drop it. Reasons, in the order that decided it:
It is a MODEL question, not a policy to bolt on. Schema 1.6.0's observability.metricsEndpoint already renders the monitoring-namespace ingress allow. What the model cannot express is a second metrics endpoint belonging to a sidecar the renderer injected rather than to the declared workload. Keying a bespoke policy off the pod label auth-mode: forward-auth works today but hardcodes "forward-auth implies a metrics port on 44180" instead of deriving it — the "list, don't derive" anti-pattern oauth2-proxy-cookie-names-distinct assertion (5) forbids, introduced one directory from the check that forbids it. Worth arguing about on its own.
It is a trust-boundary ingress allow on a different requirement set (R-NET-01/02, R-OBS-01 vs R-IDP-02). It should not be reviewed in the shadow of an --api-route headline, least of all in a PR that already needs a human for a different sensitive-path reason.
The conflict argument does not apply to this diff. It assumes five goldens; this regenerates two, because the flag renders only where declared. A CiliumNetworkPolicy is a different document in files git merges independently, and goldens regenerate deterministically — so the follow-up rebases cleanly. The N-1 rebase problem in decisions/README.md was two branches appending to the same hunk.
Every fact in the finding is hers and I measured none of it — not the 200/10,208 bytes, not the wget: download timed out on :44180and:4180, not the Flux sibling ordering. Shipping a security-adjacent rule on wholly inherited evidence as a footnote is what §2b exists to stop.
The PodMonitor it serves is in #29, not here. Shipping the policy alone opens an ingress allow for a scraper that selects nothing.
So: the sidecars' metrics are still unscrapable and this PR does not change that. Nobody should read it otherwise.
Gate
Toolbox run (DEV_KUBECONFIG=/dev/null + an ENGINE shim mounting the main checkout's .git and the sibling spec repo at /home-network-k3s):
Skipped:gitea-settings (no SETTINGS_API_TOKEN locally) and L2/L3 conformance (CLUSTER_AVAILABLE unset)
Touched requirements, all 13 UNVERIFIED at L2/L3:R-DNS-03 R-GITOPS-02 R-IDP-02 R-IDP-06 R-NET-01 R-NET-05 R-NET-06 R-NET-07 R-PLAT-02 R-TEST-01 R-TEST-02 R-TEST-03 R-TEST-04
spec-immutability is green, not skipped. catalog/render.py --selftest: PASS.
CI, read from /statuses rather than from a tick
Both contexts report, and only one of them runs. Identical on 99560c2d, df1b25b6 and f1d16f44:
gate / gate (pull_request) state=failure desc='Failing after ~1m43-1m45s'
gate / conformance (pull_request) state=success desc='Has been skipped'
That second line is §2b's trap in the wild — a skipped Gitea job reports state=success, so its green tick certifies nothing. Read the description, not the colour.
The duration is a range on purpose — it differs by a second or two per run, and the load-bearing fields are the state and the description, which are stable across all three. Worth recording that the same context changed its answer over time: on the first push it briefly read state=pending desc='Blocked by required conditions', still waiting on the gate job it depends on, and resolved to success only once that settled. Sample a status once and you can see either a pending or a green tick for a job that never executed.
And a correction worth more than the rest of this section: my local approval-policy: green was meaningless. ci/approval-policy.sh:47 reads GITHUB_EVENT_NAME, unset outside CI, so it short-circuits to "not a pull_request event (event='none') — approval policy N/A". A clean local gate structurally cannot catch a sensitive-path problem. Re-running with GITHUB_EVENT_NAME=pull_request PR_NUMBER=34 makes it evaluate and fail closed on the missing local token, while every other stage stays green — which bounds the CI failure to that one stage:
[fail] no SETTINGS_API_TOKEN — cannot read this PR's changed files or its reviews.
[fail] stage approval-policy: FAIL
[fail] approval-policy: a sensitive change lacks a non-author approval -> blocks merge
[fail] MERGE GATE: BLOCKED (rc 1, not 3)
I could not read CI's own job log: this Gitea answers 404 on /actions/runs and on /actions/jobs/{task_id}/logs, leaving only /actions/tasks. So attribution of the CI red to approval-policy rests on that local reproduction plus the sensitive-path rule, not on CI's log — weaker evidence than #28 had, and named rather than papered over.
Branch protection re-measured live (§2b), not inherited from CLAUDE.md's table: protected=True, user_can_push=False, enable_status_check=True, status_check_contexts=['gate / gate (pull_request)'], required_approvals=1; runner id=1 incluster-dind status=online. I did not provoke a rejected push to main, so user_can_push is the API's word only.
Two things to expect, both correct behaviour
approval-policy will FAIL this PR in CI. The diff touches conformance/**, which infra/gitea/approval-policy.json lists as sensitive — "the tests and the harness; changing them changes what green MEANS" — so it is fail-closed pending a non-author approving review. #28 was blocked the same way. That is CLAUDE.md §1 working, not a defect.
required_approvals=1 applies to every PR here regardless of path, so this needs a human either way.
And read descriptions, not colours: a skipped Gitea job reports state=success with the description "Has been skipped". Both status lines are quoted in the handoff entry once CI reports.
What this does NOT establish
No browser round-trip, by anyone, for this flag. The 302/401 split, the dropped POST bodies and the orphaned SignalR poll are all read from access logs; that a 401 repairs the operator's experience is reasoned from HTTP semantics, not watched. Also: ^/api/ on radarr is #30's measurement, not mine; sonarr and qBittorrent are unmeasured, not cleared; --cookie-refresh=2m30s is still unexercised (0 AuthSuccess on all five pods); nothing was applied — cluster access was read-only throughout. Full list in decisions/2026-10-01-oauth2-proxy-api-route-per-app.md.
Merging rolls jackett and radarr only. No cookie flag changed, so nobody is signed out — unlike #28. Both use strategy: Recreate on RWO Longhorn volumes, so there is a brief gap per app, and radarr/sonarr lose direct :9117 access to Jackett while its pod restarts.
The human test — how to tell a too-broad regex from a working one
Do step 1 first, before anything else. From a signed-out private window, open https://radarr.home.keithjackson.dev/ and https://jackett.home.keithjackson.dev/UI/Dashboard. Each must bounce through Keycloak and land on the app's UI. If either shows a bare 401 Unauthorized instead of a sign-in, the regex is too broad — stop and revert. That is the one failure this change can introduce, and it is the only test that can see it: a working regex is invisible on a page load, a broken one is total and immediate.
Jackett, the original report:+ Add indexer → pick one → Configure → Okay. It must save (previously 4 of 6 POSTs never reached Jackett).
Radarr, the live symptom: DevTools → Network, filter signalr. Those negotiate calls are 302 today and the UI never complains; they must now be 200 while signed in.
The loud-failure test: delete the _oauth2_proxy_radarr cookie and let the tab poll — negotiate must answer 401 and Radarr must show a problem. Then reload: / matches neither regex, so it must sign you back in silently. That round trip is the entire fix — fail loudly on the API, recover on the page load.
The six-minute test (#28's open item, still open): sit on a dashboard >6 minutes, then save. That exercises --cookie-refresh=2m30s past the 300 s cliff. A failure there points at the realm's refresh/SSO-idle timeouts, not at this change.
Second half of #28, which fixed the cookie collision, the 5-minute session and the missing metrics listener in one renderer function and **deliberately declined `--api-route`** — because the config model could not express the difference between two apps, and I have preserved that reasoning rather than worked around it.
## The defect
oauth2-proxy answers an unauthenticated request with a **302** to Keycloak. Its built-in AJAX guard would send a **401** instead, but it fires only on an **exact** `Accept: application/json`, which these apps' JavaScript does not consistently send. So a browser XHR gets a 302, **follows it invisibly**, has it satisfied silently by the live SSO session, and resolves 200 carrying the app's **HTML** where the JavaScript expected JSON — no prompt, no console error, nothing logged as a failure. On a **write** it is worse: a browser following a 302 re-issues a POST as a **GET and drops the body**, so the operator's change is discarded and the UI reports success.
**Measured, on two apps and two different paths — which is the whole argument:**
| App | Measurement |
|---|---|
| **jackett**, 2026-09-30 | **33 × 302 against 2 × 401** on `/api/` paths, and **4 of 6 POSTs answered by the proxy and never reaching Jackett** (access-log upstream field `-`, not `/`) |
| **radarr**, 2026-10-01, 23 h after #28 deployed (`radarr-7d55cbffb9-42hmh`, 0 restarts) | **232 unauthenticated requests in the pod's entire lifetime, and all 232 are the same call**: `POST /signalr/messages/negotiate`, browser UA, 302, upstream `-` |
That Radarr number is the live proof this change is needed: a **left-open browser tab** whose session the cookie rename orphaned, polling SignalR forever, silently redirected every time, and **never re-prompting the operator**. That is the reported symptom — *"reloading never fixes it and I am never re-prompted"* — still happening in production, on a service nobody was looking at.
**So radarr's broken path is `/signalr/`, jackett's is `/api/`, and PiHole serves no `/api/` at all.** One function renders all five sidecars, so the cheap fix is a renderer constant — and a constant applies one app's measurement to four apps nobody measured. The `auth` block was `additionalProperties: false`, so there was no per-app hook. **Creating the hook is the change; setting the flag is the easy part.**
## What this does
| Surface | Change | What it guards that the others cannot |
|---|---|---|
| `schemas/servicedefinition.schema.json` (**1.8.0 → 1.9.0**) | optional `auth.apiRoutes`: a **list** of anchored path regexes, `uniqueItems`, default `[]`; plus the invariant that a non-empty list requires `mode: forward-auth` | rejects a too-broad regex **where a human types it** — `ci/l0.sh` validates `catalog/*.yaml` against the schema on every gate run |
| `catalog/render.py` | emits one `--api-route=<r>` per entry, in order; **raises** on an unanchored entry | `render-golden` runs on every gate invocation even when the schema stage skips |
| `conformance/tests/l0-static/oauth2-proxy-api-routes-per-app/` (new) | cross-checks each rendered sidecar against **its own** ServiceDefinition | catches a **renderer constant**, which neither of the above can see |
The `auth` block **stays `additionalProperties: false`** — one named, documented key, not an opened door. That property is what made "there is no override hook" a fact rather than a guess for the two engineers who hit it before me.
A **list**, not a single string, because Radarr needs two and `--api-route` is natively repeatable. A single string would have forced alternation into the value, and alternation is how breadth gets smuggled past a prefix-only reading of a regex.
**The flag is `--api-route`, singular** — verified against the pinned binary rather than taken from the brief: `podman run --rm quay.io/oauth2-proxy/oauth2-proxy:v7.8.1 --help` → `--api-route strings return HTTP 401 instead of redirecting to authentication server if token is not valid. Format: path_regex`. There is no `--api-routes` in the help output.
### Which apps, and the measurement behind each
| App | `auth.apiRoutes` | Basis |
|---|---|---|
| **jackett** | `^/api/` | its own 33 × 302 / 2 × 401 split and 4 dropped POSTs |
| **radarr** | `^/api/`, `^/signalr/` | `^/signalr/` is **my** measurement of the live post-fix pod (232/232). `^/api/` is **#30's** measurement of radarr's **pre-fix** pod (62 × `/api/v3/command`, 46 × `/api/v3/queue/details`) — this app's own evidence, by another engineer, not an analogy. The current pod has logged zero `/api/` traffic, so I did **not** re-measure it. |
| **pihole** | *(none — deliberate)* | serves no `/api/`; it is the reason the field is per-app |
| **sonarr** | *(none — not measured)* | 13,643 log lines, **0** unauthenticated requests |
| **qbittorrent** | *(none — not measured)* | 13,649 log lines, **0** unauthenticated requests |
**Sonarr and qBittorrent were measured and deliberately left alone.** Sonarr is nearly Radarr's codebase, which is exactly what makes `^/api/` + `^/signalr/` a tempting guess and still a guess. Nobody has opened either UI since #28 rolled, so their logs are empty — **evidence of no traffic, not of no need.** Each catalog file now carries a comment saying so, with the numbers and what to measure first.
### Breadth is the dangerous direction, and it is not symmetric
A **missing** `--api-route` is a silent 302 — bad, invisible, recoverable. A **too broad** one answers a plain **page load** with 401: a logged-out operator gets a bare error page and is **never offered a sign-in**, so the service looks permanently broken and no reload recovers it. That is a worse outcome than the defect the flag fixes.
So every entry must match `^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$` — anchored at `^/`, then literal path characters. Rejected and all five provoked: `^/`, `^/.*`, `.*`, `/api/` (unanchored), `^/api/|^/`. Alternation is **refused rather than supported**, because the flag is repeatable so one entry per route costs nothing. The same pattern lives in all three surfaces and assertion (5) of the new check **fails if they drift apart** — if they do, the loosest wins silently.
### Goldens
**Two goldens change, not five**, and that is the per-app property proving itself on the real tree:
```
apps/wave-0/jackett.yaml + --api-route=^/api/
apps/wave-1/radarr.yaml + --api-route=^/api/
+ --api-route=^/signalr/
```
`pihole`, `qbittorrent` and `sonarr` are **byte-identical to `main`**. **No L1 fixture needed regenerating** — all three `conformance/tests/l1-rendering/*/fixtures/svcdef*.yaml` are forward-auth with no `apiRoutes`, so they render no flag. #28 touched 5 goldens and 3 fixtures because it changed flags on *every* sidecar; this changes flags only where a measurement says so.
### The new L0 check, and why it is a sibling rather than an extension
**Not** folded into `oauth2-proxy-cookie-names-distinct`. That check's subject is cookie **identity** — its inputs are names and domains, its failure mode is mutual session eviction, its register is the shared proxy's claim on the default name. `--api-route` shares none of those, so bundling them would make its name a lie and let a future api-route decision be expressed only by weakening a **cookie** check. That is #28's own stated discipline, applied to my change rather than quietly dropped.
**And "nowhere" was wrong for exactly one reason:** `render-golden` compares the render to the goldens, so a **renderer constant plus regenerated goldens agree with each other while both being wrong**. Provoked — the regen exits **0**, i.e. render-golden is *happy*, and the new check exits **1 with 15 failures** naming pihole, qbittorrent, radarr and sonarr individually. Assertion (2) compares each rendered sidecar against its own ServiceDefinition, which is the only reading that fails it.
Tested in **both directions** on throwaway trees outside the repo, never by mutating the branch:
| Tree | Result |
|---|---|
| this branch (control) | **rc 0, 43 assertions, 6 sidecars audited** |
| pre-change tree (`git archive origin/main` + the test) | **rc 1, 11 failures** |
| pihole's golden hand-edited to carry a route it never declared | **rc 1, 2 failures** |
| radarr's golden widened to `--api-route=^/` | **rc 1, 2 failures** |
| renderer made a constant, **every golden regenerated to match** | regen **rc 0**; check **rc 1, 15 failures** |
| schema `items.pattern` deleted | **rc 1, 1 failure** (the drift guard) |
Declares `conformance.home.k3s/scope: whole-repo`, so `ci/l0.sh`'s `--include-scope` union runs it on **every** gate invocation. No bespoke stage was added — `whole-repo-invariants-wired` would fail that. Selection confirmed: it is listed among **21** tests (was 20) with a requirement filter that matches nothing.
## Also folded in: the selftest could exit 1 with ZERO diagnostics
Not originally mine — @Desmond found it while wiring `catalog/render.py --selftest` into the `render-golden` stage (#33, which touches only `ci/render-golden.sh` and is **not** in this base; `origin/main` at `cdf3e14` drives the selftest from no stage, which I checked rather than assumed). The coordinator asked me to fold the fix in because the defect is in the file I own.
`_selftest()` reached `_cookie_name()` through a bare `next(genexpr)` with **no default**. Delete the `--cookie-name` emission — exactly the mutation that assertion exists to catch — and it raises `StopIteration`, which escapes `_selftest()` **before its collected `SELFTEST FAIL:` lines are printed**. Right verdict, no diagnostics, and it masks every other assertion the same mutation broke.
Provoking it exposed **three more instances of the same failure class** in the same block, which is why this is not literally one line: `side = fa_containers[1]`, `side["env"][0][...]` and the `fa_svc` lookup all raise rather than assert when the sidecar is absent entirely. All four are now None-tolerant, and the two fixtures I added for `--api-route` were written that way from the start.
| Mutation | Before | After |
|---|---|---|
| `--cookie-name` emission deleted | rc 1, **0** `SELFTEST FAIL` lines, 1 `StopIteration` | rc 1, **3** `SELFTEST FAIL` lines, 0 tracebacks |
| the oauth2-proxy sidecar not appended at all | rc 1, **0** lines, `IndexError` | rc 1, **20** `SELFTEST FAIL` lines, 0 tracebacks |
Goldens are unchanged by this part; it touches `_selftest()` only. The two PRs are independent but complementary: #33 makes the stage real, this makes its output readable when it fires. Worth noting for reviewers of #33 — `.gitea/workflows/gate.yml:138` runs `./ci/gate.sh || [ "$?" -eq 3 ]`, so **rc 1 is not tolerated in CI the way rc 3 is**.
## Not in this PR, deliberately: the metrics-ingress NetworkPolicy
@Ines verified that #28's metrics listener genuinely **serves** (port-forward: `http=200`, 10,208 bytes, `oauth2_proxy_requests_total{code="302"} 2`) but that the scrape will be **dropped** — ns `jackett`'s default-deny has **no `ingress` key at all**, and `jackett-allow-gateway-ingress` admits `fromEntities: [ingress]` only. The fix belongs in each app's own rendered policy, i.e. in my file and my goldens, and the coordinator asked whether to bundle it. **My answer is: queue it as the next PR, not drop it.** Reasons, in the order that decided it:
1. **It is a MODEL question, not a policy to bolt on.** Schema 1.6.0's `observability.metricsEndpoint` already renders the monitoring-namespace ingress allow. What the model *cannot* express is a **second** metrics endpoint belonging to a **sidecar the renderer injected** rather than to the declared workload. Keying a bespoke policy off the pod label `auth-mode: forward-auth` works today but hardcodes *"forward-auth implies a metrics port on 44180"* instead of **deriving** it — the "list, don't derive" anti-pattern `oauth2-proxy-cookie-names-distinct` assertion (5) forbids, introduced one directory from the check that forbids it. Worth arguing about on its own.
2. **It is a trust-boundary ingress allow on a different requirement set** (R-NET-01/02, R-OBS-01 vs R-IDP-02). It should not be reviewed in the shadow of an `--api-route` headline, least of all in a PR that already needs a human for a different sensitive-path reason.
3. **The conflict argument does not apply to this diff.** It assumes five goldens; **this regenerates two**, because the flag renders only where declared. A CiliumNetworkPolicy is a *different document* in files git merges independently, and goldens regenerate deterministically — so the follow-up rebases cleanly. The N-1 rebase problem in `decisions/README.md` was two branches appending to the **same hunk**.
4. **Every fact in the finding is hers and I measured none of it** — not the 200/10,208 bytes, not the `wget: download timed out` on `:44180` *and* `:4180`, not the Flux sibling ordering. Shipping a security-adjacent rule on wholly inherited evidence as a footnote is what §2b exists to stop.
5. **The PodMonitor it serves is in #29, not here.** Shipping the policy alone opens an ingress allow for a scraper that selects nothing.
So: **the sidecars' metrics are still unscrapable and this PR does not change that.** Nobody should read it otherwise.
## Gate
Toolbox run (`DEV_KUBECONFIG=/dev/null` + an `ENGINE` shim mounting the main checkout's `.git` and the sibling spec repo at `/home-network-k3s`):
**`./ci/gate.sh` → rc 3 — UNDETERMINED, NOT green.**
- **Green:** `spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1`, `approval-policy`
- **Skipped:** `gitea-settings` (no `SETTINGS_API_TOKEN` locally) and **L2/L3 conformance** (`CLUSTER_AVAILABLE` unset)
- Touched requirements, **all 13 UNVERIFIED at L2/L3:** `R-DNS-03 R-GITOPS-02 R-IDP-02 R-IDP-06 R-NET-01 R-NET-05 R-NET-06 R-NET-07 R-PLAT-02 R-TEST-01 R-TEST-02 R-TEST-03 R-TEST-04`
`spec-immutability` is **green**, not skipped. `catalog/render.py --selftest`: **PASS**.
## CI, read from `/statuses` rather than from a tick
Both contexts report, and **only one of them runs**. Identical on `99560c2d`, `df1b25b6` and `f1d16f44`:
```
gate / gate (pull_request) state=failure desc='Failing after ~1m43-1m45s'
gate / conformance (pull_request) state=success desc='Has been skipped'
```
**That second line is §2b's trap in the wild** — a *skipped* Gitea job reports `state=success`, so its green tick certifies nothing. Read the description, not the colour.
The duration is a range on purpose — it differs by a second or two per run, and the load-bearing fields are the **state** and the **description**, which are stable across all three. Worth recording that the **same context changed its answer over time**: on the first push it briefly read `state=pending desc='Blocked by required conditions'`, still waiting on the gate job it depends on, and resolved to `success` only once that settled. Sample a status once and you can see either a pending *or* a green tick for a job that never executed.
**And a correction worth more than the rest of this section:** my local `approval-policy: green` was **meaningless**. `ci/approval-policy.sh:47` reads `GITHUB_EVENT_NAME`, unset outside CI, so it short-circuits to `"not a pull_request event (event='none') — approval policy N/A"`. **A clean local gate structurally cannot catch a sensitive-path problem.** Re-running with `GITHUB_EVENT_NAME=pull_request PR_NUMBER=34` makes it evaluate and fail **closed** on the missing local token, while every other stage stays green — which bounds the CI failure to that one stage:
```
[fail] no SETTINGS_API_TOKEN — cannot read this PR's changed files or its reviews.
[fail] stage approval-policy: FAIL
[fail] approval-policy: a sensitive change lacks a non-author approval -> blocks merge
[fail] MERGE GATE: BLOCKED (rc 1, not 3)
```
**I could not read CI's own job log**: this Gitea answers 404 on `/actions/runs` and on `/actions/jobs/{task_id}/logs`, leaving only `/actions/tasks`. So attribution of the CI red to `approval-policy` rests on that local reproduction plus the sensitive-path rule, **not** on CI's log — weaker evidence than #28 had, and named rather than papered over.
Branch protection re-measured live (§2b), not inherited from CLAUDE.md's table: `protected=True`, `user_can_push=False`, `enable_status_check=True`, `status_check_contexts=['gate / gate (pull_request)']`, `required_approvals=1`; runner `id=1 incluster-dind status=online`. I did **not** provoke a rejected push to `main`, so `user_can_push` is the API's word only.
## Two things to expect, both correct behaviour
1. **`approval-policy` will FAIL this PR in CI.** The diff touches `conformance/**`, which `infra/gitea/approval-policy.json` lists as sensitive — *"the tests and the harness; changing them changes what green MEANS"* — so it is **fail-closed pending a non-author approving review**. #28 was blocked the same way. That is CLAUDE.md §1 working, not a defect.
2. **`required_approvals=1` applies to every PR here regardless of path**, so this needs a human either way.
And read descriptions, not colours: a **skipped** Gitea job reports `state=success` with the description `"Has been skipped"`. Both status lines are quoted in the handoff entry once CI reports.
## What this does NOT establish
**No browser round-trip, by anyone, for this flag.** The 302/401 split, the dropped POST bodies and the orphaned SignalR poll are all read from access logs; that a 401 **repairs** the operator's experience is reasoned from HTTP semantics, not watched. Also: `^/api/` on radarr is #30's measurement, not mine; sonarr and qBittorrent are **unmeasured, not cleared**; `--cookie-refresh=2m30s` is **still** unexercised (0 `AuthSuccess` on all five pods); nothing was applied — cluster access was read-only throughout. Full list in `decisions/2026-10-01-oauth2-proxy-api-route-per-app.md`.
**Merging rolls jackett and radarr only.** No cookie flag changed, so **nobody is signed out** — unlike #28. Both use `strategy: Recreate` on RWO Longhorn volumes, so there is a brief gap per app, and radarr/sonarr lose direct `:9117` access to Jackett while its pod restarts.
## The human test — how to tell a too-broad regex from a working one
**Do step 1 first, before anything else.** From a signed-out private window, open `https://radarr.home.keithjackson.dev/` and `https://jackett.home.keithjackson.dev/UI/Dashboard`. **Each must bounce through Keycloak and land on the app's UI.** If either shows a bare `401 Unauthorized` instead of a sign-in, **the regex is too broad — stop and revert.** That is the one failure this change can introduce, and it is the only test that can see it: a working regex is invisible on a page load, a broken one is total and immediate.
2. **Jackett, the original report:** *+ Add indexer* → pick one → *Configure* → *Okay*. It must save (previously 4 of 6 POSTs never reached Jackett).
3. **Radarr, the live symptom:** DevTools → Network, filter `signalr`. Those negotiate calls are **302** today and the UI never complains; they must now be **200** while signed in.
4. **The loud-failure test:** delete the `_oauth2_proxy_radarr` cookie and let the tab poll — negotiate must answer **401** and Radarr must *show* a problem. Then **reload**: `/` matches neither regex, so it must sign you back in silently. That round trip is the entire fix — fail loudly on the API, recover on the page load.
5. **The six-minute test** (#28's open item, still open): sit on a dashboard >6 minutes, then save. That exercises `--cookie-refresh=2m30s` past the 300 s cliff. A failure there points at the realm's refresh/SSO-idle timeouts, not at this change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
PR #28 fixed the cookie collision, the 5-minute session and the missing metrics
listener in one renderer function, and deliberately declined `--api-route`
because the config model could not express the difference between two apps.
This adds the hook it was missing and sets the flag only where a measurement
says to.
THE DEFECT. oauth2-proxy answers an unauthenticated request with a 302 to
Keycloak. Its AJAX guard would send 401 instead, but it fires only on an EXACT
`Accept: application/json`, which these apps' JavaScript does not consistently
send. So a browser XHR gets a 302, FOLLOWS IT INVISIBLY, has it satisfied
silently by the live SSO session, and resolves 200 carrying the app's HTML where
the JavaScript expected JSON. On a write it is worse: a browser following a 302
re-issues a POST as a GET and DROPS THE BODY, so the operator's change is
discarded and the UI reports success.
MEASURED, on two apps and two different paths -- which is the whole argument:
jackett 2026-09-30: 33 x 302 vs 2 x 401 on /api/ paths, and 4 of 6 POSTs
answered by the proxy and never reaching Jackett (upstream `-`).
radarr 2026-10-01, 23h after PR #28 deployed (pod radarr-7d55cbffb9-42hmh,
0 restarts): 232 unauthenticated requests in the pod's whole life and
ALL 232 are the same call -- POST /signalr/messages/negotiate from a
browser, 302, upstream `-`. A left-open tab whose session the cookie
rename orphaned, polling forever, silently redirected every time and
NEVER re-prompting. The reported symptom, still in production.
So radarr's broken path is /signalr/ and jackett's is /api/, and PIHOLE SERVES
NO /api/ AT ALL. One function renders all five sidecars, so the cheap fix is a
constant -- and a constant applies one app's measurement to four apps nobody
measured. `auth` was `additionalProperties: false`, so there was no per-app
hook. Adding one named, documented key is the change; the block stays closed.
schemas/servicedefinition.schema.json 1.8.0 -> 1.9.0: optional
`auth.apiRoutes`, a list (radarr needs two, and --api-route is natively
repeatable) of anchored path regexes. Plus the shape invariant that a
non-empty list requires mode: forward-auth, since no sidecar is rendered
for oidc/native/none and declaring it there would be a SILENT NO-OP.
catalog/render.py emits one --api-route per entry, in order, and RAISES on
an unanchored entry. The flag is SINGULAR -- verified against the pinned
v7.8.1 binary's own --help; --api-routes does not exist and the proxy
refuses to start with it.
catalog/{jackett,radarr}.yaml the two measured apps.
catalog/{pihole,sonarr,qbittorrent}.yaml comments recording that they get
NOTHING, and why. Sonarr and qBittorrent were measured (0 unauthenticated
requests in 23h) and left alone: that is evidence of no TRAFFIC, not of no
need, and sonarr being nearly radarr's codebase is what makes the guess
tempting and still a guess.
apps/wave-0/jackett.yaml, apps/wave-1/radarr.yaml regenerated. Only TWO
goldens change; pihole/qbittorrent/sonarr are byte-identical to main,
which is the per-app property proving itself on the real tree.
BREADTH IS THE DANGEROUS DIRECTION AND IT IS NOT SYMMETRIC. A missing
--api-route is a silent 302. A too-broad one answers a plain PAGE LOAD with
401, so a logged-out operator gets a bare error page and is never offered a
sign-in -- the service looks permanently broken. Each entry must therefore
match `^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$`: anchored at ^/ then literal path
characters. `^/`, `^/.*`, `.*`, `/api/` and `^/api/|^/` are all rejected, all
five provoked. Alternation is refused rather than supported because the flag is
repeatable, so one entry per route costs nothing.
NEW GATE CHECK: conformance/tests/l0-static/oauth2-proxy-api-routes-per-app
(whole-repo scope, so ci/l0.sh's --include-scope union runs it on every
invocation; no bespoke stage, which whole-repo-invariants-wired would fail).
It is a SIBLING of oauth2-proxy-cookie-names-distinct, not an extension of it:
that check's subject is cookie IDENTITY and shares neither inputs nor failure
mode with this, so bundling them would make its name a lie and let a future
api-route decision be expressed only by weakening a COOKIE check.
The check exists for one reason the other gates cannot cover: render-golden
compares the render to the goldens, so a RENDERER CONSTANT plus regenerated
goldens AGREE WITH EACH OTHER while both being wrong. Provoked: the regen exits
0 -- render-golden is happy -- and this check exits 1 with 15 failures naming
pihole, qbittorrent, radarr and sonarr individually. It also asserts the
anchoring pattern is the SAME in the schema and the renderer, because three
surfaces guard this and if they drift the loosest wins silently.
Tested in both directions on throwaway trees outside the repo: compliant tree
rc 0 / 43 assertions / 6 sidecars; pre-change tree rc 1 / 11 failures; and four
targeted mutations (hand-edited golden, widened regex, renderer constant,
deleted schema pattern) each firing their own assertion class and nothing else.
ALSO FOLDED IN (coordinator's request; defect is in the file I own). _selftest()
reached _cookie_name() through a bare `next(genexpr)` with NO default, so
deleting the --cookie-name emission -- the exact mutation that assertion exists
to catch -- raised StopIteration, which escaped _selftest() BEFORE its collected
`SELFTEST FAIL:` lines were printed. Right verdict, zero diagnostics, and it
masked every other assertion the same mutation broke (found by Desmond Oyelaran
while wiring the selftest into the render-golden stage; his PR #33 is not in this
base -- origin/main @ cdf3e14 drives the selftest from no stage, checked). Fixed
with `next(..., None)` plus an explicit check(), so a missing flag reports as a
NAMED assertion. Provoking it exposed three more instances of the same class in
the same block -- `side = fa_containers[1]`, `side["env"][0]`, and the fa_svc
lookup all raise rather than assert when the sidecar is absent -- so all four are
now None-tolerant, and the two fixtures added for --api-route were written that
way from the start. Measured on throwaway copies:
--cookie-name deleted: before rc 1 / 0 FAIL lines / StopIteration
after rc 1 / 3 FAIL lines / no traceback
sidecar not appended: before rc 1 / 0 FAIL lines / IndexError
after rc 1 / 20 FAIL lines / no traceback
Goldens are unchanged by this part; it touches _selftest() only.
NOT verified, in full in the handoff entry: no browser round-trip by anyone for
this flag -- that a 401 REPAIRS the operator's experience is reasoned from HTTP
semantics, not watched. `^/api/` on radarr is PR #30's measurement of radarr's
pre-fix pod, not mine; the current pod has zero /api/ traffic. Sonarr and
qBittorrent are UNMEASURED, not cleared. --cookie-refresh is still unexercised
(0 AuthSuccess on all five pods). Nothing was applied; the live pods are still
the pre-change spec.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Follow-up to the branch's one substantive commit; no code change.
CI on 99560c2d, read from the commit's /statuses rather than from a tick:
gate / gate (pull_request) state=failure desc='Failing after 1m44s'
gate / conformance (pull_request) state=pending desc='Blocked by required conditions'
The second line is PENDING, not the `success` + "Has been skipped" trap -- the
conformance job is gated on the gate job, which failed, so it never ran.
MY LOCAL `approval-policy: green` WAS MEANINGLESS AND I NEARLY REPORTED IT AS
EVIDENCE. ci/approval-policy.sh:47 reads GITHUB_EVENT_NAME, unset outside CI, so
it short-circuits to "N/A": a clean local gate STRUCTURALLY cannot catch a
sensitive-path problem. Verified by re-running with
GITHUB_EVENT_NAME=pull_request PR_NUMBER=34, where the stage evaluates and fails
CLOSED on the missing token while every other stage stays green -- which bounds
the CI failure to that one stage. I could NOT read CI's job log to confirm it
from CI's own output: this Gitea answers 404 on /actions/runs and on
/actions/jobs/{task}/logs, so only /actions/tasks is available. Attribution
therefore rests on the local reproduction plus the sensitive-path rule, not on
CI's log -- weaker evidence than PR #28 had, named rather than papered over.
Branch protection and the runner re-measured against the live API (CLAUDE.md
2b), not inherited from the CLAUDE.md table: protected=True,
user_can_push=False, enable_status_check=True, contexts=['gate / gate
(pull_request)'], required_approvals=1; runner id=1 incluster-dind online. So a
human is required on every PR here regardless of path. I did NOT provoke a
rejected push to main, so user_can_push is the API's word only.
DECLINED, with reasons: bundling Ines Dubois's metrics-ingress NetworkPolicy.
The coordinator asked for my judgement and the answer is to queue it as the next
PR, not to drop it. Principally because it is a MODEL question, not a policy to
bolt on: schema 1.6.0's observability.metricsEndpoint already renders the
monitoring-namespace ingress allow, and what the model cannot express is a
SECOND metrics endpoint belonging to a sidecar the RENDERER injected. Keying a
bespoke policy off the pod label auth-mode=forward-auth hardcodes "forward-auth
implies 44180" instead of deriving it -- the "list, don't derive" anti-pattern
oauth2-proxy-cookie-names-distinct assertion (5) forbids, one directory from the
check that forbids it. Also: it is a trust-boundary ingress allow and a
different requirement set (R-NET-01/02, R-OBS-01 vs R-IDP-02), so it should not
be reviewed in the shadow of an --api-route headline; the conflict argument does
not apply to my diff, which regenerates TWO goldens not five, and a
CiliumNetworkPolicy is a different document in files git merges independently;
every fact in the finding is hers and I have measured none of it; and the
PodMonitor it serves is in HER PR #29, so shipping the policy alone would open
an ingress allow for a scraper that selects nothing.
Also records that the sidecars' metrics remain UNSCRAPABLE and that this branch
does not change that.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Second half of #28, which fixed the cookie collision, the 5-minute session and the missing metrics listener in one renderer function and deliberately declined
--api-route— because the config model could not express the difference between two apps, and I have preserved that reasoning rather than worked around it.The defect
oauth2-proxy answers an unauthenticated request with a 302 to Keycloak. Its built-in AJAX guard would send a 401 instead, but it fires only on an exact
Accept: application/json, which these apps' JavaScript does not consistently send. So a browser XHR gets a 302, follows it invisibly, has it satisfied silently by the live SSO session, and resolves 200 carrying the app's HTML where the JavaScript expected JSON — no prompt, no console error, nothing logged as a failure. On a write it is worse: a browser following a 302 re-issues a POST as a GET and drops the body, so the operator's change is discarded and the UI reports success.Measured, on two apps and two different paths — which is the whole argument:
/api/paths, and 4 of 6 POSTs answered by the proxy and never reaching Jackett (access-log upstream field-, not/)radarr-7d55cbffb9-42hmh, 0 restarts)POST /signalr/messages/negotiate, browser UA, 302, upstream-That Radarr number is the live proof this change is needed: a left-open browser tab whose session the cookie rename orphaned, polling SignalR forever, silently redirected every time, and never re-prompting the operator. That is the reported symptom — "reloading never fixes it and I am never re-prompted" — still happening in production, on a service nobody was looking at.
So radarr's broken path is
/signalr/, jackett's is/api/, and PiHole serves no/api/at all. One function renders all five sidecars, so the cheap fix is a renderer constant — and a constant applies one app's measurement to four apps nobody measured. Theauthblock wasadditionalProperties: false, so there was no per-app hook. Creating the hook is the change; setting the flag is the easy part.What this does
schemas/servicedefinition.schema.json(1.8.0 → 1.9.0)auth.apiRoutes: a list of anchored path regexes,uniqueItems, default[]; plus the invariant that a non-empty list requiresmode: forward-authci/l0.shvalidatescatalog/*.yamlagainst the schema on every gate runcatalog/render.py--api-route=<r>per entry, in order; raises on an unanchored entryrender-goldenruns on every gate invocation even when the schema stage skipsconformance/tests/l0-static/oauth2-proxy-api-routes-per-app/(new)The
authblock staysadditionalProperties: false— one named, documented key, not an opened door. That property is what made "there is no override hook" a fact rather than a guess for the two engineers who hit it before me.A list, not a single string, because Radarr needs two and
--api-routeis natively repeatable. A single string would have forced alternation into the value, and alternation is how breadth gets smuggled past a prefix-only reading of a regex.The flag is
--api-route, singular — verified against the pinned binary rather than taken from the brief:podman run --rm quay.io/oauth2-proxy/oauth2-proxy:v7.8.1 --help→--api-route strings return HTTP 401 instead of redirecting to authentication server if token is not valid. Format: path_regex. There is no--api-routesin the help output.Which apps, and the measurement behind each
auth.apiRoutes^/api/^/api/,^/signalr/^/signalr/is my measurement of the live post-fix pod (232/232).^/api/is #30's measurement of radarr's pre-fix pod (62 ×/api/v3/command, 46 ×/api/v3/queue/details) — this app's own evidence, by another engineer, not an analogy. The current pod has logged zero/api/traffic, so I did not re-measure it./api/; it is the reason the field is per-appSonarr and qBittorrent were measured and deliberately left alone. Sonarr is nearly Radarr's codebase, which is exactly what makes
^/api/+^/signalr/a tempting guess and still a guess. Nobody has opened either UI since #28 rolled, so their logs are empty — evidence of no traffic, not of no need. Each catalog file now carries a comment saying so, with the numbers and what to measure first.Breadth is the dangerous direction, and it is not symmetric
A missing
--api-routeis a silent 302 — bad, invisible, recoverable. A too broad one answers a plain page load with 401: a logged-out operator gets a bare error page and is never offered a sign-in, so the service looks permanently broken and no reload recovers it. That is a worse outcome than the defect the flag fixes.So every entry must match
^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$— anchored at^/, then literal path characters. Rejected and all five provoked:^/,^/.*,.*,/api/(unanchored),^/api/|^/. Alternation is refused rather than supported, because the flag is repeatable so one entry per route costs nothing. The same pattern lives in all three surfaces and assertion (5) of the new check fails if they drift apart — if they do, the loosest wins silently.Goldens
Two goldens change, not five, and that is the per-app property proving itself on the real tree:
pihole,qbittorrentandsonarrare byte-identical tomain. No L1 fixture needed regenerating — all threeconformance/tests/l1-rendering/*/fixtures/svcdef*.yamlare forward-auth with noapiRoutes, so they render no flag. #28 touched 5 goldens and 3 fixtures because it changed flags on every sidecar; this changes flags only where a measurement says so.The new L0 check, and why it is a sibling rather than an extension
Not folded into
oauth2-proxy-cookie-names-distinct. That check's subject is cookie identity — its inputs are names and domains, its failure mode is mutual session eviction, its register is the shared proxy's claim on the default name.--api-routeshares none of those, so bundling them would make its name a lie and let a future api-route decision be expressed only by weakening a cookie check. That is #28's own stated discipline, applied to my change rather than quietly dropped.And "nowhere" was wrong for exactly one reason:
render-goldencompares the render to the goldens, so a renderer constant plus regenerated goldens agree with each other while both being wrong. Provoked — the regen exits 0, i.e. render-golden is happy, and the new check exits 1 with 15 failures naming pihole, qbittorrent, radarr and sonarr individually. Assertion (2) compares each rendered sidecar against its own ServiceDefinition, which is the only reading that fails it.Tested in both directions on throwaway trees outside the repo, never by mutating the branch:
git archive origin/main+ the test)--api-route=^/items.patterndeletedDeclares
conformance.home.k3s/scope: whole-repo, soci/l0.sh's--include-scopeunion runs it on every gate invocation. No bespoke stage was added —whole-repo-invariants-wiredwould fail that. Selection confirmed: it is listed among 21 tests (was 20) with a requirement filter that matches nothing.Also folded in: the selftest could exit 1 with ZERO diagnostics
Not originally mine — @Desmond found it while wiring
catalog/render.py --selftestinto therender-goldenstage (#33, which touches onlyci/render-golden.shand is not in this base;origin/mainatcdf3e14drives the selftest from no stage, which I checked rather than assumed). The coordinator asked me to fold the fix in because the defect is in the file I own._selftest()reached_cookie_name()through a barenext(genexpr)with no default. Delete the--cookie-nameemission — exactly the mutation that assertion exists to catch — and it raisesStopIteration, which escapes_selftest()before its collectedSELFTEST FAIL:lines are printed. Right verdict, no diagnostics, and it masks every other assertion the same mutation broke.Provoking it exposed three more instances of the same failure class in the same block, which is why this is not literally one line:
side = fa_containers[1],side["env"][0][...]and thefa_svclookup all raise rather than assert when the sidecar is absent entirely. All four are now None-tolerant, and the two fixtures I added for--api-routewere written that way from the start.--cookie-nameemission deletedSELFTEST FAILlines, 1StopIterationSELFTEST FAILlines, 0 tracebacksIndexErrorSELFTEST FAILlines, 0 tracebacksGoldens are unchanged by this part; it touches
_selftest()only. The two PRs are independent but complementary: #33 makes the stage real, this makes its output readable when it fires. Worth noting for reviewers of #33 —.gitea/workflows/gate.yml:138runs./ci/gate.sh || [ "$?" -eq 3 ], so rc 1 is not tolerated in CI the way rc 3 is.Not in this PR, deliberately: the metrics-ingress NetworkPolicy
@Ines verified that #28's metrics listener genuinely serves (port-forward:
http=200, 10,208 bytes,oauth2_proxy_requests_total{code="302"} 2) but that the scrape will be dropped — nsjackett's default-deny has noingresskey at all, andjackett-allow-gateway-ingressadmitsfromEntities: [ingress]only. The fix belongs in each app's own rendered policy, i.e. in my file and my goldens, and the coordinator asked whether to bundle it. My answer is: queue it as the next PR, not drop it. Reasons, in the order that decided it:observability.metricsEndpointalready renders the monitoring-namespace ingress allow. What the model cannot express is a second metrics endpoint belonging to a sidecar the renderer injected rather than to the declared workload. Keying a bespoke policy off the pod labelauth-mode: forward-authworks today but hardcodes "forward-auth implies a metrics port on 44180" instead of deriving it — the "list, don't derive" anti-patternoauth2-proxy-cookie-names-distinctassertion (5) forbids, introduced one directory from the check that forbids it. Worth arguing about on its own.--api-routeheadline, least of all in a PR that already needs a human for a different sensitive-path reason.decisions/README.mdwas two branches appending to the same hunk.wget: download timed outon:44180and:4180, not the Flux sibling ordering. Shipping a security-adjacent rule on wholly inherited evidence as a footnote is what §2b exists to stop.So: the sidecars' metrics are still unscrapable and this PR does not change that. Nobody should read it otherwise.
Gate
Toolbox run (
DEV_KUBECONFIG=/dev/null+ anENGINEshim mounting the main checkout's.gitand the sibling spec repo at/home-network-k3s):./ci/gate.sh→ rc 3 — UNDETERMINED, NOT green.spec-immutability,decisions-log,lint,l0,render-golden,l1,approval-policygitea-settings(noSETTINGS_API_TOKENlocally) and L2/L3 conformance (CLUSTER_AVAILABLEunset)R-DNS-03 R-GITOPS-02 R-IDP-02 R-IDP-06 R-NET-01 R-NET-05 R-NET-06 R-NET-07 R-PLAT-02 R-TEST-01 R-TEST-02 R-TEST-03 R-TEST-04spec-immutabilityis green, not skipped.catalog/render.py --selftest: PASS.CI, read from
/statusesrather than from a tickBoth contexts report, and only one of them runs. Identical on
99560c2d,df1b25b6andf1d16f44:That second line is §2b's trap in the wild — a skipped Gitea job reports
state=success, so its green tick certifies nothing. Read the description, not the colour.The duration is a range on purpose — it differs by a second or two per run, and the load-bearing fields are the state and the description, which are stable across all three. Worth recording that the same context changed its answer over time: on the first push it briefly read
state=pending desc='Blocked by required conditions', still waiting on the gate job it depends on, and resolved tosuccessonly once that settled. Sample a status once and you can see either a pending or a green tick for a job that never executed.And a correction worth more than the rest of this section: my local
approval-policy: greenwas meaningless.ci/approval-policy.sh:47readsGITHUB_EVENT_NAME, unset outside CI, so it short-circuits to"not a pull_request event (event='none') — approval policy N/A". A clean local gate structurally cannot catch a sensitive-path problem. Re-running withGITHUB_EVENT_NAME=pull_request PR_NUMBER=34makes it evaluate and fail closed on the missing local token, while every other stage stays green — which bounds the CI failure to that one stage:I could not read CI's own job log: this Gitea answers 404 on
/actions/runsand on/actions/jobs/{task_id}/logs, leaving only/actions/tasks. So attribution of the CI red toapproval-policyrests on that local reproduction plus the sensitive-path rule, not on CI's log — weaker evidence than #28 had, and named rather than papered over.Branch protection re-measured live (§2b), not inherited from CLAUDE.md's table:
protected=True,user_can_push=False,enable_status_check=True,status_check_contexts=['gate / gate (pull_request)'],required_approvals=1; runnerid=1 incluster-dind status=online. I did not provoke a rejected push tomain, souser_can_pushis the API's word only.Two things to expect, both correct behaviour
approval-policywill FAIL this PR in CI. The diff touchesconformance/**, whichinfra/gitea/approval-policy.jsonlists as sensitive — "the tests and the harness; changing them changes what green MEANS" — so it is fail-closed pending a non-author approving review. #28 was blocked the same way. That is CLAUDE.md §1 working, not a defect.required_approvals=1applies to every PR here regardless of path, so this needs a human either way.And read descriptions, not colours: a skipped Gitea job reports
state=successwith the description"Has been skipped". Both status lines are quoted in the handoff entry once CI reports.What this does NOT establish
No browser round-trip, by anyone, for this flag. The 302/401 split, the dropped POST bodies and the orphaned SignalR poll are all read from access logs; that a 401 repairs the operator's experience is reasoned from HTTP semantics, not watched. Also:
^/api/on radarr is #30's measurement, not mine; sonarr and qBittorrent are unmeasured, not cleared;--cookie-refresh=2m30sis still unexercised (0AuthSuccesson all five pods); nothing was applied — cluster access was read-only throughout. Full list indecisions/2026-10-01-oauth2-proxy-api-route-per-app.md.Merging rolls jackett and radarr only. No cookie flag changed, so nobody is signed out — unlike #28. Both use
strategy: Recreateon RWO Longhorn volumes, so there is a brief gap per app, and radarr/sonarr lose direct:9117access to Jackett while its pod restarts.The human test — how to tell a too-broad regex from a working one
Do step 1 first, before anything else. From a signed-out private window, open
https://radarr.home.keithjackson.dev/andhttps://jackett.home.keithjackson.dev/UI/Dashboard. Each must bounce through Keycloak and land on the app's UI. If either shows a bare401 Unauthorizedinstead of a sign-in, the regex is too broad — stop and revert. That is the one failure this change can introduce, and it is the only test that can see it: a working regex is invisible on a page load, a broken one is total and immediate.signalr. Those negotiate calls are 302 today and the UI never complains; they must now be 200 while signed in._oauth2_proxy_radarrcookie and let the tab poll — negotiate must answer 401 and Radarr must show a problem. Then reload:/matches neither regex, so it must sign you back in silently. That round trip is the entire fix — fail loudly on the API, recover on the page load.--cookie-refresh=2m30spast the 300 s cliff. A failure there points at the realm's refresh/SSO-idle timeouts, not at this change.🤖 Generated with Claude Code
df1b25b680tof1d16f44b8f1d16f44b8to2983378496