# 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_` prefix, [ADR-023](023-operation-error-schemas.md)), and the no-env-vars invariant ([ADR-014](014-secret-material-flow-and-capability-injection.md)). 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_` 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_` 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": , "event": }` 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](017-call-protocol-client-and-adapter-contract.md) §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](022-handler-registration-provenance-and-composition-authority.md) (the "no handler — schema only" framing). - Aligns with the adapter location principle in [ADR-017](017-call-protocol-client-and-adapter-contract.md) §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_` prefix, [ADR-023](023-operation-error-schemas.md)), streaming shape ([ADR-049](049-streaming-handler-for-subscriptions.md)), and no-env-vars invariant ([ADR-014](014-secret-material-flow-and-capability-injection.md)) 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`).