Skip to content

fix(ui): refuse a console input the JSON twin rejects (BACKLOG #1744) - #1204

Open
wshallwshall wants to merge 2 commits into
mainfrom
claude/b1744-console-validate
Open

wshallwshall wants to merge 2 commits into
mainfrom
claude/b1744-console-validate

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

BACKLOG #1744 status

Item: two console routes silently substitute a value where the JSON twin refuses the input. P3, value 3/10, difficulty 1/10, cluster input validation / console, verdict build, closing act code.

Both halves were re-measured by execution at origin/main c25e564 and both defects were present. Both are now fixed and pinned by tests that fail on the pre-fix source. The item's own closing step ("refuse with 400 and re-render with the error, as routes/search.py does") is what this change does.

Severity stays Low and conditional. MessageFoundry has zero deployments, so nothing below is a live exposure: this is what a deploying site would have hit.

What was wrong

Half one, the alert suspend. POST /ui/alerts/{id}/suspend built AlertSuspendRequest(minutes=...) and, on any refusal, substituted 60 minutes. An operator would be told the suspend succeeded, the engine would mute a window nobody asked for, and the alert_suspend audit row would record the substitute as the request.

Half two, the received-date bounds. GET /ui/messages dropped a received_from/received_to value it could not read and searched without it, so the page would come back carrying a filter the operator typed and the engine never applied. The item flagged this half as read-not-measured; measuring it found it is wider than the row states, in two ways beyond the dropped bound:

  • it accepted instants outside the EpochSeconds window the JSON /messages route declares (before 1970, after 2100), and
  • it silently re-stamped a value carrying its own UTC offset as a different instant, via .replace(tzinfo=UTC).

Those two are the same substitution defect on the same seam and are fixed with it, not scope creep into a new one.

Measured before, at c25e564

Instrument: the new test file plus an ad-hoc probe, both driving the ASGI app through httpx.

Surface Input Before JSON twin
POST /ui/alerts/{id}/suspend minutes=999999 303, muted 3600.57s (60 min), audit {"alert_id": 1, "minutes": 60.0} 422
same minutes=not-a-number 303, muted 60 min again, second identical audit row 422
GET /ui/messages received_to=not-a-date 200, bound dropped (seeded row present; a valid received_to=2020-01-01T00:00 excludes it) 422
same received_from=1900-01-01T00:00 200, negative epoch reached the store query 422 on the same epoch
same received_from=9999-12-31T23:59 200, epoch past the 2100 ceiling reached the store query 422 on the same epoch
same received_from=2026-09-16T10:00+05:00 200, offset discarded and re-stamped as UTC n/a (JSON takes epoch seconds)

Running the final test file against the base source with the fix reverted (git checkout c25e56499 -- messagefoundry_webconsole/ messagefoundry/api/): 20 failed, 4 passed. The 4 that pass are the positive controls (a valid suspend window, a valid date bound, the defer=1 landing, and the JSON twin's own 422), which is the shape you want: the controls pass before and after, and every refusal assertion fails before.

Measured after

Same file on the fixed source: 24 passed. Each refusal now answers 400 and re-renders its own page with the error, the alert stays unmuted, and no alert_suspend audit row is written.

The change

Two commits. The first is the fix; the second is the /simplify pass over it, kept separate so the behaviour change reads on its own.

  • routes/monitoring_writes.py — the suspend route refuses instead of substituting, through a _refuse_on_alerts_page helper that re-renders /ui/alerts at 400, matching the per-area shape admin._user_detail and the account pages already use.
  • routes/core.py_epoch() now raises instead of returning None, and the route refuses with a 400 re-render of the filter form carrying what the operator typed. The parsed instant goes through TypeAdapter(EpochSeconds), the same annotated type the JSON /messages route declares, so the two surfaces refuse the same instants rather than the console inventing a rule.
  • pages/messages.py, pages/monitoring.py — each builder takes error and renders a banner paragraph, the convention pages.message_search already uses.

Both operator-facing messages are derived, not transcribed. The suspend ceiling is named once as api.validation.ALERT_SUSPEND_MINUTES_MAX and AlertSuspendRequest uses it; the date window is rendered from EPOCH_SECONDS_MAX. That also fixed an error the first commit introduced: the message said the window must be "from 1 to 43200" while the model is gt=0, so it told the operator the twin rejects 0.5 minutes, which it does not.

One permission note. The refusal re-renders /ui/alerts, whose Rules half /ui/alerts gates on monitoring:read, while the suspend route holds monitoring:diagnose only. So pages.alerts now takes config=None and the helper fetches the rules only for a caller that also holds read. A custom role holding diagnose without read gets the refusal with no Rules section; an operator gets it with. Both arms are tested.

Checks run

Check Result
ruff check . pass
ruff format --check . pass (1306 files)
mypy messagefoundry messagefoundry_webconsole --exclude 'messagefoundry/tray/' (the CI invocation) pass, 293 files
pytest packaging/messagefoundry-webconsole/tests 525 passed, 3 skipped
pytest over the 28 engine tests importing messagefoundry_webconsole, plus the api/alerts/validation/content-search tests 995 passed, 1 skipped
new file alone, pre-fix source 20 failed, 4 passed (the intended before-reading)
new file alone, fixed source 24 passed

Every row above was re-run on the final head after the /simplify commit, not only on the first one.

Not run locally: the full engine suite, and any hosted-runner-only leg (windows-service-smoke, the SQL Server and Postgres store legs). Those need reading on the PR.

HOW THE CONSOLE TESTS WERE RUN, AND WHY IT MATTERS

Every console run above was made with PYTHONPATH set to the worktree root. Without it, this worktree's pytest imports a frozen non-editable copy of the console from .venv/Lib/site-packages/messagefoundry_webconsole (0.2.15) instead of the worktree source, so a run can go green having tested none of the change. Measured here, same command, only PYTHONPATH differing:

plain:            CONSOLE FILE: ...\.venv\Lib\site-packages\messagefoundry_webconsole\__init__.py   HAS FIX: False
with PYTHONPATH:  CONSOLE FILE: ...\worktrees\...-b1744-console-validate\messagefoundry_webconsole\__init__.py   HAS FIX: True

This is a local worktree artifact; CI installs the package and does not have it. Two notes for whoever hits it next:

  1. python -c "import messagefoundry_webconsole" is a false-negative instrument. -c puts the working directory first on sys.path; pytest does not. It reports the worktree copy while pytest reads the snapshot. Use python -P -c, or assert the import path from inside a test.
  2. The engine package is installed editable (_editable_impl_messagefoundry.pth) and resolves correctly either way. Only the console is affected.

Left alone, deliberately

  • docs/BACKLOG.md is untouched. It is a policy stub here; the ledger is maintainer-internal. This section is the item's status text.
  • The seam's missing path and query rules is a separate filed item and is not absorbed here. It is real and it is wider than this change: several other /ui routes declare a query parameter with weaker validation than its JSON twin (for example channel_id: str | None = Query(None, max_length=256) in routes/core.py where the JSON /messages route declares ConnectionName). This PR fixes the two routes #1744 names and adopts the JSON route's own annotated type for the dates, which is the shape that item would generalize.

Review

Four independent review passes ran over the diff (reuse, simplification, efficiency, altitude); the second commit is what they produced.

The altitude pass is worth repeating here because it checked the scope question directly: docs/API-INPUT-VALIDATION.md lines 107-111 already names this seam, already counts the population, and already carves out these exact two parameters — "the console's received_from and received_to are datetime-local strings from a browser form, not the epoch numbers the engine API takes". The other declarations are closed by swapping an annotation, which is the filed item's work. These two cannot be, because a datetime-local string is not an EpochSeconds float. So the two pieces of work partition with no overlap and no gap. routes/core.py now carries a comment saying so, because the next reader's likely move is to "finish the job" on the four filters beside them and collide with that item.

The efficiency pass measured the one thing worth measuring: validating the bounds before the defer early return costs ~1.1 microseconds against a 115 microsecond render, and moving the check after that branch would let a hand-built defer=1&received_from=garbage URL stop refusing. Left as is.

Questions the brief left open

None that block the merge. One thing worth a decision, recorded rather than acted on: refusing an offset-bearing date bound is a judgement call I made inside this seam. A datetime-local field never sends an offset, so only a hand-built URL reaches it; refusing is consistent with "refuse rather than substitute", but accepting-and-honouring the offset would also be defensible. It is one branch in _epoch() and one test if it should go the other way.

wshallwshall added 2 commits September 16, 2026 13:19
Two /ui routes substituted a value where the JSON handler answers 422, so an
operator would be told a suspend or a filter succeeded while the engine did
something else.

POST /ui/alerts/{id}/suspend fell back to 60 minutes for an out-of-range or
undecodable window. It answered 303, muted a window nobody asked for, and wrote
"minutes": 60.0 into the alert_suspend audit row as if that were the request.

GET /ui/messages dropped a received_from/received_to bound it could not read,
so the page came back carrying a filter the operator had typed and the engine
had not applied. It also accepted instants outside the EpochSeconds window the
JSON route declares, and silently re-stamped an offset-bearing value as UTC.

Both now refuse with 400 and re-render the page with the error, the shape
routes/search.py already uses. The date bounds go through the same annotated
EpochSeconds type the JSON /messages route declares, so the two surfaces refuse
the same instants.

pages.alerts takes config=None for a caller holding monitoring:diagnose without
monitoring:read: the rules half is read-gated, so a refusal rendered from the
suspend route must not widen what that caller can see.
…BACKLOG #1744)

The /simplify pass over the previous commit, applied.

The suspend message said the window must be "from 1 to 43200", but the model is
gt=0 -- it accepts 0.5 minutes. The prose disagreed with the bound at birth.
Both operator-facing messages are now derived rather than transcribed: the
suspend ceiling is named once as api.validation.ALERT_SUSPEND_MINUTES_MAX and
the model uses it, and the date-bound window is rendered from EPOCH_SECONDS_MAX.

Two modules render the alerts page now, so the active-instance limit is named
once in routes/_common.py. Two copies would drift, and the refusal would then
show a shorter list than the page the operator came from.

routes/core.py builds the echoed-back filter values once instead of repeating
six keywords at each of three render arms, as routes/search.py does; a TypedDict
keeps mypy matching each key to its named parameter through the splat. The
message-log parameter block gains a comment saying why the four metadata filters
keep their length-only bounds: that is the separate console-declaration item,
and the date bounds are a different data item, which docs/API-INPUT-VALIDATION.md
already carves out.

pages/monitoring.py returns to a flat page() call with the house sentinel for an
optional element, and the Rules heading moves inside the section helper.

The date-refusal tests collapse into one parametrized case carrying the strongest
assertions, so every refused value is now checked for the echo-back and for not
having been searched; an operator-client fixture replaces the repeated setup.
@github-actions github-actions Bot added the ci-red A required check went red. Attribute it before retrying. label Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-red A required check went red. Attribute it before retrying.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant