fix(ui): refuse a console input the JSON twin rejects (BACKLOG #1744) - #1204
Open
wshallwshall wants to merge 2 commits into
Open
wshallwshall wants to merge 2 commits into
wshallwshall wants to merge 2 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/mainc25e564 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, asroutes/search.pydoes") 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}/suspendbuiltAlertSuspendRequest(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 thealert_suspendaudit row would record the substitute as the request.Half two, the received-date bounds.
GET /ui/messagesdropped areceived_from/received_tovalue 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:EpochSecondswindow the JSON/messagesroute declares (before 1970, after 2100), and.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.POST /ui/alerts/{id}/suspendminutes=999999{"alert_id": 1, "minutes": 60.0}minutes=not-a-numberGET /ui/messagesreceived_to=not-a-datereceived_to=2020-01-01T00:00excludes it)received_from=1900-01-01T00:00received_from=9999-12-31T23:59received_from=2026-09-16T10:00+05:00Running 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, thedefer=1landing, 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_suspendaudit row is written.The change
Two commits. The first is the fix; the second is the
/simplifypass 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_pagehelper that re-renders/ui/alertsat 400, matching the per-area shapeadmin._user_detailand the account pages already use.routes/core.py—_epoch()now raises instead of returningNone, and the route refuses with a 400 re-render of the filter form carrying what the operator typed. The parsed instant goes throughTypeAdapter(EpochSeconds), the same annotated type the JSON/messagesroute declares, so the two surfaces refuse the same instants rather than the console inventing a rule.pages/messages.py,pages/monitoring.py— each builder takeserrorand renders abannerparagraph, the conventionpages.message_searchalready uses.Both operator-facing messages are derived, not transcribed. The suspend ceiling is named once as
api.validation.ALERT_SUSPEND_MINUTES_MAXandAlertSuspendRequestuses it; the date window is rendered fromEPOCH_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 isgt=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/alertsgates onmonitoring:read, while the suspend route holdsmonitoring:diagnoseonly. Sopages.alertsnow takesconfig=Noneand 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
ruff check .ruff format --check .mypy messagefoundry messagefoundry_webconsole --exclude 'messagefoundry/tray/'(the CI invocation)pytest packaging/messagefoundry-webconsole/testspytestover the 28 engine tests importingmessagefoundry_webconsole, plus the api/alerts/validation/content-search testsEvery row above was re-run on the final head after the
/simplifycommit, 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
PYTHONPATHset to the worktree root. Without it, this worktree'spytestimports 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, onlyPYTHONPATHdiffering:This is a local worktree artifact; CI installs the package and does not have it. Two notes for whoever hits it next:
python -c "import messagefoundry_webconsole"is a false-negative instrument.-cputs the working directory first onsys.path; pytest does not. It reports the worktree copy while pytest reads the snapshot. Usepython -P -c, or assert the import path from inside a test._editable_impl_messagefoundry.pth) and resolves correctly either way. Only the console is affected.Left alone, deliberately
docs/BACKLOG.mdis untouched. It is a policy stub here; the ledger is maintainer-internal. This section is the item's status text./uiroutes declare a query parameter with weaker validation than its JSON twin (for examplechannel_id: str | None = Query(None, max_length=256)inroutes/core.pywhere the JSON/messagesroute declaresConnectionName). 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.mdlines 107-111 already names this seam, already counts the population, and already carves out these exact two parameters — "the console'sreceived_fromandreceived_toaredatetime-localstrings 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 adatetime-localstring is not anEpochSecondsfloat. So the two pieces of work partition with no overlap and no gap.routes/core.pynow 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
deferearly return costs ~1.1 microseconds against a 115 microsecond render, and moving the check after that branch would let a hand-builtdefer=1&received_from=garbageURL 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-localfield 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.