Skip to content

feat(http): the HTTP surface, derived from the one verb list (Phase 4) - #6

Merged
thorwhalen merged 2 commits into
mainfrom
feat/http-surface
Sep 22, 2026
Merged

thorwhalen merged 2 commits into
mainfrom
feat/http-surface

Conversation

@thorwhalen

Copy link
Copy Markdown
Owner

Closes Phase 4 of #1. qh.mk_app over the same tools._dispatch_funcs the CLI and MCP already dispatch from, plus qh.export_ts_client for the frontend's typed client.

One registry, three emitters

ductus.http.ROUTED_FUNCS is derived from tools._dispatch_funcs, the same way ductus.mcp.TOOL_REFS is. There is nothing to keep in sync, so there is no parity test — test_the_verb_list_is_derived_not_written asserts the derivation itself, not an agreement between two lists. install_skills is absent because it declares @host_mutating at its own definition, not because this module lists it.

The core did not change, as the roadmap predicted for this phase. ductus/base.py, core.py, segment.py, detect.py, score.py are untouched. qh is in a new [http] extra behind a deferred import; import ductus pulls in neither FastAPI nor qh, asserted in a subprocess test.

The Phase 3 defect: checked for first, and qh does not have it

Phase 3 found that under from __future__ import annotations the schema layer beneath fastmcp drops every keyword-only default, leaving correct-looking OpenAPI over uncallable endpoints (i2mint/py2mcp#12). Since this package is keyword-only from the 2nd/3rd argument throughout, that would have made every verb uncallable here too.

Checked by driving a real client before writing anything else. qh is clean: gauge is callable with source alone, the OpenAPI required list is right, and unions survive. tests/test_http.py keeps driving a real client rather than inspecting a schema, and test_every_verb_is_callable_with_only_its_required_arguments is the test that would catch a regression.

What this surface did find

A verb list that is safe at a CLI is not automatically safe when the caller is a stranger.

gauge(source=...) reads a file when the string names one. gauge(out=...) writes one. Both are exactly right when you typed the command yourself — and an arbitrary file read and an arbitrary file write when you did not. judgments= is a third. Neither the CLI nor a local stdio MCP host can see this, because on those surfaces it is not a bug.

Fixed the way host_mutating already works, rather than by inventing a new mechanism. The verb declares the fact about itself:

@host_paths(source="read", judgments="read", out="write")
def gauge(source: str, *, ..., judgments=None, out=None, ...): ...

and the HTTP adapter refuses accordingly by reading that declaration. It knows nothing about gauge, so a new verb with a path parameter is guarded with no edit to ductus/http.py — test_the_guard_is_generic_not_a_list_of_verbs pins that by inventing a verb in the test and checking it is guarded.

read parameters are refused on exactly the condition under which _read_source would open a file (len < 4096 and os.path.isfile), so the branch becomes unreachable rather than guessed at from what a path looks like. A string that looks like a path but names nothing is still scored as ordinary text — tested. mk_app(guard_host_paths=False) is the seam for a loopback service you run for yourself, where reading a local file by name is the convenience it is at a CLI.

Two stale honesty claims, corrected

Found while writing the service description, and worth flagging because both had survived the phase that made them wrong:

  • render.py's footer said detectors "over-flag non-native English". That is true of the field, and the opposite of what this package was measured to do — Phase 2 found the bias runs toward formal, fluent prose, with the native-speaker control the most-accused group. The skills and the MCP instructions were corrected at the time; this string was missed, so every HTML report shipped since has carried a borrowed caution about a bias this package does not have.
  • mcp.py's instructions still quoted "about one document in five" — the pre-fix: cut the false-accusation rate on human writing from 20.6% to 6.0% #5 rate of 20.6%, not the measured 6.0%.

The footer now states the rate as a natural frequency ("about one in sixteen") rather than "6.0%", which keeps test_the_page_states_its_own_limits's no-percentage-in-the-footer guard intact and untouched — that footer sits directly under a verdict about one specific document, which is the one place the rule is load-bearing enough to have its own test. The precise figure stays on the machine-read surfaces (MCP instructions, OpenAPI description).

Both tests that pinned the old wording are updated in place with the reason written into the test, and test_the_page_states_its_own_limits now asserts "non-native" not in html so the corrected claim cannot creep back.

A rejected argument is 422, not 500

qh wraps anything that is not already an HTTPException into a 500, so the guard's refusal and gauge's own format must be one of both reached the caller as "the service is broken". A frontend that believes a 500 shows "something went wrong" in place of the reason. One generic wrapper in the adapter, knowing no verb's name — a status code is an HTTP concern, so the core keeps raising plain ValueError for every surface.

Dependency

[http] pins qh>=0.0.19, the first release whose generated TypeScript client compiles. qh.export_ts_client emitted a file tsc rejects outright — doubled braces, a stray } closing the class early after any zero-parameter endpoint, and every optional parameter emitted as required. Fixed upstream in i2mint/qh#11, which also stopped Optional[str] collapsing to any, and merged/released before this pin.

