Files
alkhttp/docs/architecture/decisions/066-from-jsonschema-as-http-adapter.md
T
glm-5.3-flash 2ec02fd578 feat(adapters): enforce advertised input schemas at call time (OAI-18, option a)
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
2026-08-31 07:18:53 +00:00

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:

  1. The adapter implementation lives in alkhttp (src/adapters/from_jsonschema.rs). The forwarding handler uses the same reqwest-backed SharedHttpClient and the same no-env-vars credential injection as from_openapi. The adapter implements OperationAdapter (the trait from alkcall, ADR-017 §5 — unchanged).

  2. Give it a real forwarding handler. A from_jsonschema-imported operation is a leaf with a reqwest forwarding handler, identical in shape to a from_openapi-imported operation — it builds an HTTP request from the input (path/query/body split per a path template), injects credentials from context.capabilities, sends via the shared HTTP client, and parses the response (JSON, text, or binary — same content-type branching as from_openapi). For a Sub op type with text/event-stream response, it registers a StreamingHandler (ADR-049), same as from_openapi.

  3. 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 type from_openapi uses).
    • A path template + HTTP method (the one endpoint).

    The adapter builds one HandlerRegistration with FromJsonSchema provenance and a real forwarding handler. The caller registers it in the OperationRegistry. This is the "one endpoint at a time" shape: no paths object to iterate, no operationId to normalize.

  4. FromJsonSchema provenance stays in alkcall (in the OperationProvenance enum, registration.rs). The provenance type lives where the registry types live; only the adapter implementation moved. FromJsonSchema is now a leaf provenance — it has a handler (a reqwest forwarding handler), same trust model as FromOpenAPI (HTTP endpoint trusted; handler is a forwarding stub).

  5. 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 with FromJsonSchema provenance is a real, callable, HTTP-forwarding operation — Internal by 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 HandlerRegistration at all. That use case is served by consuming the OperationSpec directly (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 what from_jsonschema is.

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: 4XXHTTP_400, 5XXHTTP_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's input_schema once with the jsonschema crate (the same compiler and 2020-12 dialect PublishSchemaCache already uses for publish_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 additionalProperties is absent, additionalProperties: false is 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 carries additionalProperties: false explicitly, 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_INPUT 422 naming the violated keyword and input location (ADR-023 shape) — the gateway rejects, never an upstream round-trip.
  • The explicit "additionalProperties": true opt-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::SchemaParse naming the operation), matching the publish_schema fail-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_jsonschema actually 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.
  • FromJsonSchema provenance becomes a real leaf, consistent with FromOpenAPI/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 consuming OperationSpec directly, but any code that relied on the placeholder handler returning NOT_FOUND breaks. 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, FromJsonSchema the adapter struct). The FromJsonSchema provenance variant stays; the adapter struct moves. Downstream consumers that referenced the adapter (none currently) would need to use alkhttp's re-export.

Neutral:

  • FromJsonSchema provenance 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_jsonschema clause of ADR-017 §5 ("FromJsonSchema — imports from a JSON Schema definition (schema-only, no handler)") and the operational spec in the call crate's client-and-adapters.md §"from_jsonschema" (alknet mono-repo: docs/architecture/crates/call/client-and-adapters.md).
  • Supersedes the FromJsonSchema row 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 by from_openapi.
  • Reuses HttpServiceConfig and SharedHttpClient from from_openapi (in alkhttp).
  • alkcall ADR-027 — the decision record in the call crate (from_jsonschema as an HTTP-backed adapter; FromJsonSchema provenance is a handler-bearing leaf in alkcall's OperationProvenance).