feat(http): the HTTP surface, derived from the one verb list (Phase 4) - #6
Merged
Merged
Conversation
`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.
31 of 34 tasks
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.
Closes Phase 4 of #1.
qh.mk_appover the sametools._dispatch_funcsthe CLI and MCP already dispatch from, plusqh.export_ts_clientfor the frontend's typed client.One registry, three emitters
ductus.http.ROUTED_FUNCSis derived fromtools._dispatch_funcs, the same wayductus.mcp.TOOL_REFSis. There is nothing to keep in sync, so there is no parity test —test_the_verb_list_is_derived_not_writtenasserts the derivation itself, not an agreement between two lists.install_skillsis absent because it declares@host_mutatingat 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.pyare untouched.qhis in a new[http]extra behind a deferred import;import ductuspulls in neither FastAPI nor qh, asserted in a subprocess test.The Phase 3 defect: checked for first, and
qhdoes not have itPhase 3 found that under
from __future__ import annotationsthe schema layer beneathfastmcpdrops 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.
qhis clean:gaugeis callable withsourcealone, the OpenAPIrequiredlist is right, and unions survive.tests/test_http.pykeeps driving a real client rather than inspecting a schema, andtest_every_verb_is_callable_with_only_its_required_argumentsis 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_mutatingalready works, rather than by inventing a new mechanism. The verb declares the fact about itself: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 toductus/http.py—test_the_guard_is_generic_not_a_list_of_verbspins that by inventing a verb in the test and checking it is guarded.readparameters are refused on exactly the condition under which_read_sourcewould 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_limitsnow asserts"non-native" not in htmlso the corrected claim cannot creep back.A rejected argument is 422, not 500
qhwraps anything that is not already anHTTPExceptioninto a 500, so the guard's refusal andgauge's ownformat must be one ofboth 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 plainValueErrorfor every surface.Dependency
[http]pinsqh>=0.0.19, the first release whose generated TypeScript client compiles.qh.export_ts_clientemitted a filetscrejects 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 stoppedOptional[str]collapsing toany, and merged/released before this pin.Testing
python3 -m pytest— 300 passed.uvx ruff format .,uvx ruff check ductus,uvx mypy ductus --ignore-missing-importsall clean.Beyond the test client, the server was run for real (
ductus-http, uvicorn) and driven withcurl:gaugereturns 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.