From 1875c7cf5e651c5a03b082a9b8c4516a39116ca5 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 6 Aug 2026 00:42:01 -0500 Subject: [PATCH 1/2] fix(console): budget the two search short-circuit renders inline (BACKLOG #1025) `GET /ui/messages/search` and `GET /ui/messages/search/layered` reach the reused engine handlers (`search_messages` / `layered_search`) only on their criteria/preset path; those handlers already charge the per-actor PHI-read budget in their own body (`enforce_phi_read_pacing`). The only genuinely-unpaced path was each route's SHORT-CIRCUIT render -- the bare-form and no-preset re-renders that return before the handler runs -- which on first deployment would skip the read budget entirely. A gate-level `phi=` on `require_ui_step_up` (the filing's proposed fix) is wrong for these mixed routes: a FastAPI dependency runs on EVERY request, so it would charge the criteria/preset path a SECOND time on top of the handler -- the exact double-count that already excludes the uploaded-logs route. Instead each search route now charges `enforce_phi_read_pacing` INLINE on its short-circuit branch only, so the render spends one token while a real search still charges exactly once. `GET /ui/uploaded-logs/file/{file_id}` keeps no console-side charge: its handler paces every call and it has no short-circuit, so any second charge would double-count (its scope-guard test is added here). Scope note: the filing named three routes and a `phi=` on all three; verified against the code, only the two search renders were the real gap and the naive `phi=` would have double-charged. Two routes changed, uploaded-logs deliberately unchanged. Tests: two short-circuit tests (bare-form / no-preset -> 429 when over budget), two double-charge guards (a criteria search and a preset-bearing layered run each stay 200 at a tight budget -- they red if a gate-level `phi=` is reintroduced), and the uploaded-logs scope guard. Each falsified by mutation (remove the inline charge -> the short-circuit test reds; reintroduce gate `phi=` -> the criteria guards red). Docs: the `require_ui_step_up` docstring, `docs/SECURITY.md` and the webconsole CHANGELOG now describe the inline-charge mechanism. The edit pair (BACKLOG #324) still uses gate `phi=` because `get_message` does not self-pace on a direct console call. NOT-DEPLOYED beta: written in the conditional ("would skip the budget on first deployment"), not a live exposure. --- docs/SECURITY.md | 4 +- messagefoundry_webconsole/_auth.py | 38 +++++--- messagefoundry_webconsole/routes/search.py | 19 ++++ .../messagefoundry-webconsole/CHANGELOG.md | 14 +++ .../tests/test_search_presets_ui.py | 89 ++++++++++++++++++- .../tests/test_uploaded_logs_ui.py | 38 +++++++- 6 files changed, 185 insertions(+), 17 deletions(-) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 80a4c8c0..6a2d7115 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -468,11 +468,11 @@ PHI on the wire: the twelve message/search rows above marked PHI (`/messages`, ` them carry an explicit PHI-read hop refusal + per-actor budget; the other four (`/search/presets` × 3 and `POST /uploads/{id}/resend`) return no body content of their own. -**With the console served** (`serve_ui=True` — the deployed posture for a console-served instance) **at least ten more** emit PHI. ⚠️ **This is deliberately not a closed enumeration**, per CLAUDE.md §11: a fixed count is a liability that the next PHI-emitting route silently falsifies, and this one already was — it read "nine more" and omitted `POST /ui/messages/{id}/edit-resend`, whose `_reject` arm re-renders both the pristine `core.get_message` detail and the operator's edited `raw_value`. **The authority is the code, not this list:** a `/ui` route emits PHI if it renders a message body, and the ones that charge the per-actor read budget are exactly those passing `phi=True` to `require_ui` or `require_ui_step_up` (`messagefoundry_webconsole/_auth.py`). Known today: +**With the console served** (`serve_ui=True` — the deployed posture for a console-served instance) **at least ten more** emit PHI. ⚠️ **This is deliberately not a closed enumeration**, per CLAUDE.md §11: a fixed count is a liability that the next PHI-emitting route silently falsifies, and this one already was — it read "nine more" and omitted `POST /ui/messages/{id}/edit-resend`, whose `_reject` arm re-renders both the pristine `core.get_message` detail and the operator's edited `raw_value`. **The authority is the code, not this list:** a `/ui` route emits PHI if it renders a message body, and the ones that charge the per-actor read budget are those passing `phi=True` to `require_ui` / `require_ui_step_up` (`messagefoundry_webconsole/_auth.py`) **or** that reach `enforce_phi_read_pacing` some other way — a reused engine handler that paces in its own body (`search_messages` / `layered_search` / `browse_uploaded_file`), or a console route that charges it inline on a short-circuit render (BACKLOG #1025). Known today: `GET /ui/messages`, `/ui/messages/{id}`, `/ui/messages/{id}/parse-tree`, `/ui/messages/{id}/attachments/{id}`, `/ui/messages/{id}/edit`, `POST /ui/messages/{id}/edit-resend`, `GET /ui/messages/search`, `/ui/messages/search/layered`, `/ui/dead-letters` and -`/ui/uploaded-logs/file/{file_id}` — of which the three search/upload routes carry **no** read budget (BACKLOG #1025). Note +`/ui/uploaded-logs/file/{file_id}` — all four charge the per-actor read budget (BACKLOG #1025): the reused engine handlers behind search, layered and uploaded-browse pace it in their own body, and #1025 additionally charges the two search routes' short-circuit renders (bare-form / no-preset) **inline**, since those return before the handler runs — the uploaded-browse route needs no extra charge (its handler paces every call and it has no short-circuit, so any second charge would double-count). Note `GET /ui/messages/{message_id}/edit`: it renders the full message detail, so it requires `messages:view_raw` **as well as** `messages:edit` and fails closed on either (BACKLOG #324). The catalogue's "implies `messages:view_raw`" for `messages:edit` remains a **built-in-role convention, diff --git a/messagefoundry_webconsole/_auth.py b/messagefoundry_webconsole/_auth.py index a1d85139..f11cdd93 100644 --- a/messagefoundry_webconsole/_auth.py +++ b/messagefoundry_webconsole/_auth.py @@ -521,17 +521,33 @@ def require_ui_step_up( JSON ``require_phi_read`` routes charge. It defaults **False** because most routes riding this factory are admin writes (user/role management, replay, purge, config reload) that emit no message body: charging a PHI budget there would throttle administration on a quota that measures PHI reads. - Set it on a step-up route only when the route's own response carries PHI. It is **not** yet set on - every such route: at least ``GET /ui/messages/search``, ``GET /ui/messages/search/layered`` and - ``GET /ui/uploaded-logs/file/{file_id}`` would still skip this budget on a deployed instance. - BACKLOG #324 gated the edit pair only, by owner ruling, and deliberately left those — a gap stated - here, not an accepted posture. That list is a **sample, not a census**: to know which routes charge - the budget today, read the gates, not this docstring. - - ORDERING, deliberate: the budget is charged inside ``base``, i.e. BEFORE the step-up freshness - check below — so a request that ends in a 303 to ``/ui/reauth`` has already spent a token. That is - the fail-safe direction (an attacker cannot probe the route for free by letting the window go - stale) and is immaterial at the shipped 120-reads/60s default. + The charge runs in the DEPENDENCY, i.e. on EVERY request the route serves, BEFORE the body. So set + it only when the route's own response carries PHI *and* the handler it calls does NOT already pace + the budget itself — otherwise the two charges stack. The rule splits by handler: + + * Handlers whose pacing lives ONLY in their ``require_phi_read`` Depends — e.g. ``get_message`` — + do NOT charge when a /ui route calls them DIRECTLY (that Depends is skipped on a direct call), so + the gate ``phi=`` is the route's single charge. ``GET /ui/messages/{id}/edit`` and + ``POST /ui/messages/{id}/edit-resend`` set it for exactly this reason (BACKLOG #324). + * Handlers that call ``enforce_phi_read_pacing`` in their OWN body — ``search_messages``, + ``export_messages``, ``layered_search``, ``browse_uploaded_file`` (``require_step_up`` paces + NON-GET only, so these step-up GETs pace themselves) — are ALREADY charged whenever a route + reaches them, so a gate ``phi=`` would spend the SAME per-actor bucket twice. A route that always + reaches such a handler (``GET /ui/uploaded-logs/file/{file_id}``) therefore passes NO ``phi=``. A + route that reaches one only on its criteria path and otherwise short-circuits to a render + (``GET /ui/messages/search``, ``GET /ui/messages/search/layered``) also passes NO ``phi=``: + instead it charges ``enforce_phi_read_pacing`` INLINE on the short-circuit branch only (BACKLOG + #1025), so the render is budgeted without double-charging the real-search path. + + To know which routes charge the budget today, read the gates AND the handler bodies, not this + docstring. + + ORDERING, deliberate (gate ``phi=`` routes, i.e. the edit pair): the budget is charged inside + ``base``, i.e. BEFORE the step-up freshness check below — so a request that ends in a 303 to + ``/ui/reauth`` has already spent a token. That is the fail-safe direction (an attacker cannot probe + the route for free by letting the window go stale) and is immaterial at the shipped 120-reads/60s + default. (An INLINE charge, as the two search routes use, runs after the dependency instead, so a + stale-window request there redirects before charging — harmless, since a redirect emits no PHI.) """ # allow_mfa_pending: the base must NOT fire the 6.3.3 redirect for a step-up route. This # factory runs its OWN mfa_satisfied check below, which 303s to /ui/reauth *carrying the diff --git a/messagefoundry_webconsole/routes/search.py b/messagefoundry_webconsole/routes/search.py index 7639c310..1a0a6a50 100644 --- a/messagefoundry_webconsole/routes/search.py +++ b/messagefoundry_webconsole/routes/search.py @@ -13,6 +13,7 @@ from messagefoundry.api._ui_seam import UiDeps from messagefoundry.api.models import SearchPresetCreateRequest, SearchPresetCriteria +from messagefoundry.api.security import enforce_phi_read_pacing from messagefoundry.auth import Identity, Permission from .. import pages @@ -54,6 +55,11 @@ async def _presets(engine: Any, identity: Identity, request: Request) -> list[An async def ui_message_search( request: Request, engine: Any = Depends(deps.get_engine), + # No gate-level phi= here (BACKLOG #1025): require_ui_step_up charges the budget in the + # dependency, i.e. on EVERY request, but a criteria-bearing search already charges once inside + # core.search_messages (its body calls enforce_phi_read_pacing, app.py) — so a gate-level phi= + # would spend the same per-actor bucket twice on the real-search path. The genuinely-unpaced + # path is only the bare-form render, which is charged explicitly in its branch below. identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_READ)), content: str | None = Query(None, max_length=512), field_path: str | None = Query(None, max_length=32), @@ -81,6 +87,11 @@ async def ui_message_search( control_id=control_id or "", ) if not has_criteria: + # BACKLOG #1025: this bare-form render returns WITHOUT reaching core.search_messages, + # whose body charges the per-actor read budget. Charge here — only on the short-circuit + # branch — so the render is under the same budget as a real search WITHOUT double-charging + # it (429 + Retry-After when the actor is over budget). + enforce_phi_read_pacing(request, identity) return HTMLResponse(pages.message_search(None, presets=preset_list, **shared)) try: # Call the JSON handler directly (its require_step_up Depends is skipped — @@ -173,12 +184,20 @@ async def ui_delete_preset( async def ui_layered_search( request: Request, engine: Any = Depends(deps.get_engine), + # No gate-level phi= here, as on ui_message_search (BACKLOG #1025): the composed run already + # charges once inside core.layered_search, so a gate-level phi= would double-charge that path. + # The no-preset 400 re-render is the only unpaced path, charged explicitly in its branch below. identity: Identity = Depends(require_ui_step_up(Permission.MESSAGES_READ)), presets: list[str] | None = Query(None), ) -> HTMLResponse: preset_list = await _presets(engine, identity, request) ids = ",".join(p for p in (presets or []) if p) if not ids: + # BACKLOG #1025, as on ui_message_search: this no-preset 400 re-render returns before + # core.layered_search (which charges the budget in its own body), so charge here — only on + # this short-circuit branch — to bring it under the per-actor read budget without + # double-charging the composed-run path. + enforce_phi_read_pacing(request, identity) return HTMLResponse( pages.message_search( None, error="select at least one preset to layer", presets=preset_list diff --git a/packaging/messagefoundry-webconsole/CHANGELOG.md b/packaging/messagefoundry-webconsole/CHANGELOG.md index 5beb55ee..28777b22 100644 --- a/packaging/messagefoundry-webconsole/CHANGELOG.md +++ b/packaging/messagefoundry-webconsole/CHANGELOG.md @@ -36,6 +36,20 @@ the engine compatibility range. role gets `403` on the editor (it can still resubmit through the JSON API); no built-in role is affected, since `ADMINISTRATOR` and `OPERATOR` grant both. Both verbs additionally charge the per-actor PHI-read budget now, so either can return `429` + `Retry-After` under automation. +- **The two content-search step-up GET routes now charge the per-actor PHI-read budget on their + short-circuit renders** (BACKLOG #1025). `GET /ui/messages/search` and `GET /ui/messages/search/layered` + already charged the budget when they ran a real search — the reused engine handlers + (`search_messages` / `layered_search`) pace it in their own body — but the bare-form and no-preset + re-renders return *before* reaching those handlers, so on a deployed instance those render paths + would have skipped the per-actor read budget. Each now charges `enforce_phi_read_pacing` **inline on + its short-circuit branch only**, so the render spends a token and the route can return `429` + + `Retry-After` under automation, **without** double-charging the real-search path (a gate-level + `phi=`, which runs on every request, would have spent the bucket twice whenever a criterion was + supplied). `GET /ui/uploaded-logs/file/{file_id}`, named alongside them in the original report, was + found already paced by its own handler (`browse_uploaded_file`) on every call — it has no + short-circuit — and is deliberately left unchanged; a charge there would double-count the same + budget. **No engine UI seam change:** the charge reuses the existing `enforce_phi_read_pacing` helper + the reused handlers already call. ## [0.2.15] — 2026-07-06 — Early Access diff --git a/packaging/messagefoundry-webconsole/tests/test_search_presets_ui.py b/packaging/messagefoundry-webconsole/tests/test_search_presets_ui.py index 06d40f47..41eb2fa6 100644 --- a/packaging/messagefoundry-webconsole/tests/test_search_presets_ui.py +++ b/packaging/messagefoundry-webconsole/tests/test_search_presets_ui.py @@ -29,8 +29,12 @@ async def engine(tmp_path: Path) -> AsyncIterator[Engine]: await e.stop() -async def _op(engine: Engine) -> AuthService: - service = AuthService(engine.store, AuthSettings(require_mfa=False)) +async def _op(engine: Engine, *, per_actor: int = 120) -> AuthService: + # per_actor mirrors AuthSettings' default (120); pass a smaller value to exercise the per-actor + # PHI-read budget in a single test (see test_webui.py:616 test_edit_editor_charges_the_phi_read_budget). + service = AuthService( + engine.store, AuthSettings(require_mfa=False, phi_read_rate_limit_per_actor=per_actor) + ) await service.initialize() uid = await service.create_local_user( username="op", @@ -89,3 +93,84 @@ async def test_preset_save_and_layer_ui(engine: Engine) -> None: assert d.status_code in (200, 303) r = await c.get("/ui/messages/search") assert "acme" not in r.text + + +async def test_search_message_search_charges_the_phi_read_budget(engine: Engine) -> None: + # BACKLOG #1025: GET /ui/messages/search's SHORT-CIRCUIT path. A criteria-bearing search already + # charges the budget inside core.search_messages, but the bare-form render (no criteria) returns + # before that handler runs. The route charges enforce_phi_read_pacing INLINE on that branch, so + # even the bare-form 200 spends a token; a second one over budget 429s. Without the fix a deploying + # instance would leave this bare-form render path outside the per-actor read budget. + service = await _op(engine, per_actor=1) + transport = httpx.ASGITransport(app=create_app(engine, auth=service, serve_ui=True)) + async with httpx.AsyncClient(transport=transport, base_url="http://t") as c: + await c.post("/ui/login", data={"username": "op", "password": PW}) + first = await c.get("/ui/messages/search") + assert first.status_code == 200, first.text # bare-form render still charges token 1 + second = await c.get("/ui/messages/search") + assert second.status_code == 429 + assert second.headers["Retry-After"] + + +async def test_search_layered_charges_the_phi_read_budget(engine: Engine) -> None: + # BACKLOG #1025: GET /ui/messages/search/layered's SHORT-CIRCUIT path. A composed run already + # charges inside core.layered_search, but the no-presets 400 re-render returns before that handler + # runs. The route charges enforce_phi_read_pacing INLINE on that branch, so the second over-budget + # request 429s rather than 400ing again. Without the fix a deploying instance would leave this + # no-presets render path outside the read budget. + service = await _op(engine, per_actor=1) + transport = httpx.ASGITransport(app=create_app(engine, auth=service, serve_ui=True)) + async with httpx.AsyncClient(transport=transport, base_url="http://t") as c: + await c.post("/ui/login", data={"username": "op", "password": PW}) + first = await c.get("/ui/messages/search/layered") + assert first.status_code == 400, first.text # no-presets re-render, token 1 already spent + second = await c.get("/ui/messages/search/layered") + assert second.status_code == 429 + assert second.headers["Retry-After"] + + +async def test_search_message_search_criteria_charges_once(engine: Engine) -> None: + # BACKLOG #1025 DOUBLE-CHARGE guard (the regression the earlier gate-level phi= introduced): a + # REAL, criteria-bearing search already charges the per-actor read budget exactly once inside + # core.search_messages (its body calls enforce_phi_read_pacing). The console must NOT charge it a + # second time. At per_actor=1 the single criteria search spends its one token and returns 200; a + # gate-level phi= on require_ui_step_up would spend a second token and 429 the FIRST real search + # before it runs. Synthetic needle only (matches nothing in an empty store -> 200, empty results). + service = await _op(engine, per_actor=1) + transport = httpx.ASGITransport(app=create_app(engine, auth=service, serve_ui=True)) + async with httpx.AsyncClient(transport=transport, base_url="http://t") as c: + await c.post("/ui/login", data={"username": "op", "password": PW}) + r = await c.get("/ui/messages/search", params={"content": "MRN999"}) + # charged once by the handler, not twice by gate+handler + assert r.status_code == 200, r.text + + +async def test_search_layered_criteria_charges_once(engine: Engine) -> None: + # BACKLOG #1025 DOUBLE-CHARGE guard for the layered route: a composed run over a real preset + # charges once inside core.layered_search. per_actor=2 budgets exactly: the bare-form render that + # extracts the preset id spends token 1 (its inline short-circuit charge) and the composed run + # spends token 2 -> 200. A gate-level phi= would make the composed run spend token 2 AND token 3 + # (gate + handler), overrunning the budget and 429ing it. Synthetic HL7 only (reuses ADT/MRN999). + await engine.store.enqueue_message( + channel_id="ch1", + raw=ADT, + deliveries=[("o", ADT)], + control_id="MSG1", + message_type="ADT^A01", + ) + service = await _op(engine, per_actor=2) + transport = httpx.ASGITransport(app=create_app(engine, auth=service, serve_ui=True)) + async with httpx.AsyncClient(transport=transport, base_url="http://t") as c: + await c.post("/ui/login", data={"username": "op", "password": PW}) + s = await c.post( + "/ui/messages/search/presets", + data={"name": "acme", "content": "MRN999", "message_type": "ADT^A01"}, + ) + assert s.status_code in (200, 303), s.text # preset save is not phi-paced (no token) + r = await c.get("/ui/messages/search") # bare-form render: spends token 1, carries the id + marker = "/ui/messages/search/presets/" + pid = r.text.split(marker, 1)[1].split("/delete", 1)[0] + lay = await c.get("/ui/messages/search/layered", params={"presets": pid}) + # composed run charges token 2 only -> within budget + assert lay.status_code == 200, lay.text + assert "match(es)" in lay.text diff --git a/packaging/messagefoundry-webconsole/tests/test_uploaded_logs_ui.py b/packaging/messagefoundry-webconsole/tests/test_uploaded_logs_ui.py index 8d4aa64d..9f4101bb 100644 --- a/packaging/messagefoundry-webconsole/tests/test_uploaded_logs_ui.py +++ b/packaging/messagefoundry-webconsole/tests/test_uploaded_logs_ui.py @@ -23,8 +23,12 @@ BATCH = ADT + ADT2 -async def _service(engine: Engine, *users: tuple[str, Role]) -> AuthService: - service = AuthService(engine.store, AuthSettings(require_mfa=False)) +async def _service(engine: Engine, *users: tuple[str, Role], per_actor: int = 120) -> AuthService: + # per_actor mirrors AuthSettings' default (120); pass a smaller value to exercise the per-actor + # PHI-read budget in a single test (see test_webui.py:616 test_edit_editor_charges_the_phi_read_budget). + service = AuthService( + engine.store, AuthSettings(require_mfa=False, phi_read_rate_limit_per_actor=per_actor) + ) await service.initialize() for name, role in users: uid = await service.create_local_user( @@ -118,3 +122,33 @@ async def test_uploaded_logs_ui_503_when_unconfigured(engine: Engine, tmp_path: async with httpx.AsyncClient(transport=transport, base_url="http://t") as c: await _login(c, "op") assert (await c.get("/ui/uploaded-logs")).status_code == 503 + + +async def test_uploaded_log_browse_charges_the_phi_read_budget( + engine: Engine, tmp_path: Path +) -> None: + # BACKLOG #1025 scope guard: GET /ui/uploaded-logs/file/{file_id} is ALREADY under the per-actor + # read budget and needs no console-side phi= — unlike the two search routes, its console handler has + # no short-circuit render path: it always calls core.browse_uploaded_file, whose body itself calls + # enforce_phi_read_pacing (app.py). So the metadata browse charges token 1 (render = 200) and a + # second browse over the budget 429s, with NO phi= on require_ui_step_up. Adding phi= here would + # charge the same bucket twice (dependency + handler body), 429ing even the first browse. Upload + + # list are plain require_ui (no phi), so neither spends a token. Synthetic HL7 only (reuses BATCH). + service = await _service(engine, ("op", Role.OPERATOR), per_actor=1) + transport = httpx.ASGITransport(app=_app(engine, service, tmp_path)) + async with httpx.AsyncClient(transport=transport, base_url="http://t") as c: + await _login(c, "op") + up = await c.post( + "/ui/uploaded-logs/upload", + files={"file": ("acme.hl7", BATCH, "application/octet-stream")}, + ) + assert up.status_code in (200, 303), up.text + r = await c.get("/ui/uploaded-logs") # require_ui, no phi: spends no token + marker = "/ui/uploaded-logs/file/" + fid = r.text.split(marker, 1)[1].split('"', 1)[0].split("/")[0] + assert len(fid) == 32 + first = await c.get(f"/ui/uploaded-logs/file/{fid}") + assert first.status_code == 200, first.text # metadata browse charges token 1 + second = await c.get(f"/ui/uploaded-logs/file/{fid}") + assert second.status_code == 429 + assert second.headers["Retry-After"] From a1d2782102aac10bc5ed98933ac6c91122bd1c51 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 6 Aug 2026 00:42:15 -0500 Subject: [PATCH 2/2] docs(backlog): flip #1025 banner to shipped (BACKLOG #1025) Flip the #1025 banner from filed to shipped and correct its mechanism note from the gate-level `phi=` the earlier draft described to the inline short-circuit charge that actually shipped (a gate `phi=` would have double-charged the criteria path). Banner line only. The census distribution lines were NOT recomputed; this commit touches only the #1025 banner. The item's Severity/Mechanism body paragraphs still describe the pre-amendment three-route premise and need a follow-up reconciliation not bound by the banner-only rule (flagged for the owner). --- docs/BACKLOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index e3142125..462125ea 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -4953,7 +4953,7 @@ The comment immediately above says *"Scope is deliberately the posture the requi ## 1025. Three `require_ui_step_up` routes emit PHI with no `phi=`, so they charge no per-actor read budget -> 🔢 **Filed 2026-08-05 — not started.** Value **5/10** · Difficulty **2/10** · _fill-in_. `GET /ui/messages/search`, `/ui/messages/search/layered` and `/ui/uploaded-logs/file/{file_id}` put PHI on the wire through `require_ui_step_up` without `phi=`, so `require_ui`'s `allow_phi_read` throttle never runs for them. **A missing rate limit, not a missing authorization check** — all three still gate on the right permission. +> ✅ **SHIPPED 2026-08-06 — the two content-search render paths brought under the per-actor read budget; the third route was already covered.** Value **5/10** · Difficulty **2/10** · _fill-in_. **AMENDED 2026-08-05 — scope corrected against the code before building.** The filing's premise (all three routes charge no read budget) does not hold: `search_messages`, `layered_search` and `browse_uploaded_file` each call `enforce_phi_read_pacing` in their own body — which the console executes when it invokes them directly — so every request that actually reaches a handler was already charged at the cited commit `e0482aea`. The real gap was only the console's SHORT-CIRCUIT renders (`GET /ui/messages/search` bare-form, `GET /ui/messages/search/layered` no-preset) that return *before* the handler runs. **AMENDED 2026-08-06 — mechanism corrected from a gate-level `phi=` to an inline branch charge.** A gate-level `phi=` on `require_ui_step_up` charges in the dependency, i.e. on *every* request, so it would have double-charged the criteria/preset path — which already charges in the handler — the exact double-count that excludes the uploaded route. Instead each search route now charges `enforce_phi_read_pacing` **inline on its short-circuit branch only**, so the bare-form / no-preset render spends a token while a real search still charges exactly once. `GET /ui/uploaded-logs/file/{file_id}` was deliberately left unchanged — it has no short-circuit and `browse_uploaded_file` paces every call, so any second charge would double-count the same budget (empirically the first browse would `429` at a budget of 1). **A missing rate limit, not a missing authorization check** — all three still gate on the right permission. Shipped: the two search short-circuit charges + the `require_ui_step_up` docstring corrected + `docs/SECURITY.md` and the webconsole CHANGELOG aligned to the true mechanism. **Cluster:** Security / PHI anti-automation. **Priority:** P2. **Verdict:** build (small). **Severity:** would leave three PHI-emitting console routes outside the per-actor read budget on first deployment, so an authorised-but-abusive actor could enumerate through them without hitting the 429 the sibling browse routes enforce. No unauthorised access.