Skip to content

feat(web): the JSON API over the existing reports, with server-side sort - #547

Merged
eaitbrahim merged 1 commit into
mainfrom
feat-534-json-api
Aug 24, 2026
Merged

feat(web): the JSON API over the existing reports, with server-side sort#547
eaitbrahim merged 1 commit into
mainfrom
feat-534-json-api

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Closes #534. Unblocks #536#539.

Reads only. No new write surface, and keel/web/security.py, tests/commands/test_console_thinness.py and tests/web/test_server.py all have a 0-line diff — verified against the branch, not asserted.

The envelope

Every route answers with the same key set: {as_of, engine, data, sort}. Failures are {as_of, data: null, error: {status, title, detail}}, where status is a string ("404", not 404) — the no-JSON-numbers rule reaches the error document too, so a client never has to switch parsing mode.

route covers sortable
/api/config
/api/status / open_positions
/api/setup /setup — (runbook order is the information)
/api/activity /activity cycles
/api/insights /insights rules
/api/journal /insights entries
/api/rules /rules rules
/api/venues /venues venues
/api/gates /gates

/glossary deliberately has no counterpart — it becomes an outbound link in #539. test_the_api_routes_cover_every_html_route_that_reads compares both route tables mechanically, so #540 cannot delete an HTML route the API never replaced.

Sorting

?sort=<column>&dir=asc|desc. An unknown column or direction is a 400 naming the valid ones — silently ignoring is how a dead sort control ships. sort.columns is echoed even when nothing was sorted, so #537 reads the column list off the response rather than hardcoding it.

Ordering lives in payload.order_rows, beside the _plain that wrote the strings, and is Decimal per column — never per row, which would mix Decimal and str in one comparison. Absent values sort last in both directions.

The float test asserts its own premise first. Two ERC-20 quantities one wei apart — legal at an 18-decimal base increment — are distinct as Decimal and equal as float. test_float_and_decimal_orderings_of_the_same_column_genuinely_differ states that disagreement before the endpoint test runs, so the endpoint test cannot be green for the trivial reason that sorting sorts. Mutating order_rows to float produces [0.099, ...002, ...001] where Decimal gives [0.099, ...001, ...002].

Engine stopped is a state, not an error

HTTP 200, engine.state: "warn", data: null, as_of present.

Two deliberate calls recorded in the source: a non-2xx status is what #536/#538 will read as unreachable, and a first-run user with no deployment is not in an outage. And data: null rather than {} — a view handed an empty object renders zeros, which for a trading dashboard is worse than rendering nothing.

/api/config, /api/venues and /api/gates still carry data when stopped: they describe the binary, not a deployment.

The routing layer formats nothing, enforced by AST

test_the_routing_layer_formats_nothing walks api.py and fails on any format() call or f-string carrying a format spec — "a second money renderer is never the one with _plain's no-exponent guarantee in it."

test_rule_6_holds_in_the_api_layer_too runs #533's own _rule6_findings over api.py, rather than adding a stem to SERIALISER_STEMS — which would have meant editing the pinned file. That is why test_console_thinness.py is a 0-line diff.

Four temptations to compute in routing are recorded with what was done instead, including a per-gate action count that is not emitted at all (Rule 6e bans len(); the client reads actions.length).

Shared test helper — declared

tests/web/test_payload.py::_every_payload gained five entries. Purely additive, no signature change, and it brings the new payloads under the existing no-JSON-numbers walk and state-vocabulary check. Mutation-checked: a bare 1 and a "purple" state planted in the new builders fail both guards.

It deliberately did not reuse test_server.py::_request in test_api.py — that helper defaults client_header="1", which would have answered the central GET question on the test's behalf. That is the #545 failure mode, avoided on purpose, with the reasoning in the module docstring.

Two stale claims found and corrected

keel/web/__init__.py still said "the request handler implements do_GET and do_HEAD and nothing else" — false since #437. Corrected rather than deleted, because server.do_POST points readers at that file for the rule it must not break, and the replacement guarantee is stronger: the write surface is a closed set, and no capability-increasing action is reachable. server.py's "six read-only pages" over eight routes is fixed too.

One measurement worth knowing

setup.inspect is 3.6 ms/call against 0.07 ms for gather_status — 50×. It runs once per response on every endpoint including /api/config, deliberately, so engine is uniform and #536's client wrapper needs no table of which routes carry it. Imperceptible on loopback; recorded in api.py with the condition that would reverse it.

Gates

  • ruff check keel tests packages — All checks passed
  • mypy — no issues in 361 source files
  • pytest -q4745 passed, 3 skipped (80 new in tests/web/test_api.py)

🤖 Generated with Claude Code

https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6

…en keel isn't running

Nine `GET /api/*` endpoints over the reports the console already builds, one per read the
rendered routes perform. `/glossary` gets no counterpart on purpose: it becomes an outbound
keeltrading.com link in #539 and its renderer is deleted in #540, so an `/api/glossary` would be
a surface built in order to be removed, and any client written against it would break on the
release that removes it. `test_the_glossary_has_no_api_counterpart` states that absence as an
assertion rather than leaving it as an omission.

Reads only. Not one route answers a POST; `X-Keel-Client: 1` still gates `POST /api/*` and still
does not gate `/setup/*`; `keel/web/security.py` has a nought-line diff and so does
`tests/commands/test_console_thinness.py`.

