ADR-0031: MCP tool input is validated against its published schema, in middleware, before any handler runs
- Status: accepted
- Date: 2026-09-12
- Deciders: Theurian maintainers
- Requirements: SEC-12, SEC-13, SEC-17, T-12
- Situates against ADR-0003 (the boundary is an adapter concern, not a domain one), ADR-0013 (the control SEC-12 owes before a write-intent tool opens), ADR-0014 (why the SDK is not patched), and ADR-0032 (the surface this control is a precondition for)
This ADR recorded a decision and shipped no code: no schema file, no
middleware, no registration change and no test landed with it, and its own diff
was confined to docs/. The decision is implemented as of Phase B slice B2
(#662,
PR #663), which is why the
status above is accepted. Compliance names what that slice discharged, with
the test that discharges it, and what stays owed and to whom. Nothing above
Compliance is rewritten: the measurements below are dated and anchored to a
commit, and re-writing them to today's tree would delete the evidence the
decision rests on.
Every repository fact below was measured on 2026-09-12 against be977ea7,
which is the commit this document was written at and is reachable from
origin/main. Each is stated with the file and the symbol that carries it, so a
reader can attack the reading rather than the conclusion.
Context
SEC-12 reads: "Validate every MCP tool input against its published JSON Schema
before it reaches application code"
(docs/architecture/requirements-analysis.md, the SEC table). Two records say
plainly that it does not run:
| Record | What it says today |
|---|---|
docs/security/threat-model.md, under Future controls, not shipped |
"SEC-12 — validating every MCP tool input against its published JSON Schema at the boundary — is not implemented; input is checked by domain construction as above, not against the schemas." |
docs/roadmap.md, the Phase 0 requirement table |
the SEC-12 row's What ships column reads nothing and its What is owed column reads the whole control |
What runs instead is domain construction: a tool body builds ProjectId,
ItemId, Sensitivity and their siblings, and each type refuses a value it
cannot represent. That is a real control and it is not this one. It checks the
values a handler reads; it says nothing about the keys a caller sent.
schemas/mcp/ holds eight files, of which exactly one is input-side — and
nothing reads it. Seven are responses or response fragments
(knowledge-search-response, knowledge-status-response,
project-list-response, retrieval-metadata, review-findings-response,
review-search-response, system-capabilities-response). The eighth,
tool-context.schema.json, is a published input contract: it types the
context fields every project-scoped call carries — projectId required,
snapshotId, agentId, taskId — and it already sets
additionalProperties: false.
It is enforced against no real traffic, and the repository says so in its own
words. schemas/README.md's what-verifies-each-schema table gives that row as
"nothing, and nothing should: it describes tool input, so there is no response
to compare", and the one test it names,
tests/unit/test_schemas.py::test_project_id_is_required_on_every_tool_call,
validates two literal documents against the schema rather than validating any
real call.
That row names one test where five hold properties of this file, which slice B2 needs to know because one of the five moves. Four of them are parametrized over the schema tree and name the file in their node id; the fifth loads it by literal path and does not. Collected:
$ uv run --frozen python -m pytest packages/theurian-core/tests/unit/test_schemas.py \
--collect-only -q | grep -i "tool.context"
.../test_schemas.py::test_schema_is_valid_draft_2020_12[tool-context.schema.json]
.../test_schemas.py::test_schema_declares_an_id_and_title[tool-context.schema.json]
.../test_schemas.py::test_object_schemas_reject_unknown_properties[tool-context.schema.json]
.../test_schemas.py::test_every_published_project_id_pattern_admits_exactly_what_projectid_constructs[mcp/tool-context.schema.json-('properties', 'projectId')]
(The node-id prefix is abbreviated to .../; nothing else is edited.) The fifth
is ::test_project_id_is_required_on_every_tool_call, which builds a validator
from _load("mcp/tool-context.schema.json") at test_schemas.py:294 — a
literal, so no node id carries the filename and the collection key cannot see
it. git grep -n "tool-context" -- packages/theurian-core/tests returns exactly
two lines, :294 and :781, which is the other half of the derivation.
Only ::test_object_schemas_reject_unknown_properties[tool-context.schema.json]
moves. It asserts that every top-level object schema sets
additionalProperties: false, and decision 1's composition moves that keyword
off this file. The other four hold shape, $id/title, the projectId pattern
against ProjectId, and the required-projectId property, none of which
decision 1 touches.
The population of references to the file under the ADR's own key is five
hits, not four — and the key carries its frame, because ADR-0032 has since added
hits of its own and a reader running this against HEAD gets a different number
for a reason that has nothing to do with SEC-12:
$ git grep -n "tool-context" be977ea7 -- packages schemas docs tools | cut -d: -f2,3
docs/protocol/mcp-tools.md:50
packages/theurian-core/tests/unit/test_schemas.py:294
packages/theurian-core/tests/unit/test_schemas.py:781
schemas/README.md:98
schemas/mcp/tool-context.schema.json:3
In order: the protocol page's link, the literal-document test, the
projectId-pattern face's parametrization entry, the schemas/README.md row
that records the absence, and the schema's own $id. (The cut drops the
be977ea7: prefix and the matched line; nothing is annotated in place of it.)
No filter is applied and none is needed at this frame — an earlier draft
piped this through grep -v ':docs/adr/0031' and grep -v ':docs/work-logs/',
and both are no-ops here: this ADR did not exist at be977ea7 and the work logs
carry no hit, so the count is 5 with or without them. A reader running the key
against HEAD does need both filters. The conclusion is unchanged and is the
one that matters: no code path under src/ names it.
So SEC-12 costs two things, and the first is smaller than it looks: a
per-tool input schema does not exist yet, and the artifact that does exist
has no seat to be enforced from. Decision 2 is that seat, and schemas/README.md's
"nothing should" sentence is one of the records that stops being true when it
lands.
Why the seat is forced, and both halves are measured
The SDK validates arguments and silently drops the keys it does not know.
mcp==2.1.1 builds a per-tool pydantic model from the handler's signature
(mcp/server/mcpserver/utilities/func_metadata.py, create_model(...,
__base__=ArgModelBase)), and ArgModelBase's configuration is
ConfigDict(arbitrary_types_allowed=True) — no extra="forbid". The generated
wire models are the same shape: mcp_types/_wire_base.py's WireModel sets
only populate_by_name and says in its own docstring that "subclasses set
extra themselves", and extra="forbid" appears zero times anywhere in the
mcp_types package — the whole package rather than one module, because a single
module is a key a reader can attack and the package is the claim that matters:
$ grep -rho 'extra="forbid"' .venv/lib/python3.13/site-packages/mcp_types/ | wc -l
0
Pydantic's default for an unset extra is ignore. So an unknown key
in params.arguments is dropped before any Theurian code sees the request, and
no code Theurian could write inside a handler can notice that it was there.
The registration seam runs after that coercion, so it cannot hold this
alone. mcp/tools.py's _tool is the one registration seam, and it is
pinned twice — by
tests/unit/test_tool_error_type_contract.py::test_every_tool_is_registered_through_the_one_seam,
which reads register's AST, and by
tests/integration/test_mcp_tools.py::test_every_registered_tool_goes_through_the_forwarding_seam,
which asks the built server whether each Tool.fn carries functools.wraps'
__wrapped__. That seam is where a below-surface refusal is converted into a
caller-visible one, and it is the right place for that. It is the wrong place
for this: by the time _forwarding's wrapper runs, the arguments have already
been through the SDK's model, and the dropped keys are gone.
The middleware tier is the one seat that sees raw params. The SDK's
ServerMiddleware protocol (mcp/server/context.py) states its own contract:
it "runs at the top of ServerRunner._on_request / _on_notify after ctx is
built but before any validation, lookup, or handshake", and "the method and
the raw inbound params are ctx.method and ctx.params (no model validation
has happened yet)". CallNext's docstring says the same from the other side:
"ServerMiddleware runs before params validation, so its rewrites change what
the handler is invoked with; an Extension interceptor runs after, so its
rewrites change only what the handler observes on ctx."
Nothing is wired there today. daemon/runner.py's build_server
constructs MCPServer(name=..., title=..., version=..., instructions=...) and
passes no middleware, then hands the server to register. So the seat is empty
rather than occupied by something else, and slice B2 is the change that fills
it.
The seat is a position inside the SDK's own two middlewares, not the outermost
one, and that placement is the SDK's rather than a choice. MCPServer.__init__
appends RequestStateBoundary to a list Server.__init__ has already seeded
with OpenTelemetryMiddleware, and then extends it with whatever the caller
passed, with the comment stating the order: "User middleware runs inside the
SDK's built-ins (OpenTelemetry, then the request-state boundary), outermost-first
in the order given." So Theurian controls the order among its own middlewares
and not its position relative to those two. That is fine for this control — both
built-ins run before params validation, so ctx.params still carries the raw
keys — and it is recorded because it is not something the ADR chose.
Decision
1. Every registered tool has a published input schema, and schemas/mcp/ is where it lives
An input schema is a wire contract, exactly as the response schemas already
are: it is versioned with the tree, it is what
tests/integration/test_wire_contract.py validates real traffic against, and a
change to it is a change to what clients may send. It is not an internal
convenience the server may tighten or loosen between builds.
The filenames are slice B2's to choose; this ADR fixes only that they live
beside the response schemas under schemas/mcp/ and that there is one per
registered tool. Naming the files here would assert artifacts that do not
exist, which is the failure mode ADR-0030 records for a design document that
tries to stay in bijection with a tree.
tool-context.schema.json becomes the first schema this control reads, not a
ninth thing to write. It already types the context every project-scoped call
carries and already forbids additional properties; what it has never had is a
reader. referencing's offline registry (decision 3) is what makes a $ref
resolvable with no network.
There is exactly one composition that works, and it costs an edit to the
referent — measured rather than assumed. The obstacle is that
additionalProperties in Draft 2020-12 considers only the properties in its
own schema object, so a per-tool schema that references the context and then
closes itself rejects the very fields it referenced. Three forms were driven
against jsonschema==4.26.0 with a real registry, each against a valid document
(projectId + two tool fields) and a document carrying one unknown key:
| Form | Referent keeps additionalProperties: false |
Referent's closure moved out |
|---|---|---|
$ref with sibling properties + additionalProperties: false |
rejects the valid document ('body', 'itemId' were unexpected) |
rejects the valid document ('projectId' was unexpected) |
allOf: [{$ref}] + additionalProperties: false |
rejects the valid document | rejects the valid document |
allOf: [{$ref}] + unevaluatedProperties: false |
rejects the valid document | accepts it, and rejects the unknown key |
Only the bottom-right cell is a working input schema, so decision 1 fixes the
shape: each per-tool schema is allOf: [{"$ref": tool-context}] plus its own
properties, closed with unevaluatedProperties: false — the keyword that,
unlike additionalProperties, sees what the allOf branch evaluated.
tool-context.schema.json's own additionalProperties: false therefore moves
to the per-tool closure, and its pin moves with it.
tests/unit/test_schemas.py::test_object_schemas_reject_unknown_properties[tool-context.schema.json]
asserts that every top-level object schema sets the keyword, so B2's edit to the
referent takes it RED — deliberately. The closure is not lost; it relocates to
each tool's unevaluatedProperties, and the pin has to be rewritten to say so
rather than deleted.
The retrieval-metadata precedent does not transfer, and that is why this had
to be measured. knowledge-search-response.schema.json uses $ref in a
property slot — "retrieval": {"$ref": ...} — where the referent's own
closure applies to its own object and nothing composes. Same-level composition
is a different problem, and reading the precedent as if it answered this one is
what an implementer would do without this table.
The alternative stays available: inline the four context properties in each
per-tool schema, keeping additionalProperties: false and duplicating the
context four keys at a time. Its cost is one further published copy of the
projectId pattern per per-tool schema, not one in total —
_PROJECT_ID_FACES (tests/unit/test_schemas.py:779-788) already tracks
five faces and pins each against ProjectId, so the first inlined schema
makes a sixth and the seven tools registered today
(grep -c '@_tool(' packages/theurian-core/src/theurian/mcp/tools.py → 7)
would take it to twelve. The duplication is what $ref exists to avoid, and
at that multiplier it is the fallback rather than the plan.
2. The validation seat is an SDK ServerMiddleware, wired where the server is built
The check runs in a ServerMiddleware registered on the MCPServer that
daemon/runner.py:build_server constructs. It reads ctx.method and
ctx.params, and for a tools/call it resolves the tool name and validates
params.arguments against that tool's published input schema before
call_next(ctx) is awaited.
Two properties follow from the measurements above, and neither is available at any other tier:
- The closure is enforceable. The middleware sees the keys the caller actually sent, because the SDK has not yet built the model that would drop them.
- The check precedes the handler by construction, not by convention. The
SDK's own dispatcher wraps params validation, the handler call and the
pre-init gate inside
call_next(ctx), so a tool cannot opt out of the control by being registered differently — the way it could opt out of a check written into individual tool bodies.
The _tool seam keeps the job it already has. The two controls compose: _tool
holds how a refusal raised below the surface reaches the caller, and the
middleware holds which requests reach a handler at all.
3. No new dependency: jsonschema is already a runtime dependency, and the offline registry pattern already exists
packages/theurian-core/pyproject.toml lists jsonschema==4.26.0 and
referencing==0.37.0 among the core's six runtime dependencies. referencing
is declared there specifically because Theurian imports it for "the no-network
registry that keeps the installed migration schema offline (issue #235)" — the
pyproject comment says so in those words.
Validation reuses that pattern rather than inventing a second one:
infrastructure/filesystem/migration_loader.py already builds a validator over
a locally-resolved registry and already bounds a document before validating it
(MAX_DOCUMENT_NESTING, MAX_DOCUMENT_NODES, MAX_DOCUMENT_RENDERED_CHARS,
recorded on validate_migration_document for issues #291 and #245 — an
unbounded document cost unbounded work inside jsonschema's own message
building). The MCP boundary takes untrusted input from a caller, which is the
same shape, so the bounds travel with the pattern.
This keeps ADR-0014's dependency count unchanged, which matters because the alternative that would have added one is rejected below.
4. Unknown keys refuse, and a refusal is bounded and does not echo unbounded caller input
Every published input schema is closed against unknown keys — with decision 1's
unevaluatedProperties: false, the one keyword the table there measured as
composing with the context $ref — and a request carrying a key the schema does
not name is refused rather than trimmed. The reason is the one the
domain-construction control cannot reach: a key the server silently drops is a
caller believing it asked for something it did not get, and on a write-intent
surface (ADR-0032) that difference is the difference between a proposal a human
reviews and a proposal a human reviews without the constraint the agent
thought it had set.
A refusal message is bounded, and the house pattern is already in the tree.
mcp/tools.py's unregistered-project refusal reports an oversized project_id
by its length rather than echoing it, with the reason written beside it:
"an oversized id is reported by its length rather than echoed, so the error
cannot be turned into an ~1x amplifier of the caller's own bytes (#17)". The
same shape governs a schema refusal: it names the offending key path and the
constraint that rejected it, and it does not reproduce an arbitrary-length
value the caller supplied. The bound and the exact wording are slice B2's; the
rule is this ADR's.
5. Fail-closed by structure: a registered tool with no loaded input schema is refused at dispatch
A tool name the middleware cannot resolve to a loaded schema is refused, and the refusal is the dispatch answer — not a pass-through with a log line. Stated as the property a test must hold: a tool registered without a published input schema is unreachable.
This is the clause that makes the control survive the next contributor. A
control that applies to the tools someone remembered to enumerate is a control
that a future tool leaves by omission, and the failure is silent in exactly the
direction that matters. The same reckoning is why PROCESS_SPAWN_SITES in
tests/unit/test_network_call_sites.py is asserted by equality against the
whole set rather than by membership: it fails on an addition and on a removal.
6. The published schema and the SDK-derived schema must agree, and the agreement is recomputed
The SDK derives its own input schema from each handler's type annotations, and
_tool's use of functools.wraps is what keeps that introspection working —
_tool's docstring records it: "functools.wraps keeps the wrapped signature,
annotations and docstring, which is what the SDK reads to build each tool's
input and output schema".
So two descriptions of the same input exist: the published file and the one the
SDK computes. They must not drift, and the check is a test that recomputes
the agreement from both live sides rather than a reviewer comparing them by eye.
The exact equivalence relation is slice B2's to fix — a derived schema and a
hand-written one differ in incidental ways (title strings, $defs placement)
that a naive equality would trip over, so what is owed is a stated relation
with a positive control proving it can fail, not a ==.
"Tighter" is not the defect; two specific things are. A published schema is
supposed to be tighter than a Python annotation on the value domain — that is
the whole argument in the alternatives table against generating the published
file from the handler signature, and it is why an enum, a pattern, a range
or a format belongs in the file and not in a type hint. What the agreement
must actually forbid:
- The published schema permits what the handler refuses. A request the contract admits and the server rejects is a published lie, and it is the direction that costs a caller a failed call it was told would work.
- Tightening that deletes a documented capability. A published
enumnarrower than the set the handler serves retires a capability by editing a file, with no changelog entry and no deprecation.
So the relation is asymmetric: the published schema may constrain a value domain the annotation does not, and may not admit what the handler refuses.
The closure axis is outside the relation by design, and that is measured.
The SDK sets no additionalProperties on any tool's derived schema. Driven
against the built server:
# over build_server(...)._tool_manager.list_tools(), sorted by name
for tool in sorted(tools, key=lambda t: t.name):
print(f"{tool.name:22}{tool.parameters.get('additionalProperties', '<absent>')}")
knowledge.get <absent>
knowledge.search <absent>
knowledge.status <absent>
project.list <absent>
review.findings <absent>
review.search <absent>
system.capabilities <absent>
tools with no additionalProperties: 7 of 7
A relation that compared closure would therefore be RED on every tool, for ever — the published schema closes and the derived one never does. The closure is enforced by the middleware (decision 2), not by this comparison, and the relation states that exclusion explicitly rather than discovering it on the first run.
What stays a handler-layer refusal, and what becomes a schema refusal.
Shape, key set, enum membership, pattern, range and format move to the schema,
and their refusals arrive as a key path and a constraint (decision 4). Refusals
that depend on state a schema cannot see stay in the handler with their remedies
intact: an unregistered project id, a snapshot that does not resolve, an item
the caller may not read. That split is what stops decision 6 from being read as
"every refusal becomes a schema refusal", which would delete the remedies
mcp/tools.py carries.
Consequences
Positive
- SEC-12 stops being owed and starts running, and the three records that
currently say it does not (
threat-model.md's Future controls, not shipped entry,roadmap.md'snothing/the whole controlrow, andschemas/README.md's "nothing, and nothing should" row fortool-context.schema.json) move in the slice that ships it, not later. - A published input contract stops being decorative.
tool-context.schema.jsonhas typed every project-scoped call's context since it was written and has never been read; the middleware is what makes it a control rather than a description. - The write-intent surface gets its precondition.
docs/roadmap.md's Phase B row already states that "SEC-12 … becomes mandatory the moment a write-intent tool opens". ADR-0032 depends on this decision landing first. - The contract becomes checkable in both directions. A client can read what it may send; a server test can hold that what it accepts is what it published.
- No new dependency, and no fork of the SDK. The core's runtime dependency list stays at six packages.
Negative
- A second description of every tool's input exists, and two descriptions of one thing drift. Decision 6 is the answer, and it is an owed test rather than a structural impossibility — until it lands, the agreement is a convention.
- The middleware tier is SDK surface, and the SDK is pinned rather than
stable.
ServerMiddleware's ordering guarantees aremcp==2.1.1's, the position of user middleware relative to the SDK's own two is fixed byMCPServer.__init__, and the SDK's ownmiddlewareproperty says the chain "may change in a 2.x minor release". A version bump is where those would change. ADR-0014's exact pinning is what makes that a deliberate event rather than a surprise, but the coupling is real and is recorded here rather than discovered at the bump. - The context schema loses a keyword it has always carried. Decision 1's
composition moves the closure off
tool-context.schema.jsonand onto each per-tool schema, which reddens a pin that has held since the file was written. The closure is not weakened — it is enforced once per tool instead of once in the referent — but for the length of slice B2's commit the file that types every call's context does not close itself, and an implementer who moved the keyword and did not rewrite the pin would read a red test as noise. - Refusing unknown keys is a compatibility decision. A client that sends a forward-looking field today gets a refusal instead of silence. That is the intended direction — silence is what this control exists to end — but it is a behaviour change for any caller relying on the current tolerance.
Neutral
- Domain construction is not replaced.
ProjectIdand its siblings keep refusing values they cannot represent; the two controls answer different questions, and one is not a weaker form of the other. - Response schemas are untouched. This ADR adds an input side; it changes
nothing about what
test_wire_contract.pyalready validates on the way out.
What this does not close
- Which tools exist. This is a control over the registered set, whatever it is. ADR-0032 decides what joins it.
- Authorization. SEC-13 project scoping is a separate control at a different point, and a schema-valid request for a project the caller may not read is still a schema-valid request.
- Content safety of the values. A schema constrains shape, not meaning. SEC-11's secret scan and SEC-15's safety triple keep their own seats.
- Rate and cost bounds. A caller who can make the server spend work not bounded by a recorded limit is the T-6 family's, not this one's; #26's concurrency cap is the precedent for how such a bound is recorded.
- The exact input-schema filenames and the exact refusal wording. Slice B2's, deliberately: naming them here would assert artifacts that do not exist.
Alternatives considered
| Alternative | Why rejected |
|---|---|
Validate inside the _tool registration seam |
The seam runs after the SDK has built its argument model, and that model drops unknown keys (ArgModelBase sets no extra="forbid", measured above). A check there can validate the values a handler receives and can never see the keys a caller sent, so the closure — the half that catches a client asking for something this build does not implement — is unenforceable from it. |
A Starlette middleware in daemon/server.py |
It sits below the MCP framing, so it would have to re-parse JSON-RPC envelopes and Streamable-HTTP batching to find params.arguments. That is a second implementation of the SDK's own dispatch, and it would be wrong in a way nothing tests the day the transport changes. The SDK middleware tier is handed the parsed method and params by contract. |
Patch or fork the SDK's request models to extra="forbid" |
Two costs. The dependency is exact-pinned by ADR-0014, so a patched model is a fork to carry across every bump. And it answers the wrong requirement: forbid rejects keys the handler signature does not name, while SEC-12's own text demands validation "against its published JSON Schema" — a stricter and different statement, since a published schema constrains value ranges, enums and formats that a Python annotation does not. |
| Generate the published schema from the handler signature at build time | It removes the drift of decision 6 by removing one of the two descriptions, which sounds strictly better and is not: the published artifact would then be a projection of the implementation rather than a contract the implementation is held to, so every accidental widening of a parameter type would publish itself as an intentional contract change. The contract is supposed to be the thing that does not move by accident. |
| Validate only the write-intent tools, since those are the new surface | The control would then apply to the tools someone remembered, which is the failure decision 5 exists to prevent. It also gets the risk backwards on the read side: knowledge.search and review.search take caller text today and are the surfaces a malformed request reaches first. |
Compliance
This ADR shipped no behaviour, so at the time it was written it had no shipped test to name. Its enforcement at design time is the measurements it cites; its enforcement at implementation time is the tests slice B2 owed and has now delivered. The two halves are kept apart below rather than merged: what B2 landed is named with the test that holds it, and what is still owed keeps the house Still owed heading and names the slice or the issue that will satisfy it.
Measured at be977ea7 on 2026-09-12 — the state this decision was taken
against, reproducible from that sha and deliberately not re-measured, since
slice B2 moved most of them and re-writing them to today's tree would delete the
evidence the decision rests on:
schemas/mcp/holds 8 files (ls schemas/mcp/): seven response or response-fragment schemas and one input-side contract,tool-context.schema.json. No per-tool input schema exists, and the one input schema that does has no reader undersrc/—git grep -n "tool-context" be977ea7 -- packages schemas docs toolsreturns five hits, needing no filter at that frame: a link, two test references, theschemas/README.mdrow that records the absence, and the file's own$id. The key takes the sha because ADR-0032 added four hits of its own after this frame, none of them a reader.- Five tests hold a property of
tool-context.schema.json, not the oneschemas/README.md's row names: four collected node ids overtest_schemas.py's parametrized suites (--collect-only -q | grep -i "tool.context", key in Context), plus::test_project_id_is_required_on_every_tool_call, which loads the path as a literal attest_schemas.py:294and so names it in no node id. Exactly one of the five —::test_object_schemas_reject_unknown_properties[tool-context.schema.json]— moves with decision 1's composition. _PROJECT_ID_FACES(tests/unit/test_schemas.py:779-788) holds 5 publishedprojectIdpattern faces, and 7 tools are registered (grep -c '@_tool(' .../mcp/tools.py), which is the multiplier that prices decision 1's inline alternative at twelve faces rather than six.- The SDK's argument model sets no
extra="forbid":grep -rho 'extra="forbid"' .venv/lib/python3.13/site-packages/mcp_types/ | wc -lanswers 0 over the whole package, andArgModelBase's own config isConfigDict(arbitrary_types_allowed=True)(mcp/server/mcpserver/utilities/func_metadata.py). - No SDK-derived tool schema sets
additionalProperties— measured over the built server, 7 of 7 absent, which is why decision 6's equivalence relation excludes the closure axis. build_serverpasses no middleware (daemon/runner.py, theMCPServer(...)construction), and the seat it would occupy is inside the SDK's own two —OpenTelemetryMiddlewarethenRequestStateBoundary, appended byServer.__init__andMCPServer.__init__before the caller's list is extended in.- Only one
$refcomposition admits a valid document and rejects an unknown key, and it requires the referent's own closure to move:allOf: [{"$ref": tool-context}]withunevaluatedProperties: false. The table in decision 1 records all three forms driven againstjsonschema==4.26.0, both with the referent closed and with it open. - The core's runtime dependency list is 6 packages and already includes
jsonschema==4.26.0andreferencing==0.37.0(packages/theurian-core/pyproject.toml).
Landed in Phase B slice B2 (PR #663), each item with the test that discharges it:
- One published input schema per registered tool, and the population is
derived rather than listed.
tests/integration/test_input_validation_dispatch.py::test_every_registered_tool_resolves_to_a_published_input_schemawalks the built server's tool manager and asserts set equality in both directions — a registered tool with no loaded schema fails, and so does a schema published for a tool this build no longer registers, which would be a wire contract naming a call that answers "no such tool" (PROCESS_SPAWN_SITES' rule).::test_every_published_schema_names_the_file_it_came_fromholds the origin map beside it, so a failure says which file. - The fail-closed dispatch refusal (decision 5).
::test_a_tool_with_no_published_schema_is_refused_at_dispatchregisters a tool through_toolwith no published schema on a real built server and drives a realtools/callat it. The assertion is that the handler did not run — a sentinel list the body appends to stays empty — because a refusal assertion alone passes against a build that refuses after dispatching, and a guard no data reaches survives its own deletion.::test_an_ordinary_tool_is_still_served_on_that_same_serveris the positive control the decision asked for, and::test_the_unpublished_tool_name_is_one_no_build_artifact_claimspins the premise that the name is one nothing else registers or publishes. - Each per-tool schema's
unevaluatedProperties: falseis enforced on the wire, and against the SDK's own drop.tests/integration/test_input_validation_wire.py::test_an_unknown_key_is_refused_and_the_refusal_names_the_key_not_the_valuedrives a realtools/call— initialize, the session id,notifications/initialized— becauseserver.call_toolis the SDK's tool dispatcher and never reaches this tier.::test_the_same_call_is_served_when_the_middleware_is_lifted_offis the second half this ADR required, the one that makes the seat load-bearing rather than stylistic: with this one middleware removed, the same call carrying an unknown key is served atisError: falseand answers byte-identically with the key and without it.::test_a_valid_call_is_served_unchanged_through_the_middlewareis the served control, and::test_a_refusal_carries_the_shape_a_handler_refusal_carriesholds that a caller cannot tell which tier refused it from the shape of the answer. - Refusal messages are bounded and do not echo unbounded caller input
(decision 4).
tests/unit/test_input_validation.pydrives a ten-thousand-character unknown key and a ten-thousand-character tool name to short messages;::test_a_refused_value_is_never_echoed_backand the two control-character arms hold that no caller-written value reaches a message raw; and::test_a_refusal_past_the_ceiling_cannot_be_builtwith::test_every_refusal_template_fits_the_ceilingrecompute the ceiling from the live templates and the fragment cap, so a builder that grows a new unbounded fragment raises at construction instead of shipping an amplifier. Over the wire,test_input_validation_dispatch.py::test_the_widest_body_the_schema_tier_can_see_is_refused_without_echoing_it— renamed from…the_widest_body_this_transport_admits…in Amendment 1's change, because the transport now admits bodies wider than the schema tier is ever handed. - The published schema and the SDK-derived schema agree (decision 6).
tests/integration/test_input_schema_agreement.pystates the relation in its module docstring, with the exclusions this ADR required it to name: the closure axis — recomputed by::test_no_derived_schema_carries_a_closure_keywordrather than cited from the measurement above — value-domain tightening, andsnapshotId/agentId/taskId, which the shared context publishes and no handler reads.::test_no_published_type_admits_what_the_handler_refuses,::test_the_published_and_derived_schemas_agree_about_what_is_requiredand::test_the_published_schema_names_no_key_the_handler_has_no_parameter_forare the direction the decision said must be caught, and both positive controls were driven: droppingitemIdfromknowledge-get-input.schema.json'srequiredreddens the requiredness arm in this ADR's own words, and givingproject_listan unpublished parameter reddens the other direction.::test_the_parametrization_covers_the_tools_the_fixture_servespins the premise that the parametrization covers the registered set. - Every published input-side
maxLengthis the live constant it transcribes. Not an item this ADR foresaw, and a member of decision 6's class rather than a new one: a hand-transcribed bound drifts from the module it was copied from, in whichever direction nothing checks — above the constant it publishes a value the handler refuses, below it it refuses at the wire what the handler would have served.tests/unit/test_input_schema_bounds.pymaps each bound to the constant and to the code that enforces it, because two bounds can be equal by coincidence, and asserts the population by equality so a new unpinned bound fails. - The bounds on an untrusted document are applied at this boundary too.
tests/unit/test_input_validation.pydrives each ofMAX_PARAMS_NESTING,MAX_PARAMS_NODESandMAX_PARAMS_RENDERED_CHARSagainst a synthetic schema set, with::test_the_nesting_fixture_reaches_the_depth_it_claimspinning the fixture's own arithmetic, andtest_input_validation_dispatch.pydrives the nesting and node bounds through a real inboundtools/callwith an at-the-bound positive control beside each. The rendered-character bound is the one that cannot be driven over this transport, which is recorded below rather than quietly skipped. tool-context.schema.jsongained a reader, and the record that said it should not have one moved with it.mcp/validation.py's loader reads it through every per-tool schema's$ref, and the middleware applies it on every project-scoped call.schemas/README.md's row now names that reader, names the five-test population by the collection command rather than by a list, and says which one of the five moved.- The closure pin moved with the closure, and was rewritten rather than
deleted.
tests/unit/test_schemas.py::test_object_schemas_reject_unknown_propertiesnow holds one claim in three arms —additionalProperties: falseon a response schema,unevaluatedProperties: falseon an input schema, andtool-context.schema.jsonas the tree's one recorded exception, whose arm asserts that the delegation is real: that referrers exist at all, and that every one of them carries the keyword. The vacuity control was driven by moving the referent's$idso nothing referenced it. - The records that said SEC-12 does not run were rewritten in the commit that
made them false —
docs/security/threat-model.md's T-11 entry,docs/roadmap.md's SEC-12 requirement row and its Phase B rows, andschemas/README.md's row. Two more than this ADR listed, both found while writing them. T-11's Controls paragraph itself asserted thatprojectId"is not validated by a JSON schema at the MCP boundary — there is no such validation,jsonschemais imported only by the migration loader", which the middleware falsifies on both clauses. Anddocs/protocol/mcp-tools.md— the wire contract a client author reads — described no input validation at all; it now publishes the per-tool schema table, the refusal semantics and the three caller-observable behaviour changes. Whether any of these rewrites is faithful is a reading and no mechanical check reaches it, which is said here, as it was before, rather than left to be inferred. - The
protocolVersiontreatment of the three caller-observable refusals is settled and recorded.docs/protocol/mcp-tools.md's Changing this contract section, as of76e1628con the branch of #663, grants the three the sixth, seventh and eighth breaking-but-unbumped exemptions — taking that series from five to eight — andprotocolVersionstaystheurian/v1. Two legs are shared by all three: a consumer census re-measured for this change and pasted as the commands that produce it rather than summarised — the population is every place outside Core anddocs/that builds atools/call, and it holds one construction site, a test helper, whose eight call sites are partitioned and listed over the four key sets this contract defines; no plugin script builds an MCP call at all — and the pre-1.0 versioning policy the Core changelog states in its own header, deliberately not the "no known external integration to break" leg the fourth and fifth exemptions rest on, since Core is published on PyPI and nobody here can say what is installed against it. Each refusal then carries a ground of its own and a scope line that stops it widening to the next: unknown keys were never published as accepted,project.listandsystem.capabilitiesnever took an argument in this document or in any schema underschemas/mcp/, andquery's 2,000-character bound was already published in two places, so what moved there is the disposition of an over-bound query from clamp to refuse.
Still owed, with the milestone that will satisfy it:
- The per-tool schemas carry the value-domain constraints
ADR-0032 decision 3's table
assigns them — an explicit
maxLengthonbody, the wire equivalent of theMAX_SOURCE_FILE_BYTEScap that_read_bodyapplies to a body file and that nothing applies on a path with no file, anduniqueItemsonlabels[]. B2 published one schema for each of the seven tools this build registers (grep -c '@_tool(' packages/theurian-core/src/theurian/mcp/tools.py→ 7,ls schemas/mcp/*-input.schema.json | wc -l→ 7, 2026-09-13), and not one of them takes abodyor alabels[]: those fields arrive with the write-intent tools, so the constraints and their driving cases are both owed at slice B4. What B2 owed here and delivered is the mechanism that will carry them, and the sweep that refuses a write-intent tool registered without a published schema at all. - The two caps are reconciled; what stays owed under this heading is narrower
than the reconciliation was —
#669, landed. This bullet
used to read "
MAX_PARAMS_RENDERED_CHARSis unreachable over this transport, and the two caps are unreconciled"; both clauses are false now, and Amendment 1 below records what landed, what measuring it revealed, and what it left open. In short:build_apppasses a derivedmax_request_body_sizeinstead of taking the SDK's default, andMAX_PARAMS_RENDERED_CHARSis held by_rendered_width's charge rather than by the two caps' ordering, so it is reachable and enforced at any transport cap. Still owed at slice B4, both narrower: the unit a publishedmaxLengthon a write-intentbodycounts — JSON Schema counts code points, while the transport cap's derivation is in landed bytes — and a pin that recomputes that cap's+ 1 MiBenvelope allowance from the sibling fields' own published bounds, which do not exist yet (#691). Two wire-escape classes still meet the bare413at a landed size the store would accept; that is recorded as an accepted residual onMAX_REQUEST_BODY_BYTESand in the threat model's SEC-12 entry, not owed to a milestone. snapshotId,agentIdandtaskIdare published, now enforced, and read by nothing — #665, Phase B. They are decision 6's third exclusion, and the exclusion is held equal to the published-but-underived population bytest_input_schema_agreement.py::test_the_excluded_context_keys_are_still_the_unread_three, so a fourth such key cannot join it by being excluded and the file goes RED whichever way #665 decides. A recorded deferral, not an acceptance._meta.serverInfois absent from a refusal this tier answers.ServerRunner._serializestamps it on a modern-era result from server state no middleware is handed, so a refusal from this seat reaches a2026-07-28client without it. Recorded onmcp/middleware.py's module docstring rather than discovered later; nothing about the refusal changes, and a client learns the server's identity from the handshake. Owed a fix only if a client is found that reads it.
Amendment 1 — the transport body cap is chosen and recorded, and the render budget no longer rests on it (2026-09-14, #669, PR #685)
This is an append-only amendment. The decisions above are unchanged. What it corrects is a Compliance item: the second owed bullet, which said
MAX_PARAMS_RENDERED_CHARSis unreachable over this transport and the two caps are unreconciled. Both clauses are false as of this change; the bullet now points here, and the test cite it carried named a test this change deleted.Where the figures below come from, because they no longer share one anchor and an earlier draft of this block said they did:
- Measured 2026-09-14 at
172e9d28, a branch commit — the cap derivation, the wire-escape table, the413refusal's own size.- Measured 2026-09-15 at
5f96c781, a branch commit — the per-request memory table, the per-node punctuation cost, the fallback's transient and its timings. These are round two's re-measurements; the addendum at the end of this amendment records what they replaced and why.- Measured 2026-09-15 at
097d754c, a branch commit — the transport and parse terms, taken on the call this request path actually parses with. No row of the table moved; only the model that has to explain it.- Measured 2026-09-15 at
286b3d2a, a branch commit — round three's:jsonschema's message-construction term and the family-(ii) worst instances, the maximum-over-moments composition, and the chunked transient's second factor. These replaced a model that was missing a term and a ceiling that was missing a factor; the second addendum at the end says what each was.- Derived from the live constants rather than measured, and said so where it happens: the composed render ceiling, and the worst render the pre-#669 charge admitted at a given cap.
Every commit named above is a branch commit of this pull request and not on
mainuntil it lands — which is why each anchor carries the pull-request qualifier rather than reading as a tree a reader can check out. One figure is older than either, and the sentence quoting it says so: the 53,476,811-character reproduction was taken against the interim2 *cap this amendment withdrew, not against the cap it ships. CPython 3.13,mcp==2.1.1,jsonschema==4.26.0.
What the record said, and what is true now. build_app called
streamable_http_app without max_request_body_size, so the SDK's
DEFAULT_MAX_REQUEST_BODY_SIZE — 4 MiB, mcp/server/transport_security.py —
was the first bound an inbound body met: a number this project never chose and
never recorded. It now passes daemon/server.py's MAX_REQUEST_BODY_BYTES,
26,214,400 bytes, derived as 3 * MAX_SOURCE_FILE_BYTES + 1 MiB from the
byte cap ADR-0032 decision 3 puts on the file a proposal lands. That sits above
MAX_PARAMS_RENDERED_CHARS (12 MiB), so a body between the two arrives, is
framed, and meets this seam's bounded refusal — which names the tool and the
limit it passed — instead of a bare 413 Request body too large (22 bytes,
measured) from a tier that exists before any MCP framing does. The formula is
recomputed from both live constants by
test_input_validation_dispatch.py::test_the_transport_body_cap_is_derived_from_the_cap_on_a_landed_file,
the ordering by ::test_the_transport_body_cap_sits_above_the_rendered_character_bound,
and the tier boundary is driven at the exact byte on both sides by
::test_a_body_of_exactly_the_transport_cap_still_reaches_mcp_framing and
::test_a_body_one_byte_past_the_transport_cap_is_refused_without_echoing_it.
The multiplier is an enumeration, and that is what makes it an authority.
The first draft of this change sized the cap at 2 *, from a sample of
encodings judged realistic — quote, backslash, newline, tab, CJK — every one of
which measures 2.0x and none of which is the worst case. The ratio is a property
of a character's UTF-8 length, not of how ordinary its script is: under
ensure_ascii, every 2-byte character (Latin supplements, Greek, Cyrillic,
Hebrew, Arabic) costs a 6-byte \uXXXX over 2 landed bytes, 3.0x, and a
Cyrillic body landing under MAX_SOURCE_FILE_BYTES met the bare 413 inside
the change that was closing this issue. The constant now records one row per
UTF-8 byte length, under both encoder families — ensure_ascii=True, the
stdlib default, and raw UTF-8, which the official Python and JS clients emit —
and tests/unit/test_transport_body_cap.py is what makes that table
falsifiable: ::test_the_multiplier_is_the_worst_wire_ratio_any_non_control_class_reaches
recovers the multiplier from the live constant by arithmetic and compares it to
the measured maximum, and
::test_the_classes_that_exceed_the_multiplier_are_exactly_the_two_the_residual_names
holds the residual set by equality, so a third class rising past the multiplier
fails too. Over the wire the acceptance is parametrized per script class —
test_input_validation_dispatch.py::test_a_write_intent_sized_body_arrives_and_is_refused_by_its_schema
posts a body landing at MAX_SOURCE_FILE_BYTES in ASCII, 2-byte, 3-byte and
astral text, ensure_ascii-escaped, and asserts each is answered by its schema
rather than by a 413.
The render budget is held by the charge, not by the caps' ordering — and the
premise that said otherwise is withdrawn. MAX_PARAMS_RENDERED_CHARS was
written under "a request's rendered width never exceeds the bytes the caller
sent", and _rendered_width charged a string len(value) accordingly, while
jsonschema renders a failing instance with {instance!r} and repr escapes.
The premise is false in both directions and was false before this change: raw
U+007F is one wire byte and four rendered characters, and JSON admits it
raw, so even under the SDK's 4 MiB default the worst reachable render was
16,777,216 characters — 1.33x the 12 MiB budget. Raising the cap widened that
breach rather than creating it, and the worst instance at this cap is any
raw non-printable 2-byte BMP character, U+0600 among them: two wire bytes, a
six-character \uXXXX render, so under the old code-point charge the gate
admitted 12,582,912 of them — 25,165,824 wire bytes, inside this cap with the
+ 1 MiB envelope allowance to spare, sent raw as the official clients send it
— and jsonschema would render 75,497,472 characters, 6.00x the budget,
which is 4.50x the worst the SDK's own default could reach. That is the
worst over every escape class and not over one body someone tried, and it is the
2-byte class because that class alone saturates the charge without meeting the
transport first: raw U+007F is charge-bound too but renders only four characters
per code point (4.00x), and a non-printable astral character renders ten but is
transport-bound at 6,553,600 of them (5.21x). Derived from the two constants and
swept on CPython 3.13 over the 1,112,064 code points UTF-8 can carry — every one
but the 2,048 surrogates, which no encoder will put on the wire — rather than
argued from the three named here.
The reproduction behind those figures was run on the same character class at the
interim 2 * cap (17,825,792 bytes) this amendment withdrew, where the
transport bound first instead of the charge: a body of U+0600 passed the gate
charged about 8.9 M and made jsonschema build a measured
53,476,811-character message, 4.25x the budget — that pair belongs to
the interim cap, and the shipped cap's pair is the one above. An earlier draft
of this paragraph printed the interim figures under the shipped cap's name.
Either way the refusal eventually produced stayed bounded at 341 bytes and
echoed nothing — the transient was the cost, not the answer.
_rendered_width now charges every leaf at least the number of characters
that leaf contributes to the render: a string through an isprintable() fast
path that allocates nothing, through len(repr(value)) - 2 up to
_CHUNK_CODE_POINTS and a chunked upper bound above it; an integer by its digit
count; and every remaining leaf type — float and None, which an earlier
draft charged zero, among them — by its whole repr. The escape classes under
that charge are verified exhaustively over all 1,114,112 code points rather than
argued. So the budget is enforced by the charge at any transport cap, and
the ordering above now decides only which refusal a caller between the two
bounds receives. Pinned per escape class by
tests/unit/test_rendered_width_charge.py and driven over the wire by
test_input_validation_dispatch.py::test_escape_heavy_text_smaller_than_the_render_budget_still_exceeds_it,
whose body is smaller in bytes than the render budget itself — so no ordering of
the two caps could have refused it.
What that budget bounds is the charged leaves, not the whole render. An
instance is its leaves plus the punctuation holding them together — braces,
brackets, ,, :, the quotes around each string — which no leaf is charged
for, because _iter_nodes descends into containers and charges their members
instead, and a container is the one deliberate zero. That excess is a fixed cost
per node, measured at no more than 4 characters per node over both the
structured worst cases and 300,000 random shapes, so the render a request can
actually reach is MAX_PARAMS_RENDERED_CHARS + MAX_PARAMS_NODES * 4 =
12,982,912 characters, 1.032x the constant alone. Quote the composed figure
wherever the real ceiling matters; the constant alone under-states it.
The withdrawn sentence survives in the tree only where something refutes it:
the two constant docstrings
(daemon/server.py's MAX_REQUEST_BODY_BYTES and mcp/validation.py's
MAX_PARAMS_RENDERED_CHARS), the two test modules that drive its falseness, and
this amendment. No record states it as fact.
Three caller-visible behaviour changes, each named here because a client
author reads the refusal, not the constant. A body up to 26,214,400 bytes is now
read where 4 MiB was the limit, so the write-intent body ADR-0032 sizes for
arrives and is answered by its schema — naming the tool and the constraint —
where a 2-byte-script body of the same landed size previously met a 413 that
named nothing. An escape-heavy body that previously reached a published
maxLength refusal can now meet the rendered-character refusal first: the same
isError, a different limit and a different message. And arguments whose whole
render passes the composed ceiling are now refused at this seam where they were
admitted: with float and None charged nothing, a request pairing a string
at the budget with 99,995 full-precision floats rendered 15,182,796 characters
and was let through. It now receives the seam's bounded refusal.
The residual is recorded, not closed, and it is two rows of the wire table.
Only the control classes exceed 3.0x, both at 6.0x: C0 characters other than
\b \t \n \f \r, which have no remedy because raw C0 is illegal
JSON, and DEL (U+007F), whose remedy is to send it raw rather than
ensure_ascii-escaped (1.0x). Text dense in either still meets the bare 413
at a landed size the store would accept. Accepted rather than closed: that
density is not what a knowledge store lands, and some residual is inherent to
any finite byte bound at a tier with no MCP framing to refuse through. Astral
characters are not in that set — they expand at 3.0x and the cap covers
them.
What the bound costs per request, and what still bounds nothing. The SDK buffers a whole body before anything parses it, so a multiple of the wire bytes is live in Python heap while one at-cap request is in flight — and that multiple is neither a single multiple of the wire bytes nor a function of the body alone. Up to three terms are live, and which of them exist depends on the path the request takes rather than on how big it is:
- The transport's buffers — 2x the wire bytes, whatever the body holds.
RequestBodyLimitMiddlewareaccumulates the body into abytearrayand makes thebytescopy itself; Starlette'srequest.body()joins that single chunk and hands back the very same object rather than copying again. - The parse's own peak — 1x, 3x or 5x, set by the widest code point, over
single-large-leaf bodies. The SDK parses with
pydantic_core.from_json(body)(streamable_http.py), straight from the bytes, and PEP 393 sizes the resultingstrby its widest member — 1, 2 or 4 bytes per code point — so the finished string alone is 1x, 2x or 4x, and above the 1-byte kind the parse holds one further wire-byte-sized buffer while it widens. There is one string, not two: nothing downstream copies it, andjsonrpc_message_adapter.validate_pythonpeaks at 0.0 MiB and hands back the very same object, checked by identity. Those multiples are measured over single-large-leaf bodies, the shape this cap is sized for; a body of many small values instead pays CPython's per-object overhead, which the ratio does not include — ~46 bytes a value, taking 98,900 distinct short strings to 4.87x their wire bytes where one leaf of the same size parses at 1.00x. That excess is bounded absolutely rather than by the body:MAX_PARAMS_NODEScaps it at a few MiB. jsonschema's message construction — on refusal paths only. Most keywords build their message with{instance!r}— including every constraint the published schemas apply to a string field that a large value can fail (maxLength,minLength,pattern), andtypeandenumbesides. Measured onjsonschema==4.26.0,required,constandmaximumname no instance value at all, and the two in_KEYWORDS_THAT_NAME_KEYSname only a key; an earlier draft of this bullet said every keyword but those two rendered the instance, which over-states the set. A request that passes the charge gate and then fails a rendering keyword makesiter_errorsbuild the instance a second time, bounded by2 × MAX_PARAMS_RENDERED_CHARS × 4bytes, ~96 MiB isolated, and uncorrelated with the size of the request that triggers it. That string sits at the width of thereproutput, which is not always the parsed string's:represcapes every non-printable code point to ASCII, so only a code point that survives it raw can widen the result. The render strings take the parsed kind when the widest code point is printable, and 1 byte per character when every wide one is escaped — the rule is exact, the output's kind being the kind of the widest printable code point, checked over 200,000 mixed strings with no exception.
The peak is a maximum over moments, not a sum, because the parse's transient
buffers are freed before jsonschema renders anything, so the two never stand
together:
peak = max(2*wire + parse_peak, # the parse moment
2*wire + code_points*kind + 2*rendered*kind) # the render moment
where kind is the parsed string's bytes per code point. Read it as an upper
bound, not as a prediction. It is tight — within +0.12x, and always above,
by the request's fixed overhead — for the five single-large-leaf shapes
test_request_memory_model.py pins, whose widest code point is printable or
1-byte. Outside that set it over-predicts, because the render moment's kind is
the parsed string's while the strings it prices sit at the repr output's: a
body of non-printable astral characters measures 8.11x where the expression says
23.00x, and one of non-printable U+0600 measures 9.11x against 15.00x. A DEL-only
body is unaffected, its parsed kind already being 1. Swept over the code point
space the expression never under-predicts — worst over-prediction −14.88x —
which is why it is recorded as the bound this daemon can be held to rather than
as a figure to expect.
Three path families follow, and they are the honest unit of this record.
(i) Charge-refused shapes meet _unbounded before iter_errors is ever
called, so only the parse moment exists. One authenticated at-cap POST per row,
one fresh process each:
| Body at 26,214,400 wire bytes | tracemalloc peak |
vs wire bytes | ru_maxrss |
|---|---|---|---|
| all ASCII | 75.1 MiB | 3.00x | ~50 MiB |
| dense U+007F | 75.2 MiB | 3.01x | ~50 MiB |
| U+007F + one 2-byte character | 125.1 MiB | 5.00x | ~25 MiB |
| U+007F + one astral character | 175.1 MiB | 7.00x | ~75 MiB |
The parse moment reproduces every one of those rows: 2x + 1x = 3.00x for both 1-byte-kind bodies, 2x + 3x = 5.00x with a 2-byte character, 2x + 5x = 7.00x with an astral one, the dense-U+007F row's extra 0.01x being the charge's own chunked transient. Holding on all four is what makes it a model rather than an arithmetic that fits one row.
(ii) jsonschema-answered shapes carry both moments, and whichever is larger
wins. A body of printable multi-byte text is charged one character per code
point, so it passes the gate the family-(i) rows meet and reaches iter_errors.
Round-3 measurements, tracemalloc, one authenticated POST each:
| Worst by | Peak | The instance that reaches it |
|---|---|---|
| ratio | 114.1 MiB = 38.04x the wire bytes | "\x7f" * 3,145,717 plus one U+1F600 — only 3,145,848 wire bytes, charged 12,582,869, just under the budget and therefore admitted. Its render moment is 38.00x against a 7.00x parse moment, so the render decides it |
| absolute | ~194–200 MiB, up to 8.00x | CJK filler plus ASCII plus one astral character, at the cap, saturating the wire bound and the render budget at once |
(iii) Valid shapes render nothing, so family (i)'s composition applies with no second moment.
The moments are not added. Presenting the three terms additively over-states
a printable 2-byte body by 1.88x the wire bytes — 7.00x predicted against 5.12x
measured — by charging it for buffers already freed; and the max is
load-bearing rather than a formality, since printable CJK peaks at its parse
moment, 5.00x against a 4.00x render moment. Different shapes' peaks are decided
by different terms, and neither term alone is the model.
The worst of family (i) is 7.00x, and 3.00x is the ASCII row — an earlier
draft of this paragraph recorded the ASCII row as though it bounded every body,
and a later one recorded family (i)'s 7.00x the same way. Both family-(ii) rows
exceed 175.1 MiB, and the ratio-worst exceeds every ratio in that table
five-fold at an eighth of the size. The two columns are named because they
answer different questions and disagree by
design: tracemalloc is the Python heap and reproduces to the tenth of a MiB
across runs, while ru_maxrss is a process high-water mark that moves with
whatever the process already touched, which is why it is quoted only to the MiB
and why it is not monotone in the row order — the 2-byte row reads lower
than the ASCII row there while tracemalloc reads half again higher. Read the
tracemalloc column for what a request costs; ru_maxrss answers what the
process peaked at, which is not the same question.
Two sentences earlier drafts of this paragraph carried are withdrawn, and
family (ii) is the counterexample to each: "the multiple is set by the body's
widest code point, not by its length", and "all four rows are those two terms
and nothing else". The charge's own fallback is a separate cost again: it once
reprred a whole leaf, peaking at 100 MiB (dense U+007F) to 400 MiB (the same leaf
with one emoji), tracemalloc, and _chunked_width bounded it to
_CHUNK_CODE_POINTS * (10 + 1) * 4 — ~352 KiB, two terms because the same
expression builds the repr output and the concatenated slice it reprs, measured
across three readings as 360,548 to 361,156 bytes, worst over a leaf of
non-printable astral characters carrying one printable astral. An earlier record
priced only the repr output and called it 320 KB.
The derivation also couples this daemon's per-request memory ceiling to a
filesystem constant — raising MAX_SOURCE_FILE_BYTES for a reason about files
raises every row above by three times as much, and then by up to four times that
again for a body carrying one astral character — which is recorded on
MAX_REQUEST_BODY_BYTES because nothing at the security/paths.py end says so.
The number of concurrent arrivals is bounded
nowhere in-process; that is T-6's recorded deferral, and the figures are on
#26.
Two homes for what this hands on. The unit question and the envelope pin
both fall to slice B4 (#691).
A maxLength transcribed from an 8 MiB byte cap admits up to four times the bytes
it names, because JSON Schema counts code points, and the + 1 MiB addend is an
allowance inside a hard total rather than a derived figure — nothing could
derive it until description, evidence.*, sourceAnchors[], labels[] and
scopePaths[] carry published bounds. Separately, the same under-charge exists
in infrastructure/filesystem/migration_loader.py's own _rendered_width,
whose docstring states a lower bound as intended behaviour
(#693) — box-split so this
class is closed across both seats rather than at the one this change touched.
Finally, the stale test cite corrected above was found by review rather than by
the suite: nothing checks that a test name an ADR cites still exists
(#692).
Addendum, 2026-09-15 — round two: three figures were measurements over a favourable instance, written down as bounds. Appended rather than folded into the text above, because the class is worth more than the corrected numbers. Each face was a real measurement, recorded without the instance it was taken over, so each read as universal and each was smaller than the truth. The faces, as PR #685's round two found them:
- Per-request memory, recorded as "roughly 3.0x the wire bytes, 75.1 MiB".
That is the all-ASCII row. PEP 393 sizes a
strby its widest member, so one astral character anywhere in the body takes the same request to 7.00x, 175.1 MiB — the worst of the four rows measured at this cap, and the row the table above bolds. - The render charge, recorded as charging "every leaf what
reprrenders it as".floatandNonefell through to areturn 0, so a request pairing a string at the budget with 99,995 full-precision floats was charged 12,582,906 and admitted while rendering 15,182,796 characters, 1.207x the budget. Fixed in code rather than reworded, and with it the whole-render ceiling became statable: 12,982,912 characters, not the 12,582,912 the constant alone names. - The pre-fix reach, recorded as "53,476,811 characters, 4.25x the budget,
widening the breach 3.2x". Measured against the interim
2 *cap and printed under the shipped3 *cap's name. At the cap this amendment ships the worst is 75,497,472 characters, 6.00x the budget, 4.50x the SDK default's worst — the same character class, at the larger size the shipped cap admits. The reproduction was right; the cap it was filed under was not.
Every figure in this amendment now names the instance it is worst over and the
unit it is counted in, and the measurement block at the top says which anchor
each was taken at. That is now enforced rather than merely intended, which
it was not when this addendum was first written: this amendment's prose was read
by nothing, and the paragraph here said so. It is wired now.
tests/integration/test_sec12_record_figures.py recomputes the composed ceiling
from MAX_PARAMS_RENDERED_CHARS and MAX_PARAMS_NODES, recomputes the worst
render the old charge admitted, and drives _rendered_width over every leaf
type the records call every leaf — so a figure that drifts from the build goes
RED against the build rather than against a second copy of itself.
tests/integration/test_sec12_retired_claims.py holds the other direction:
each retired figure above — the ASCII-only memory row, the universal charge
claim, and the interim cap's three — may appear only with the attribution that
marks it retired, so none of them can quietly come back as a live one. A revert
of these corrections is no longer silent.
Addendum, 2026-09-15 — round three: the same class, one surface further in. Round two's class was a measurement over a favourable instance, written down as a bound. Round three found it three more times, and the worst face was produced by the stage that was closing it:
- The memory model was missing the term
jsonschemaitself spends. The render budget is character-denominated, so a body of printable multi-byte text is charged one character per code point, passes the charge gate, and reachesiter_errors— which renders the instance a second time at PEP 393 width. A legal authenticated request of 3,145,848 wire bytes peaks at 114.1 MiB, 38.04x, where the model then recorded predicted 7.00x. The model above is now three terms composed as a maximum over moments, with family (ii) and its two worst instances written out. The term had in fact been measured while the two-term model was being written, recorded as an aside, and the pin steered onto a fixture where the two-term model held — which is the class's own mechanism, executed by the stage closing it. - The chunked transient's ceiling was one term of two.
_CHUNK_CODE_POINTS * 10 * 4priced the repr output and omitted the concatenated slice the same expression builds. The real ceiling is(10 + 1) * 4per code point, ~352 KiB, measured 360,548–361,156 bytes. - The retired charge universal came back in a new dress — in the CHANGELOG's
own
Fixedheading, and in the docstring of the module whose job is refusing it. A verbatim key cannot catch a reworded sentence, sotest_sec12_retired_claims.pynow carries a subject-plus-phrasing key that does, and runs it over the two production docstrings as well as these records.
What terminates the class is structural, not another round of faces. No cost or memory universal survives in a #669 record unless it is recomputed from live constants by a pin, driven at a named worst instance by a pin, or rewritten as an enumeration of measured rows with their instruments and instances named. The three paragraphs above are the third form; the falsified universals are kept as attributed retired claims rather than deleted, so a reader meets the correction instead of a gap.