Decision: advertise == enforce. The key allowlist (OAI-02) stays as the first gate with its established unknown-key message; a compiled leaf validator now runs second, so required/type/enum/pattern/bounds violations surface as INVALID_INPUT 422 naming the keyword — not as upstream round-trips. - new src/adapters/input_validation.rs: CompiledInputSchema compiles an op's input_schema once at import with the jsonschema crate (same 2020-12 dialect publish_schema uses) and validates peer input at call time; the compile-time copy is hardened closed-by-default (additionalProperties: false injected when absent) so the validator reproduces the allowlist's unknown-key semantics; explicit additionalProperties:true catch-all and schema values are preserved; the original spec value is never mutated - from_openapi/from_jsonschema import(): compile per registration, capture the validator in the handler closure (re-import recompiles — the closure capture is the invalidation story); a non-compilable input schema fails import loudly (AdapterError::SchemaParse naming the operation), matching the publish_schema fail-closed precedent - from_openapi generated input schemas now carry additionalProperties:false explicitly, so the /schema advert states the enforced rule and external schema-driven validators reach the same verdicts - forward/forward_stream/build_request gain an Option<&CompiledInputSchema> parameter; enforcement runs after the allowlist - round-trip test (review 002 Test-gap 10): the /schema-exported input_schema is compiled with the same validator and driven against build_request over a 10-input violation matrix — accept-sets exactly equal in both directions; the chain-test that lets advertise/enforce drift surface as a CI failure - ADR-066: new decision section (advertise==enforce) with the trust- boundary reasoning and the rejected option (b) rationale - module + enforce_input_schema docs updated to the two-gate shape cargo test --all-features 596 pass; clippy --all-features/-D warnings, fmt, doc gates clean. docs(tasks): mark review-002-fu-oai18-decision done
18 KiB
ADR-066: from_jsonschema as an HTTP-Backed Single-Endpoint Adapter in alkhttp
Ported from alknet ADR-066 (from_jsonschema as an HTTP-Backed Single-Endpoint Adapter in alknet-http); re-targeted to alkhttp.
Status
Accepted (supersedes the from_jsonschema clause of ADR-017 §5 and the
FromJsonSchema provenance row of ADR-022 — both described a schema-only,
no-handler adapter in the call crate)
Context
from_jsonschema was originally specified (alknet ADR-017 §5) as a
schema-only
adapter living in the call crate (alknet-call, now alkcall): it produced
HandlerRegistration bundles
with a NOT_FOUND-returning placeholder handler and FromJsonSchema
provenance. The stated use case was validation, discovery, and
composition-graph construction without a runtime — type-checking a
composition plan without executing it, building a UI of available
operations without standing up the transports.
This is broken. An operation in the OperationRegistry needs a real
handler. A placeholder that returns NOT_FOUND does not work with how
the registry is supposed to function: an Internal op registered with
a dead handler is a trap, not a feature. The "schema-only, no handler"
concept conflated two things — schema validation (a compile-time /
planning activity that doesn't need a registry entry at all) and
operation registration (which always needs a handler). Validation
against a JSON Schema does not require a HandlerRegistration; it
requires the schema and a validator. Registering an operation requires
a handler. The old from_jsonschema tried to do the former by abusing
the latter, and produced something that works for neither.
The misplacement was compounded by a location error: the adapter lived
in the call crate (which is supposed to stay lean — no HTTP client), but
a from_jsonschema that is actually useful for calling non-standard
endpoints needs reqwest, exactly like from_openapi and from_mcp.
The adapter location map in ADR-017 / the call crate's
client-and-adapters.md already
establishes that HTTP-backed adapters live in the HTTP crate; the old
from_jsonschema violated its own stated principle by living in
the call crate. The move was recorded as alknet ADR-066 (moved from
alknet-call to alknet-http); with the extraction, that makes it this
crate — alkhttp. The FromJsonSchema provenance variant itself stays in
the call crate (alkcall ADR-027 records the provenance-side decision:
FromJsonSchema is a handler-bearing leaf in alkcall's
OperationProvenance enum).
A concrete use case now forces the decision: composing a non-standard,
non-OpenAPI, basic REST endpoint that does not have a full OpenAPI
document. The endpoint has a method, a URL, an input/output JSON Schema,
and an auth scheme — but no paths object, no operationId, no
components. from_openapi requires an OpenAPI document; this endpoint
doesn't have one. The gap is: register a single HTTP endpoint as a
call-protocol operation, one at a time, with the caller supplying the
schema directly.
Decision
from_jsonschema becomes an HTTP-backed single-endpoint adapter in
alkhttp, functionally similar to from_openapi but registering
one endpoint at a time instead of parsing a full OpenAPI document:
-
The adapter implementation lives in alkhttp (
src/adapters/from_jsonschema.rs). The forwarding handler uses the same reqwest-backedSharedHttpClientand the same no-env-vars credential injection asfrom_openapi. The adapter implementsOperationAdapter(the trait from alkcall, ADR-017 §5 — unchanged). -
Give it a real forwarding handler. A
from_jsonschema-imported operation is a leaf with a reqwest forwarding handler, identical in shape to afrom_openapi-imported operation — it builds an HTTP request from the input (path/query/body split per a path template), injects credentials fromcontext.capabilities, sends via the shared HTTP client, and parses the response (JSON, text, or binary — same content-type branching asfrom_openapi). For aSubop type withtext/event-streamresponse, it registers aStreamingHandler(ADR-049), same asfrom_openapi. -
Single-endpoint registration. The caller supplies:
- An
OperationSpec(name, op type, input/output JSON Schema,error_schemas,access_control,visibility). - An
HttpServiceConfig(base URL, auth scheme, default headers — the same config typefrom_openapiuses). - A path template + HTTP method (the one endpoint).
The adapter builds one
HandlerRegistrationwithFromJsonSchemaprovenance and a real forwarding handler. The caller registers it in theOperationRegistry. This is the "one endpoint at a time" shape: nopathsobject to iterate, nooperationIdto normalize. - An
-
FromJsonSchemaprovenance stays in alkcall (in theOperationProvenanceenum,registration.rs). The provenance type lives where the registry types live; only the adapter implementation moved.FromJsonSchemais now a leaf provenance — it has a handler (a reqwest forwarding handler), same trust model asFromOpenAPI(HTTP endpoint trusted; handler is a forwarding stub). -
Remove the "schema-only, no handler" concept. The placeholder handler and the "schema-only ops are
Internal, so dispatch should never reach them" rationale are removed. An op registered withFromJsonSchemaprovenance is a real, callable, HTTP-forwarding operation —Internalby default (adapter-registered ops are composition material, ADR-015), but it actually forwards if invoked.The schema-validation-without-a-handler use case (type-checking a composition plan, building a UI) does not require a
HandlerRegistrationat all. That use case is served by consuming theOperationSpecdirectly (the spec already carries the input/ output JSON Schemas); no adapter, no registry entry, no handler is needed. If a future use case requires registering a schema-only op for discovery purposes, that is a separate feature and would warrant its own ADR — it is not whatfrom_jsonschemais.
Relationship to from_openapi
from_openapi |
from_jsonschema |
|
|---|---|---|
| Input | A full OpenAPI 3.x document (JSON or YAML) | A single endpoint: OperationSpec + HttpServiceConfig + path template + method |
| Granularity | One HandlerRegistration per (path, method) in the doc |
One HandlerRegistration per call |
| Schema source | Parsed from the OpenAPI doc (parameters, request body, responses) | Supplied directly by the caller |
| Handler | reqwest forwarding handler (shared HTTP client) | Same reqwest forwarding handler |
| Provenance | FromOpenAPI |
FromJsonSchema |
| Location | alkhttp | alkhttp |
| Use case | Standard OpenAPI APIs (GitHub, OpenAI, Anthropic) | Non-standard, non-OpenAPI, or basic REST endpoints without a full spec |
The two adapters share the forwarding-handler implementation, the
credential injection path, the error-fidelity rule (HTTP_<status>
prefix, ADR-023), and the no-env-vars
invariant (ADR-014).
The
difference is purely the input shape: a full document vs. a single
endpoint.
Response-key wildcards (review 002 OAI-13)
from_openapi projects OpenAPI response keys onto HTTP_<status> error
codes, which require a concrete status. Class wildcards map to the first
legal concrete status in their implied range: 4XX → HTTP_400, 5XX
→ HTTP_500. The declared payload schema of the wildcard response is
carried by the projected entry. default has no implied status range,
so it is not projected (it would advertise an HTTP_0 code that can
never match a callback status) — unmapped upstream statuses surface as
the synthesized HTTP_<actual> at call time regardless. A concrete
status key always outranks a wildcard covering the same range, in both
error projection and the success sweep (SSE detection +
output-schema selection), where the precedence order is: concrete 2XX
statuses, then 2XX, then default.
Input-schema enforcement: advertise == enforce (review 002 OAI-18)
Decided: enforce the full input schema at call time. Until this
decision, build_request enforced a key allowlist only (review 001
OAI-02: unknown keys rejected), while /schema advertised the full
JSON Schema — required, enum, pattern, value types, bounds. The
advertised contract was broader than the defended one: a
required: [id] operation accepted {} and let the upstream surface
the violation as a remote 422; a {"type": "string"} property sent as
an object serialized as JSON text into the query string;
enum/pattern/minimum were never consulted. This is the same
advertise/enforce drift class review 002 filed as
[minor→major-class], and OAI-02's own rationale decides the direction:
peer input is never trusted, and the advertised schema is exactly
what a peer reads before crafting input. ADR-066's trust boundary
(specs are trusted configuration) answers whether the assembly's
schema must be defended against; it does not make peer input trusted,
so "the spec is configuration" does not justify enforcement-by-allowlist.
Scoping the advert instead (the rejected alternative) would have to
strip required/enum/pattern from two projection surfaces
(to_openapi and to_mcp) and degrades the contract for well-behaved
consumers.
Concretely:
- Each adapter's
import()compiles the op'sinput_schemaonce with thejsonschemacrate (the same compiler and 2020-12 dialectPublishSchemaCachealready uses forpublish_schema) and captures the validator in the handler closure — compile once per registration, no runtime cache-invalidation scheme needed, mirroring the value-keyed cache only in spirit (the closure capture is the invalidation story: re-import recompiles). - The compile step hardens the schema copy it compiles: when
additionalPropertiesis absent,additionalProperties: falseis injected into the compiled copy, so the validator reproduces the OAI-02 closed-by-default semantics. The spec value itself is never mutated. from_openapi's generated input schemas now state the rule in the advert itself: every generated schema carriesadditionalProperties: falseexplicitly, so a schema-driven external consumer reaches the same verdicts the gateway enforces (pinned by the advertised-vs-enforced round-trip test).- The allowlist check runs first and is unchanged (its message
names the undeclared key and the declared set — better diagnosis
than the validator's generic additionalProperties error); the
compiled validator runs second. Unknown-key rejection therefore
keeps the established message, and leaf violations surface as
INVALID_INPUT422 naming the violated keyword and input location (ADR-023 shape) — the gateway rejects, never an upstream round-trip. - The explicit
"additionalProperties": trueopt-in catch-all (documented catch-all for open-shaped endpoints) is preserved verbatim by the compiler; declared keys' constraints still bind under it. - An input schema that fails to compile fails import loudly
(
AdapterError::SchemaParsenaming the operation), matching thepublish_schemafail-closed precedent: an un-validatable input contract must never register as an operation whose enforcement silently degrades.
Interaction with the gateway marker extensions: the
HEADER_PARAM_IN_MARKER-decorated properties and the body property
are peer-visible schema extensions that ride inside properties, so
the compiled validator treats them as ordinary properties (validated
against their embedded schema; the marker key itself is not a JSON
Schema keyword and is ignored by the compiler). The catch-all
semantics above keep open-shaped endpoints working.
Forwarding contract decisions (review 002 FWD-17/18/19)
The shared forwarding core (src/adapters/forward.rs) used by both
from_openapi and from_jsonschema pins three contracts. Each is a
decision the code half-implied; the module doc carries the full normative
text, this section records the reasoning at the assembly-trust boundary
that ADR-066 establishes: an assembly's spec/template/base-URL inputs
are trusted configuration, while peer call-time input never is.
FWD-17 — SSE payload contract (decided: carry raw payload + event
name for non-JSON frames). A subscription frame's data: payload
that is itself valid JSON surfaces as the decoded value (123 stays a
number, "123" stays a string). Any other payload surfaces as the
{"data": <raw>, "event": <name|null>} wrapper: a legitimately
non-JSON stream stays field-addressable and an upstream's named-event
conventions (event: error) are visible instead of indistinguishable
from data. The event value is the frame's event: field under
WHATWG last-wins semantics — null when absent, never the implicit
message default, so "named" and "default" stay distinguishable. JSON
frames surface as themselves even under a named event; consequence:
the event name is only visible on non-JSON frames. Chosen over the
alternative (document the old JSON-or-string contract) because the old
behavior silently erased payload shape and made an upstream's error
convention unobservable — a fidelity loss on the same axis ADR-023
already rejects for status mapping.
FWD-18 — placeholder routing rule (decided: fix + structural-value
error). A key matching a path-template placeholder is consumed by the
path and never also emits as a query parameter, whatever its value's
shape (the renderer's placeholder check precedes query routing — fixed
behavior, pinned by test). A placeholder renders exactly one literal
path segment, so a structural value (object/array) under a placeholder
key fails with INVALID_INPUT rather than splicing minified JSON into
the path. Scalar values (string/number/boolean/null) render
percent-encoded as before.
FWD-19 — literal % in template text (decided: documented
trade-off, not rejection). A % inside a value is always encoded
(% → %25), so a value can never inject or fake percent escapes. A
% in template or base text survives verbatim: an assembly writing
%2F into a template is presumed to intend a pre-encoded segment for
upstreams that route %2F differently from /. Inputs are
assembly-supplied (this ADR's trust boundary), so the strict shape
(call-time rejection of raw % in template text) is rejected — it
would break legitimate pre-encoded templates without adding safety:
the surviving %2F still renders as a single literal segment, cannot
change the origin, and cannot be forged from peer input. The trade-off
(upstream-dependent routing of bare percent escapes) is the
assembly's choice, not this crate's.
Consequences
Positive:
from_jsonschemaactually works — it has a real handler, not a placeholder. A concrete use case (non-standard REST endpoints) is served.- The adapter location is consistent: all HTTP-backed adapters
(
from_openapi,from_mcp,from_jsonschema) live in the HTTP crate (alkhttp), where reqwest is. The call crate (alkcall) stays lean. - The "schema-only, no handler" trap is removed. An op in the registry is always callable.
FromJsonSchemaprovenance becomes a real leaf, consistent withFromOpenAPI/FromMCP/FromCall.
Negative:
- The schema-validation-without-a-handler use case (the original stated
purpose) is no longer served by
from_jsonschema. That use case is served by consumingOperationSpecdirectly, but any code that relied on the placeholder handler returningNOT_FOUNDbreaks. The only existing consumer was the call crate's own tests; no downstream consumer depended on this — the placeholder was a trap, not a contract. - The call crate loses a public export (
from_jsonschema,FromJsonSchemathe adapter struct). TheFromJsonSchemaprovenance variant stays; the adapter struct moves. Downstream consumers that referenced the adapter (none currently) would need to use alkhttp's re-export.
Neutral:
FromJsonSchemaprovenance is now a leaf (handler-bearing), not a "no handler" provenance. The ADR-022 table row updates: it can compose? No. Has composition authority? No. Default visibility? Internal. Trust model? HTTP endpoint trusted; handler is a forwarding stub. This aligns with the other leaves. ADR-017 §5 and ADR-022's provenance table/enum-doc are amended (2026-07-09) to point here — the supersession is recorded in the superseded ADRs, not only in this one.
References
- Supersedes the
from_jsonschemaclause of ADR-017 §5 ("FromJsonSchema— imports from a JSON Schema definition (schema-only, no handler)") and the operational spec in the call crate'sclient-and-adapters.md§"from_jsonschema" (alknet mono-repo:docs/architecture/crates/call/client-and-adapters.md). - Supersedes the
FromJsonSchemarow of ADR-022 (the "no handler — schema only" framing). - Aligns with the adapter location principle in
ADR-017 §5 and the
call crate's
client-and-adapters.md§"Adapter Location Map": HTTP-backed adapters live in the HTTP crate (alkhttp). - Reuses the forwarding handler, credential injection, error fidelity
(
HTTP_<status>prefix, ADR-023), streaming shape (ADR-049), and no-env-vars invariant (ADR-014) established byfrom_openapi. - Reuses
HttpServiceConfigandSharedHttpClientfromfrom_openapi(in alkhttp). - alkcall ADR-027 — the decision record in the call crate (
from_jsonschemaas an HTTP-backed adapter;FromJsonSchemaprovenance is a handler-bearing leaf in alkcall'sOperationProvenance).