THE ENVELOPE. Every success is `{as_of, engine, data, sort}` with a constant key set, so #536's
single `fetch` wrapper needs no per-endpoint branch -- a client that must test whether a key is
present is branching on payload SHAPE, which is the inference Rule 3 exists to remove. `engine`
is a judged field with a closed two-word vocabulary; a stopped engine answers `data: null` at
HTTP **200**, because #538's service worker and #536's wrapper both read a non-ok status as "the
server is unreachable", and a first-run user who has set nothing up is not in an outage. `null`
rather than `{}`: an empty object is a payload with every figure missing, and a view handed one
renders zeros.

What `engine` does NOT claim is that the agent ran recently. That needs a THRESHOLD, and the
serialiser holds none by design (`_freshness_payload` refuses the same temptation). The evidence
is already on the wire without arithmetic -- `data_freshness` carries candle ages in the CLI's
own words, and the activity feed carries `last_cycle_before_scope`, which exists precisely so an
empty view can say when keel last ran. A third `engine` word arrives when a report builder holds
a heartbeat WITH its staleness verdict, and it is copied here, not computed here.

REJECTED: a third `engine` word for "the report raised". The client behaviour required by a
stopped engine and by an unbuildable report is identical -- show no figures, say why -- so it
would be three branches to get two behaviours, and the HTTP status plus `error.detail` already
carry the difference.

REJECTED: one envelope with a nullable `error`, told apart by a field. Most refusals happen
BEFORE the session cookie is checked, and filling in `engine` there would mean an
unauthenticated request reading the deployment state off disk. The discriminator is the HTTP
status, which `res.ok` already checks.

SORT. `?sort=<column>&dir=asc|desc`, refused with the column list when the column is not
declared -- silently ignoring one is how a client ships a sort header that does nothing and
nobody notices for a release. Python orders with `Decimal`, in `payload.order_rows`, next to the
`_plain` that wrote the strings being ordered. Measured reason, in the test: two ERC-20
quantities one wei apart (`0.100000000000000001` / `...002`, both legal at an 18-decimal base
increment) map to the SAME double, because adjacent doubles near 0.1 are ~1.4e-17 apart. A
float-keyed sort leaves the tied pair in arrival order; a mutation run confirms it answers
`[0.099, ...002, ...001]` where `Decimal` answers `[0.099, ...001, ...002]`. Absent values sort
last in BOTH directions -- `None` is "not recorded", not zero, and giving one a numeric key
makes it the largest thing in a descending sort, which is the always-passing fee rail (#198) in
a different hat.

REJECTED: `?sort=-expectancy`. One signed token needs a grammar -- what a leading `-` means,
what happens to a column starting with one, how a client strips it before comparing against
`sort.columns`. Two orthogonal facts, two parameters, no parsing beyond a table lookup.

REJECTED: sorting the source dataclasses before serialising. That needs a map from every payload
key back to the report attribute it came from, in a second file, and the map rots the first time
a key is renamed. `Field.value` is documented as exact and `Decimal`-parseable; re-parsing it is
what it is for. The numeric `sort` companion field #533 rejected is still not here.

REJECTED: numeric-or-text decided per row. That puts a `Decimal` and a `str` in one comparison,
which raises. It is decided per COLUMN -- numeric only when every present value parses finite,
so a `Decimal("NaN")` sentinel (which parses, and compares false to everything) drops the whole
column to text ordering rather than making the result depend on the sort's comparison order.

`GET /api/config` serves `version` (#539's `?v=`) and `build` (#538's cache key) as separate
fields off a `BuildInfo` resolved ONCE by `serve_cmd`. `build_info()` shells out to git twice,
and an endpoint a service worker polls must not fork a subprocess to answer; parsing the fields
back out of the footer's `describe()` line would be a display string read as data.

`GET /api/*` does not require `X-Keel-Client`, deliberately. The header buys a CORS preflight a
hostile origin cannot satisfy, closing the plain-form-POST gap that `SameSite=Strict` and the
HMAC token both assume shut -- a GET is not that gap: a cross-origin read cannot see the
response without CORS headers this server never sends, and `SameSite=Strict` denies the cookie
before any of it matters. Requiring it would cost `curl http://127.0.0.1:8765/api/status` and
the address bar, which is how an operator checks the interface is telling the truth. Reverse it
the day a GET can change something.

Refusals under `/api/*` are JSON, path-scoped rather than `Accept`-negotiated: an HTML error
page handed to `res.json()` is a parse error in the client, a worse diagnostic than the 403 it
hides. The gate in front of `POST /api/*` is untouched -- same trigger, same status, same
ordering; only the body's media type follows the path.

Two stale claims corrected while in the area, both checked rather than inherited:
`keel/web/__init__.py` still said "the request handler implements do_GET and do_HEAD and nothing
else", which stopped being true at #437 -- and `server.do_POST` points readers at that file for
the rule it must not break. `server.py`'s docstring said "six read-only pages" over eight routes.

`open_repo`/`load_config`/`deployment_state`/`close_repo` moved from `server.py` to `api.py`
unchanged, with their reasoning: both front-ends read keel through them, and a copy in each file
would be two places deciding whether a view may migrate a live database.

Measured: `setup.inspect` is 3.6 ms per call against a migrated database, vs 0.07 ms for
`gather_status`. It runs once per response on every endpoint, `/api/config` included, because a
uniform `engine` field is what lets one client wrapper handle every route. If something ever
polls `/api/config` in a loop the fix is to cache the probe, not to make the field optional --
an optional field puts the branch back on the client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyeggYtojNXCTHeD3JHxb6
@eaitbrahim
eaitbrahim merged commit fe43a27 into main Aug 24, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the feat-534-json-api branch August 24, 2026 12:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JSON API over the existing reports, with server-side sort and /api/config

1 participant