Testing

python3 -m pytest — 300 passed. uvx ruff format ., uvx ruff check ductus, uvx mypy ductus --ignore-missing-imports all clean.

Beyond the test client, the server was run for real (ductus-http, uvicorn) and driven with curl: gauge returns the expected report, an argument-only call works, and the guard refuses a write with a 422 carrying its reason.

Left for Phase 5

mk_app(ui=...) mounts a built frontend at / when one exists and is a no-op when it does not, so the API works with nothing built and the two are same-origin when something is — which is also what lets a browser test drive it without CORS. Nothing is built yet.

`ductus.http.ROUTED_FUNCS` is derived from `tools._dispatch_funcs` -- the same
list `cw` builds the CLI from and `ductus.mcp` derives `TOOL_REFS` from -- so a
third surface still means no second implementation and no parity test. `qh`
lives in the new `[http]` extra behind a deferred import; `import ductus` pulls
in neither FastAPI nor qh, asserted in a subprocess test. The core did not
change, as the roadmap predicted.

`export_client()` generates the frontend's typed TypeScript client from this
app's own OpenAPI, so a changed Python signature is a TypeScript type error
rather than a runtime surprise. `mk_app(ui=...)` serves a built frontend
same-origin when one exists, and is a no-op when it does not.

Checked for the Phase 3 defect first, since it would have failed identically:
under `from __future__ import annotations` the schema layer beneath `fastmcp`
drops every keyword-only default, leaving correct-looking OpenAPI over
uncallable endpoints. `qh` does NOT have it -- established by driving a real
client, not by reading a schema, and `test_http.py` keeps driving one.

What this surface did find: a verb list that is safe at a CLI is not
automatically safe when the caller is a stranger. `gauge(source=...)` reads a
file when the string names one and `gauge(out=...)` writes one -- correct when
you typed the command yourself, an arbitrary file read and an arbitrary file
write when you did not. Neither the CLI nor a local stdio MCP host can see
that, because on those surfaces it is not a bug.

Fixed the way `host_mutating` already works: the verb declares the fact about
itself with `@host_paths(source="read", judgments="read", out="write")`, and
the HTTP adapter refuses accordingly by reading the declaration -- it knows
nothing about `gauge`, so a new verb with a path parameter is guarded with no
edit here. `read` parameters are refused on exactly the condition under which
`_read_source` would open a file, so the branch is unreachable rather than
guessed at. `mk_app(guard_host_paths=False)` is the seam for a loopback
service you run for yourself.

Also corrects two stale honesty claims that Phase 2 contradicted and that were
missed when the skills were fixed: `render.py`'s footer said detectors
"over-flag non-native English" (this package's measured bias runs the other
way, toward formal fluent prose, with the native-speaker control the
most-accused group), and `mcp.py`'s instructions still quoted the pre-fix
"one document in five" rate rather than the measured 6.0%. The footer states
the rate as a natural frequency so the no-percentage rule keeps its guard,
and both tests that pinned the old wording are updated with the reason.
qh wraps anything that is not already an HTTPException into a 500, so the
guard's refusal and gauge's own 'format must be one of' both reached the
caller as 'the service is broken'. A frontend that believes a 500 shows
'something went wrong' in place of the reason.

One generic wrapper in the HTTP adapter, knowing no verb's name. A status
code is an HTTP concern, so the core keeps raising plain ValueError for every
surface.
@thorwhalen
thorwhalen merged commit 5b4381f into main Sep 22, 2026
12 checks passed
@thorwhalen
thorwhalen deleted the feat/http-surface branch September 22, 2026 10:23
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.

1 participant