From 3d77ffe1d323763997b20d31e8901c0c5cc33d88 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 30 Aug 2026 22:24:03 +0000 Subject: [PATCH 1/4] fix(adapters): drop dead search_filter parameter from to_mcp handle_batch (PRJ-24) The batch tool's input schema declares no query field, so the parsed search_filter was computed then discarded with 'let _ ='. Remove the parameter and the discard; call sites pass only (arguments, identity). --- src/adapters/to_mcp.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/adapters/to_mcp.rs b/src/adapters/to_mcp.rs index 89d3e7c..a2c221c 100644 --- a/src/adapters/to_mcp.rs +++ b/src/adapters/to_mcp.rs @@ -208,7 +208,6 @@ impl ToMcpGateway { async fn handle_batch( &self, - search_filter: Option, arguments: Option, identity: Option, ) -> CallToolResult { @@ -242,7 +241,6 @@ impl ToMcpGateway { .await; results.push(envelope_to_value(response)); } - let _ = search_filter; CallToolResult::structured(serde_json::json!({ "results": results })) } } @@ -458,7 +456,7 @@ impl rmcp::handler::server::ServerHandler for ToMcpGateway { TOOL_SEARCH => this.handle_search(search_filter, identity).await, TOOL_SCHEMA => this.handle_schema(arguments, identity).await, TOOL_CALL => this.handle_call(arguments, identity).await, - TOOL_BATCH => this.handle_batch(search_filter, arguments, identity).await, + TOOL_BATCH => this.handle_batch(arguments, identity).await, unknown => { let err = CallError::new( "NOT_FOUND", @@ -710,11 +708,7 @@ mod tests { TOOL_SEARCH => gateway.handle_search(search_filter, identity).await, TOOL_SCHEMA => gateway.handle_schema(arguments, identity).await, TOOL_CALL => gateway.handle_call(arguments, identity).await, - TOOL_BATCH => { - gateway - .handle_batch(search_filter, arguments, identity) - .await - } + TOOL_BATCH => gateway.handle_batch(arguments, identity).await, unknown => { let err = CallError::new( "NOT_FOUND", From 29d98b8c22e88ae61713ae56285cfa340be881e7 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 30 Aug 2026 22:55:38 +0000 Subject: [PATCH 2/4] fix(adapters): projection doc truthfulness in to_openapi (PRJ-16b/17/18/19/20/21/23, info.version 1.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PRJ-16b: BatchResultEntry.error now refs a defined BatchError component (oneOf over the six protocol-code envelopes plus a generic BatchOperationError arm carrying the operation-declared code enum); the dangling #/components/schemas/CallError ref is gone - PRJ-17: operation-declared errors at protocol statuses with HTTP_-prefixed codes merge into the shared protocol response's oneOf (per-code CallError_ components) instead of clobbering it — the runtime genuinely emits both; non-protocol statuses overwrite as before - PRJ-18: /publish 400 dropped the INVALID_OPERATION_TYPE claim (runtime reports that at 401 without a token, error.rs); the 401 entry is the true one and already documented - PRJ-19: 415 (missing/non-JSON Content-Type) and plain-text 422 (shape-rejection) extractor slots documented, extending the plain-text extractor rejection family; /subscribe gains 415/422, /call gains 415 with the shape-rejection noted on 422, /search and /schema gain the slots too - PRJ-20: /call 401 now carries the identity-split oneOf (FORBIDDEN + INVALID_OPERATION_TYPE), matching error.rs's 401-without-identity mapping for both - PRJ-21: /batch's unreachable 500 removed (all dispatch failures are in-band entries; routes.rs has no 500 path) - PRJ-23: the OAS-invalid x-operation-error-statuses pseudo-schema key inside components.schemas removed (nothing consumed it; any openapiv3 registry rejects it as an invalid schema name) - info.version 1.3.0 per ADR-045 (doc-contract corrections, wire contract unchanged) Verification: cargo test (to_openapi suite 42/42 green, incl. the populated-registry openapiv3 parse and deterministic golden checks) --- src/adapters/to_openapi.rs | 567 ++++++++++++++++++++++++++++++------- 1 file changed, 470 insertions(+), 97 deletions(-) diff --git a/src/adapters/to_openapi.rs b/src/adapters/to_openapi.rs index b49397f..25d6f90 100644 --- a/src/adapters/to_openapi.rs +++ b/src/adapters/to_openapi.rs @@ -18,8 +18,19 @@ //! the doc with the settled gateway runtime contract (review-001 //! PRJ-01..05, PRJ-14, PRJ-15): envelope responses, 422 client-fault //! mappings, the `/subscribe` 200+SSE asymmetry, Bearer -//! `securitySchemes`, shared error components — additive; no -//! previously-documented status or field was removed. +//! `securitySchemes`, shared error components. `1.3.0` is the +//! review-002 projection-truthfulness pass (PRJ-16b/17/18/19/20/21/23): +//! runtime-resolvable refs (the in-band `BatchResultEntry.error` +//! component), protocol-status op errors merged oneOf into the shared +//! responses instead of clobbering them, `/call` 401 and the extractor +//! 415/plain-text-422 slots documented, `/publish` 400 narrowed to the +//! framing contract, the unreachable `/batch` 500 removed, and the +//! OAS-invalid `x-operation-error-statuses` pseudo-schema dropped. Per +//! ADR-045's tracking rule this is a minor bump, not major: the +//! corrections remove only documentation of statuses/codes the runtime +//! never emitted (a strict client matching them observed nothing to +//! break), and every added slot documents behavior the runtime already +//! had — the wire contract is unchanged. //! //! # Error fidelity //! @@ -28,25 +39,28 @@ //! //! - `INVALID_INPUT` and identity-resolved `INVALID_OPERATION_TYPE` are //! documented at `422` — the status the runtime emits. Axum extractor -//! rejections (malformed JSON body, wrong shape) are plain-text -//! 400/415/422 bodies with none of the `CallError` JSON shape; the doc -//! documents the dispatch-path contract and notes the extractor gap -//! per response (the PRJ-03 doc-align decision; closing the gap is a -//! runtime change out of scope here). +//! rejections are plain-text bodies with none of the `CallError` JSON +//! shape: malformed framing answers `400`, a missing or non-JSON +//! `Content-Type` answers `415`, and a syntactically-valid body that +//! fails deserialization answers `422` (axum's `Json`/`Query` +//! rejection statuses — PRJ-19). Those slots are documented alongside +//! the dispatch-path JSON contract per endpoint. //! - Protocol-level codes are documented at their mapped statuses. //! `FORBIDDEN`/`INVALID_OPERATION_TYPE` map to `401` (no token) or //! `403`/`422` (token present) per the identity-aware mapper; the doc -//! places them at the identity-resolved status. +//! places them at the identity-resolved status, so the `401` slots +//! carry both codes (PRJ-20). //! - Operation-level codes are projected by registry-declared //! `http_status`, **but** the runtime is purely code-driven: a //! non-`HTTP_*` code (e.g. `RATE_LIMITED` declared at 429) actually //! surfaces as `500 INTERNAL` because `ErrorDefinition.http_status` is -//! never consulted at dispatch. The doc annotates every such -//! projection with `x-runtime-behavior: 500` (the PRJ-04 -//! project-honest decision; honoring `http_status` at runtime was -//! rejected as a runtime contract change). `HTTP_`-prefixed -//! codes surface at their declared status at runtime and carry no -//! annotation. +//! never consulted at dispatch. Non-protocol statuses carry +//! `x-runtime-behavior: 500` (the PRJ-04 project-honest decision); +//! protocol-status declarations from `HTTP_`-prefixed codes +//! are **merged oneOf** into the shared protocol response instead of +//! overwriting it (PRJ-17) — the runtime genuinely maps those codes +//! to the declared status, alongside the protocol codes that status +//! already documents. //! //! See `docs/architecture/http-adapters.md` §"to_openapi" and //! ADR-042/045/068/023. @@ -61,7 +75,7 @@ use alkcall::registry::spec::ErrorDefinition; use super::openapi_spec::OpenAPISpec; -const GATEWAY_VERSION: &str = "1.2.0"; +const GATEWAY_VERSION: &str = "1.3.0"; const GATEWAY_TITLE: &str = "alk gateway"; const OPENAPI_VERSION: &str = "3.0.0"; @@ -89,6 +103,9 @@ const CODE_NOT_FOUND: &str = "NOT_FOUND"; const CODE_INTERNAL: &str = "INTERNAL"; const CODE_TIMEOUT: &str = "TIMEOUT"; +const SCHEMA_BATCH_ERROR: &str = "BatchError"; +const SCHEMA_BATCH_OPERATION_ERROR: &str = "BatchOperationError"; + const SCHEME_BEARER: &str = "bearerAuth"; const RESPONSE_REF: &str = "#/components/schemas/"; @@ -141,6 +158,9 @@ fn search_path_item() -> Value { "responses": { "200": json_response(ref_schema("SearchResponse"), "The AccessControl-filtered operation listing under `output` (GW-02). Items carry name, namespace, and op_type; forbidden ops are omitted from the listing — the request does not fail. Cache-Control: no-store, Vary: Authorization."), + "400": plain_text_extractor_rejection(), + "415": plain_text_unsupported_media_type(), + "422": plain_text_extractor_shape_rejection(), "404": json_response(ref_response("NotFound"), "The appended /search and /schema reserved paths are hidden from the listing; a direct dispatch to services/list still resolves normally."), "500": json_response(ref_response("Internal"), @@ -170,6 +190,7 @@ fn schema_path_item() -> Value { "200": json_response(ref_schema("SchemaResponse"), "The operation's full spec under `output`. Cache-Control: no-store, Vary: Authorization."), "400": plain_text_extractor_rejection(), + "415": plain_text_unsupported_media_type(), "401": json_response(ref_response("Unauthorized"), "No bearer token or it did not resolve; AccessControl checks FORBIDDEN (401 without a token)."), "403": json_response(ref_response("Forbidden"), @@ -177,7 +198,7 @@ fn schema_path_item() -> Value { "404": json_response(ref_response("NotFound"), "Unknown operation, or the operation is Internal (hidden from HTTP discovery, GW-02)."), "422": json_response(ref_response("InvalidInput"), - "Dispatch-path client fault: the services/schema op reported INVALID_INPUT (missing name)."), + "Dispatch-path client fault: the services/schema op reported INVALID_INPUT (missing name). A query string that parses but yields no `name` is a plain-text extractor 422 (PRJ-19)."), "500": json_response(ref_response("Internal"), "Dispatcher failure."), "504": json_response(ref_response("Timeout"), @@ -206,6 +227,62 @@ fn call_path_item(operation_errors: &BTreeMap) -> Value { }) } +/// The operation-declared status projections merged into a target +/// responses map (PRJ-17): the shared protocol statuses document the +/// protocol codes the runtime emits at that status, and the runtime +/// also maps `HTTP_`-prefixed operation codes to their declared +/// status — so instead of overwriting a shared response (which would +/// erase the protocol codes every real call still carries), each +/// operation-declared projection is appended to the shared response's +/// `oneOf` as a per-code envelope component. Non-protocol statuses have +/// nothing shared to merge with; they overwrite as before (they only +/// occur at keys the fixed map has not set). +fn merge_operation_errors(responses: &mut Value, operation_errors: &BTreeMap) { + for (status, projection) in operation_errors { + let key = status.to_string(); + let code_variants: Vec = projection + .pointer("/content/application~1json/schema/properties/code/enum") + .and_then(Value::as_array) + .map(|codes| { + codes + .iter() + .filter_map(|code| code.as_str()) + .map(error_variant_ref) + .collect() + }) + .unwrap_or_default(); + if code_variants.is_empty() { + continue; + } + let Some(shared) = responses.get_mut(&key) else { + responses[&key] = projection.clone(); + continue; + }; + let Some(shared_schema) = shared + .pointer_mut("/content/application~1json/schema") + .filter(|schema| schema.is_object()) + else { + responses[&key] = projection.clone(); + continue; + }; + let existing = shared_schema.as_object_mut().expect("schema object"); + if !existing.contains_key("oneOf") { + let shared_schema_value = existing.clone(); + existing.insert("oneOf".to_string(), json!([shared_schema_value])); + } + if let Some(variants) = existing.get_mut("oneOf").and_then(Value::as_array_mut) { + variants.extend(code_variants); + } + } +} + +/// The in-envelope component name of one operation-declared error code +/// (PRJ-17's merge target): the oneOf variant a merged protocol +/// response references. +fn error_variant_ref(code: &str) -> Value { + json!({ "$ref": format!("{RESPONSE_REF}CallError_{code}") }) +} + /// The fixed status set of a dispatch-path endpoint's documented /// response map (200 + protocol statuses, plus the operation-declared /// statuses gathered from the registry; PRJ-12 determinism: the merge @@ -215,17 +292,27 @@ fn call_responses(operation_errors: &BTreeMap) -> Value { "200": json_response(ref_schema("CallOk"), "The operation's final ResponseEnvelope: request_id, result=ok, output."), "400": plain_text_extractor_rejection(), - "401": json_response(ref_response("Unauthorized"), - "No bearer token or it did not resolve (AccessControl denial without identity)."), + "415": plain_text_unsupported_media_type(), + "401": json_response(one_of_refs(&[ + "CallErrorForbidden".to_string(), + "CallErrorInvalidOperationType".to_string(), + ]), + "No bearer token or it did not resolve: AccessControl denial (FORBIDDEN) or the non-Once-op dispatch-path report (INVALID_OPERATION_TYPE) without identity (PRJ-20). Malformed JSON bodies are rejected earlier by the extractors with a plain-text 400 (not this JSON shape)."), "403": json_response(ref_response("Forbidden"), "Token resolved, but AccessControl denies the operation."), "404": json_response(ref_response("NotFound"), "Unknown operation, or the operation is Internal (hidden from the HTTP surface)."), - "422": json_response(one_of_refs(&[ - "CallErrorInvalidInput".to_string(), - "CallErrorInvalidOperationType".to_string(), - ]), - "Dispatch-path client fault: malformed input (INVALID_INPUT), or the operation's type does not accept a Once invoke (INVALID_OPERATION_TYPE). Malformed JSON bodies are rejected earlier by the extractors with a plain-text 400 (not this JSON shape). Once-op invokes are bounded by the 30 s gateway deadline."), + "422": json_response( + { + let mut schema = one_of_refs(&[ + "CallErrorInvalidInput".to_string(), + "CallErrorInvalidOperationType".to_string(), + ]); + schema["x-extractor-variant"] = json!("A syntactically valid JSON body that fails deserialization is also answered 422, with a plain-text extractor body (PRJ-19) — see components.responses.ExtractorShapeRejection."); + schema + }, + "Dispatch-path client fault: malformed input (INVALID_INPUT), or the operation's type does not accept a Once invoke (INVALID_OPERATION_TYPE). The extractor's shape rejection (valid JSON, wrong shape) is also a 422 but with a plain-text body, not this envelope (PRJ-19). Once-op invokes are bounded by the 30 s gateway deadline.", + ), "500": json_response(one_of_refs(&[ "CallErrorInternal".to_string(), "CallFailure".to_string(), @@ -234,9 +321,7 @@ fn call_responses(operation_errors: &BTreeMap) -> Value { "504": json_response(ref_response("Timeout"), "The Once-op dispatch exceeded the 30 s gateway deadline (retryable)."), }); - for (status, projection) in operation_errors { - responses[&status.to_string()] = projection.clone(); - } + merge_operation_errors(&mut responses, operation_errors); responses } @@ -267,9 +352,8 @@ fn publish_responses(operation_errors: &BTreeMap) -> Value { "The operation's final ResponseEnvelope: request_id, result=ok, output."), "400": json_response(one_of_refs(&[ "CallErrorInvalidInput".to_string(), - "CallErrorInvalidOperationType".to_string(), ]), - "Malformed NDJSON stream framing: empty body, first line missing 'operation'/'chunk', a line exceeding the 2 MiB per-line cap (INVALID_INPUT), or a non-Pub op reported INVALID_OPERATION_TYPE without a resolved token (see 422 for the token-present variant). Raw read failures surface as INVALID_INPUT too."), + "Malformed NDJSON stream framing: empty body, first line missing 'operation'/'chunk', a line exceeding the 2 MiB per-line cap, or a raw body-read failure (INVALID_INPUT). A later NDJSON line that is not valid JSON is a dispatch-path 422. The gateway's 2 MiB + 64 KiB body-limit layer answers an oversized whole-body upload with a plain-text 413 before the route runs. A non-Pub op without a resolved token is the 401 below (INVALID_OPERATION_TYPE), not this status (PRJ-18)."), "401": json_response(one_of_refs(&[ "CallErrorForbidden".to_string(), "CallErrorInvalidOperationType".to_string(), @@ -292,12 +376,7 @@ fn publish_responses(operation_errors: &BTreeMap) -> Value { "504": json_response(ref_response("Timeout"), "The Once-op final envelope dispatch exceeded the 30 s gateway deadline (retryable)."), }); - for (status, projection) in operation_errors { - if status == &STATUS_BAD_REQUEST { - continue; - } - responses[&status.to_string()] = projection.clone(); - } + merge_operation_errors(&mut responses, operation_errors); responses } @@ -321,16 +400,11 @@ fn batch_path_item() -> Value { }, "responses": { "200": json_response(ref_schema("BatchResponse"), - "results[] shares entries' order with the request; each entry is an envelope-shaped {request_id, result, output|error} object; entries for Internal ops carry a NOT_FOUND in-band error."), + "results[] shares entries' order with the request; each entry is an envelope-shaped {request_id, result, output|error} object; entries for Internal ops carry a NOT_FOUND in-band error. Per-call dispatch failures surface only as these in-band entries — there is no HTTP error status for an individual call."), "400": json_response(one_of_refs(&[ "BatchCapExceeded".to_string(), ]), - "Request-level failure: the batch exceeds 100 operations (INVALID_INPUT, JSON). Malformed JSON bodies are rejected earlier by the extractors with a plain-text 400 (not this JSON shape)."), - "500": json_response(one_of_refs(&[ - "CallErrorInternal".to_string(), - "CallFailure".to_string(), - ]), - "Dispatcher failure, or an operation-level error code without HTTP_ prefix or http_status (the runtime mapper is purely code-driven and such codes surface as 500)."), + "Request-level failure: the batch exceeds 100 operations (INVALID_INPUT, JSON). Malformed JSON bodies are rejected earlier by the extractors with a plain-text 400 (not this JSON shape); a JSON array body whose items fail deserialization is a plain-text extractor 422. Oversized uploads are pre-empted by the gateway's body-limit layer with a plain-text 413."), } } }) @@ -354,6 +428,8 @@ fn subscribe_path_item() -> Value { "200": sse_response( "Server-Sent Events. Each Success SSE event carries one output value (with a retry: 15000 reconnect hint); an Error event carries the serialized CallError as its data and ends the stream (an Err is terminal — no events follow it, matching the wire dispatch's call.error semantics). Keep-alive comment frames are sent every 15 s. Pre-dispatch denials (unknown op, Internal op, ACL denial, wrong dispatch path), handler-triggered (not stream) failures and mid-stream handler failures all appear as event:error frames here — standard HTTP monitoring sees no failures on /subscribe; clients must inspect event:error."), "400": plain_text_extractor_rejection(), + "415": plain_text_unsupported_media_type(), + "422": plain_text_extractor_shape_rejection(), } } }) @@ -393,6 +469,8 @@ fn components(operation_errors: &BTreeMap) -> Value { "OperationSpecOutput": operation_spec_output_schema(), "BatchResponse": batch_response_schema(), "BatchResultEntry": batch_result_entry_schema(), + "BatchError": batch_error_schema(), + "BatchOperationError": error_variant_schema(&operation_error_codes(operation_errors)), "BatchCapExceeded": batch_cap_exceeded_schema(), "SseStream": sse_stream_schema(), "NdjsonBody": ndjson_body_schema(), @@ -411,9 +489,11 @@ fn components(operation_errors: &BTreeMap) -> Value { "CallErrorInternal": call_error_schema(CODE_INTERNAL), "CallErrorTimeout": call_error_schema(CODE_TIMEOUT) }); - if !operation_errors.is_empty() { - schemas["x-operation-error-statuses"] = - json!(operation_errors.keys().copied().collect::>()); + for code in operation_error_codes(operation_errors) { + let component = format!("CallError_{code}"); + if schemas.get(&component).is_none() { + schemas[&component] = call_error_schema(&code); + } } json!({ "securitySchemes": { @@ -440,6 +520,26 @@ fn components(operation_errors: &BTreeMap) -> Value { }) } +/// The operation-declared codes, deduplicated and sorted across every +/// status (BTreeMap-fold, PRJ-12 determinism): the code set the merged +/// `CallError_` components and `BatchOperationError`'s enum cover. +fn operation_error_codes(operation_errors: &BTreeMap) -> Vec { + let mut codes = std::collections::BTreeSet::new(); + for projection in operation_errors.values() { + if let Some(enum_codes) = projection + .pointer("/content/application~1json/schema/properties/code/enum") + .and_then(Value::as_array) + { + codes.extend( + enum_codes + .iter() + .filter_map(|v| v.as_str().map(str::to_string)), + ); + } + } + codes.into_iter().collect() +} + fn one_of_response() -> Value { json_response( one_of_refs(&[ @@ -499,6 +599,22 @@ fn call_error_schema(code: &str) -> Value { schema } +/// The generic in-band error variant: the `CallError` wire shape with +/// `code` carrying the operation-declared enum (PRJ-16b). With no +/// declared operation errors the arm collapses to the unpinned shape +/// (still runtime-true: a foreign code surfaces as `500 INTERNAL`, +/// whose envelope is exactly this shape). +fn error_variant_schema(codes: &[String]) -> Value { + let mut schema = call_error_shape_schema(); + if !codes.is_empty() { + schema["properties"]["code"] = json!({ + "type": "string", + "enum": codes + }); + } + schema +} + fn failure_envelope_schema(operation_errors: &BTreeMap) -> Value { let _ = operation_errors; json!({ @@ -599,11 +715,31 @@ fn batch_result_entry_schema() -> Value { "request_id": { "type": "string" }, "result": { "type": "string", "enum": ["ok", "error"] }, "output": { "description": "The operation's output when result=ok." }, - "error": ref_schema("CallError") + "error": ref_schema(SCHEMA_BATCH_ERROR) } }) } +/// The in-band error of a failed batch entry (PRJ-16b): each failed +/// entry carries the serialized `CallError` — a protocol code (the six +/// pinned components) or an operation-declared code (the generic arm's +/// enum). An in-band payload is the raw error, not the 500-remap the +/// HTTP status path applies, so the operation-code arm stays generic. +fn batch_error_schema() -> Value { + json!({ + "description": "The in-band error of one failed batch entry: the serialized CallError as the runtime emits it — any protocol or operation-declared code lands here.", + "oneOf": [ + ref_schema("CallErrorInvalidInput"), + ref_schema("CallErrorInvalidOperationType"), + ref_schema("CallErrorForbidden"), + ref_schema("CallErrorNotFound"), + ref_schema("CallErrorInternal"), + ref_schema("CallErrorTimeout"), + ref_schema(SCHEMA_BATCH_OPERATION_ERROR) + ] + }) +} + fn batch_response_schema() -> Value { json!({ "type": "object", @@ -666,13 +802,47 @@ fn ref_response(name: &str) -> Value { /// rejection — NOT the `CallError` JSON envelope. The route extractors /// (`Json`, `Json>`, `Query`) /// reject malformed request framing before dispatch; their bodies are -/// plain text (axum default). This is a known runtime gap (PRJ-03 -/// doc-align): the JSON `CallError` shape is only guaranteed on the -/// dispatch-path errors (422/401/403/404/500/504 and operation-declared -/// statuses). +/// plain text (axum default). The sibling slots +/// ([`plain_text_unsupported_media_type`], +/// [`plain_text_extractor_shape_rejection`]) document the 415 and 422 +/// rejections the same extractor layer emits (PRJ-19). These are known +/// runtime gaps (PRJ-03 doc-align): the JSON `CallError` shape is only +/// guaranteed on the dispatch-path errors (401/403/404/422/500/504 and +/// operation-declared statuses). fn plain_text_extractor_rejection() -> Value { json!({ - "description": "Malformed request framing (bad JSON body / wrong shape / bad query string). NOTE: the framework's extractor rejects with a PLAIN-TEXT body (not the CallError JSON shape this doc uses elsewhere) — a known runtime gap (review-001 PRJ-03). Dispatch-path client faults use 422 with the JSON envelope instead.", + "description": "Malformed request framing (syntactically invalid JSON body / malformed query string). NOTE: the framework's extractor rejects with a PLAIN-TEXT body (not the CallError JSON shape this doc uses elsewhere) — a known runtime gap (review-001 PRJ-03). Dispatch-path client faults use 422 with the JSON envelope instead. A body exceeding the gateway's request-body limit is a plain-text 413.", + "content": { + "text/plain": { + "schema": { "type": "string" } + } + } + }) +} + +/// A `415` response: the plain-text rejection emitted when the request +/// carries no `Content-Type` (or a non-JSON one on a JSON-body +/// endpoint) (PRJ-19). Same extractor layer as the 400/422 slots. +fn plain_text_unsupported_media_type() -> Value { + json!({ + "description": "Missing Content-Type (or a type the endpoint does not consume). The framework's extractor rejects with a PLAIN-TEXT body — not the CallError JSON shape (PRJ-19).", + "content": { + "text/plain": { + "schema": { "type": "string" } + } + } + }) +} + +/// A `422` response: the plain-text rejection emitted when the body is +/// syntactically valid JSON but fails deserialization into the +/// endpoint's request type (e.g. a JSON array where the endpoint reads +/// one object, or a per-item shape mismatch on `/batch`) (PRJ-19). +/// Dispatch-path 422s (invalid *input data*) use the JSON envelope +/// instead. +fn plain_text_extractor_shape_rejection() -> Value { + json!({ + "description": "The body is valid JSON but does not deserialize into the endpoint's request shape. The framework's extractor rejects with a PLAIN-TEXT body — not the CallError JSON shape (PRJ-19).", "content": { "text/plain": { "schema": { "type": "string" } @@ -897,7 +1067,7 @@ mod tests { } #[test] - fn info_version_is_1_2_0_after_runtime_truth_overhaul() { + fn info_version_is_1_3_0_after_projection_truthfulness() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let version = spec @@ -907,8 +1077,8 @@ mod tests { .unwrap(); assert_eq!(version, GATEWAY_VERSION); assert_eq!( - version, "1.2.0", - "minor bump: the gateway shape only gains documented fields/statuses (ADR-045)" + version, "1.3.0", + "minor bump: doc-contract corrections only — the wire contract is unchanged (ADR-045)" ); } @@ -937,6 +1107,25 @@ mod tests { assert_eq!(spec.paths.len(), 6); } + #[test] + fn doc_with_operation_errors_validates_against_openapiv3() { + let registry = sample_registry(); + let spec = to_openapi(®istry).unwrap(); + let text = serde_json::to_string(&spec.raw).unwrap(); + let parsed: openapiv3::OpenAPI = + serde_json::from_str(&text).expect("populated doc parses as OpenAPI 3.0"); + let components = parsed.components.expect("components present"); + assert!( + components + .schemas + .keys() + .any(|name| name.starts_with("CallError_")), + "merged operation-error components present: {:?}", + components.schemas.keys().collect::>() + ); + assert_eq!(spec.paths.len(), 6); + } + // --- PRJ-15: securitySchemes / security ------------------------------- #[test] @@ -1086,7 +1275,9 @@ mod tests { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let responses = responses(&spec, PATH_SCHEMA, "get"); - for status in ["200", "400", "401", "403", "404", "422", "500", "504"] { + for status in [ + "200", "400", "401", "403", "404", "415", "422", "500", "504", + ] { assert!( responses.contains_key(status), "/schema {status} documented" @@ -1129,10 +1320,10 @@ mod tests { let r400 = responses_map.get("400").unwrap(); let text = r400["content"]["text/plain"]["schema"]["type"].as_str(); assert_eq!(text, Some("string")); + let description = r400.get("description").and_then(Value::as_str).unwrap(); assert!( - r400.pointer("/description").and_then(Value::as_str) - == Some("Malformed request framing (bad JSON body / wrong shape / bad query string). NOTE: the framework's extractor rejects with a PLAIN-TEXT body (not the CallError JSON shape this doc uses elsewhere) — a known runtime gap (review-001 PRJ-03). Dispatch-path client faults use 422 with the JSON envelope instead."), - "the extractor-rejection gap is documented on the 400 itself" + description.contains("PLAIN-TEXT body"), + "the extractor-rejection gap is documented on the 400 itself: {description}" ); assert!(r400["content"].get("application/json").is_none()); } @@ -1144,16 +1335,59 @@ mod tests { let responses = responses(&spec, PATH_BATCH, "post"); assert!(responses.contains_key("400")); let schema = response_schema(responses.get("400").unwrap()); - let refs: Vec<&str> = schema - .get("oneOf") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(|v| v.get("$ref").and_then(Value::as_str)) - .collect() - }) - .unwrap_or_default(); - assert!(refs.contains(&"#/components/schemas/BatchCapExceeded")); + let refs = one_of_refs_of(schema); + assert!(refs.contains(&"#/components/schemas/BatchCapExceeded".to_string())); + } + + #[test] + fn call_and_search_document_the_extractor_415_and_422_slots() { + let registry = OperationRegistry::new(); + let spec = to_openapi(®istry).unwrap(); + for (path_name, method, statuses) in [ + (PATH_CALL, "post", vec!["400", "415"]), + (PATH_SEARCH, "get", vec!["400", "415", "422"]), + (PATH_SCHEMA, "get", vec!["400", "415"]), + (PATH_SUBSCRIBE, "post", vec!["400", "415", "422"]), + ] { + let responses_map = responses(&spec, path_name, method); + for status in statuses { + let slot = responses_map + .get(status) + .unwrap_or_else(|| panic!("{path_name} {status} documented (PRJ-19)")); + let text_type = slot + .get("content") + .and_then(|c| c.get("text/plain")) + .and_then(|c| c.get("schema")) + .and_then(|s| s.get("type")) + .and_then(Value::as_str); + assert!( + text_type == Some("string"), + "{path_name} {status} is a plain-text extractor slot (PRJ-19)" + ); + } + } + let call_responses_map = responses(&spec, PATH_CALL, "post"); + let call_422 = call_responses_map.get("422").unwrap(); + assert!( + call_422 + .get("description") + .and_then(Value::as_str) + .unwrap() + .contains("plain-text body"), + "the /call 422 description distinguishes the extractor variant" + ); + } + + #[test] + fn search_drift_check_after_prj19_additions() { + let registry = OperationRegistry::new(); + let spec = to_openapi(®istry).unwrap(); + let responses = responses(&spec, PATH_SEARCH, "get"); + assert!( + !responses.contains_key("401") && !responses.contains_key("403"), + "PRJ-15 unaffected by PRJ-19 additions: /search still carries no 401/403" + ); + assert!(responses.contains_key("404")); } // --- PRJ-04: operation-error projection honesty ------------------------ @@ -1220,6 +1454,55 @@ mod tests { ); } + #[test] + fn http_prefixed_protocol_status_merges_into_shared_response() { + let mut registry = OperationRegistry::new(); + register( + &mut registry, + external_spec("svc/op", vec![error("HTTP_404", Some(404))]), + ); + let spec = to_openapi(®istry).unwrap(); + let responses_map = responses(&spec, PATH_CALL, "post"); + let r404 = responses_map.get("404").unwrap(); + let schema = response_schema(r404); + assert_eq!( + schema.get("$ref").and_then(Value::as_str), + Some("#/components/responses/NotFound"), + "PRJ-17: the shared NOT_FOUND response survives the merge" + ); + let variant_refs: Vec = schema + .get("oneOf") + .and_then(Value::as_array) + .expect("PRJ-17: op-declared variants appended to the shared response's oneOf") + .iter() + .filter_map(|v| v.get("$ref").and_then(Value::as_str).map(str::to_string)) + .collect(); + assert!( + variant_refs.contains(&"#/components/schemas/CallError_HTTP_404".to_string()), + "the HTTP_404 variant is listed alongside the shared response: {variant_refs:?}" + ); + assert!( + spec.raw["components"]["schemas"]["CallError_HTTP_404"] + .pointer("/properties/code/enum/0") + .is_some(), + "the merged variant component is defined" + ); + } + + #[test] + fn http_prefixed_unauthorized_status_appends_to_401_one_of() { + let mut registry = OperationRegistry::new(); + register( + &mut registry, + external_spec("svc/op", vec![error("HTTP_401", Some(401))]), + ); + let spec = to_openapi(®istry).unwrap(); + let responses_map = responses(&spec, PATH_CALL, "post"); + let refs = one_of_refs_of(response_schema(responses_map.get("401").unwrap())); + assert!(refs.contains(&"#/components/schemas/CallErrorForbidden".to_string())); + assert!(refs.contains(&"#/components/schemas/CallError_HTTP_401".to_string())); + } + #[test] fn operation_error_without_http_status_not_projected() { let mut registry = OperationRegistry::new(); @@ -1290,17 +1573,19 @@ mod tests { // --- PRJ-05: /subscribe 200 + in-band error contract ------------------- #[test] - fn subscribe_documents_only_200_and_extractor_400() { + fn subscribe_documents_200_and_extractor_slots() { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let responses = responses(&spec, PATH_SUBSCRIBE, "post"); assert_eq!( responses.len(), - 2, - "200 + extractor 400 only: {responses:?}" + 4, + "200 + extractor 400/415/422 only: {responses:?}" ); assert!(responses.contains_key("200")); assert!(responses.contains_key("400")); + assert!(responses.contains_key("415")); + assert!(responses.contains_key("422")); let content = &responses["200"]["content"]; assert!(content.get("text/event-stream").is_some()); assert!(content.get("application/json").is_none()); @@ -1462,38 +1747,35 @@ mod tests { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let responses = responses(&spec, PATH_PUBLISH, "post"); - let refs_400: Vec = { - let schema = response_schema(&responses["400"]); - schema - .get("oneOf") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(|v| v.get("$ref").and_then(Value::as_str)) - .map(str::to_string) - .collect() - }) - .unwrap_or_default() - }; + let refs_400 = one_of_refs_of(response_schema(&responses["400"])); assert!(refs_400.contains(&"#/components/schemas/CallErrorInvalidInput".to_string())); - let refs_401: Vec = { - let schema = response_schema(&responses["401"]); - schema - .get("oneOf") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(|v| v.get("$ref").and_then(Value::as_str)) - .map(str::to_string) - .collect() - }) - .unwrap_or_default() - }; + let refs_401 = one_of_refs_of(response_schema(&responses["401"])); assert!( refs_401.contains(&"#/components/schemas/CallErrorInvalidOperationType".to_string()) ); } + #[test] + fn publish_400_documents_only_the_framing_contract() { + let registry = OperationRegistry::new(); + let spec = to_openapi(®istry).unwrap(); + let responses = responses(&spec, PATH_PUBLISH, "post"); + let refs_400 = one_of_refs_of(response_schema(&responses["400"])); + assert_eq!( + refs_400, + vec!["#/components/schemas/CallErrorInvalidInput".to_string()], + "PRJ-18: /publish 400 oneOf is exactly the INVALID_INPUT framing code" + ); + let description = responses["400"] + .get("description") + .and_then(Value::as_str) + .unwrap(); + assert!( + !description.contains("reported INVALID_OPERATION_TYPE"), + "PRJ-18: the 400 description must not claim the 401-reported condition as a 400 outcome: {description}" + ); + } + // --- /call protocol statuses -------------------------------------------- #[test] @@ -1501,7 +1783,9 @@ mod tests { let registry = OperationRegistry::new(); let spec = to_openapi(®istry).unwrap(); let responses = responses(&spec, PATH_CALL, "post"); - for status in ["200", "400", "401", "403", "404", "422", "500", "504"] { + for status in [ + "200", "400", "401", "403", "404", "415", "422", "500", "504", + ] { assert!(responses.contains_key(status), "/call {status} documented"); } assert!( @@ -1514,6 +1798,95 @@ mod tests { ); } + #[test] + fn call_401_covers_the_identity_split_codes() { + let registry = OperationRegistry::new(); + let spec = to_openapi(®istry).unwrap(); + let responses = responses(&spec, PATH_CALL, "post"); + let refs_401 = one_of_refs_of(response_schema(&responses["401"])); + assert!( + refs_401.contains(&"#/components/schemas/CallErrorForbidden".to_string()), + "PRJ-20: FORBIDDEN lands at 401 without identity: {refs_401:?}" + ); + assert!( + refs_401.contains(&"#/components/schemas/CallErrorInvalidOperationType".to_string()), + "PRJ-20: unauthenticated Sub/Pub call reports INVALID_OPERATION_TYPE at 401 (error.rs): {refs_401:?}" + ); + let description = responses["401"] + .get("description") + .and_then(Value::as_str) + .unwrap(); + assert!( + description.contains("INVALID_OPERATION_TYPE"), + "the 401 description names both codes: {description}" + ); + } + + #[test] + fn batch_documents_no_unreachable_500() { + let registry = OperationRegistry::new(); + let spec = to_openapi(®istry).unwrap(); + let responses = responses(&spec, PATH_BATCH, "post"); + assert!( + !responses.contains_key("500"), + "PRJ-21: batch dispatch failures are in-band entries; no HTTP 500 exists to document" + ); + } + + #[test] + fn batch_result_entry_error_refs_a_defined_component() { + let registry = sample_registry(); + let spec = to_openapi(®istry).unwrap(); + let entry = &spec.raw["components"]["schemas"]["BatchResultEntry"]; + let error_ref = entry + .pointer("/properties/error/$ref") + .and_then(Value::as_str) + .expect("BatchResultEntry.error is a $ref"); + let target = error_ref.trim_start_matches("#/components/schemas/"); + assert!( + spec.raw["components"]["schemas"].get(target).is_some(), + "PRJ-16b: {error_ref} resolves inside components.schemas" + ); + } + + #[test] + fn batch_error_component_covers_protocol_and_operation_codes() { + let registry = sample_registry(); + let spec = to_openapi(®istry).unwrap(); + let batch_error = &spec.raw["components"]["schemas"]["BatchError"]; + let arms: Vec<&str> = batch_error + .get("oneOf") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|v| v.get("$ref").and_then(Value::as_str)) + .collect() + }) + .unwrap_or_default(); + for expected in [ + "#/components/schemas/CallErrorNotFound", + "#/components/schemas/CallErrorInternal", + "#/components/schemas/BatchOperationError", + ] { + assert!(arms.contains(&expected), "PRJ-16b: {expected} in {arms:?}"); + } + } + + #[test] + fn no_extension_key_inside_components_schemas() { + let registry = sample_registry(); + let spec = to_openapi(®istry).unwrap(); + let schemas = spec.raw["components"]["schemas"] + .as_object() + .expect("schemas object"); + for name in schemas.keys() { + assert!( + !name.starts_with("x-"), + "PRJ-23: {name} is not a schema name — extension keys are illegal inside components.schemas" + ); + } + } + #[test] fn protocol_error_component_schemas_pin_codes() { let registry = OperationRegistry::new(); From 60362c4d0f343d60eb5a8c42d75708a7aeaa0763 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 30 Aug 2026 22:56:53 +0000 Subject: [PATCH 3/4] docs(architecture): align http-adapters.md to_openapi section with the 1.3.0 projection - /call response map: 415 slot, identity-split 401 oneOf, PRJ-17 merge note, PRJ-19 shape-rejection note - /batch: no-500 statement (PRJ-21) and the BatchError in-band error component (PRJ-16b) - adapter.rs openapi.json test pin refreshed to 1.3.0 --- docs/architecture/http-adapters.md | 24 +++++++++++++++++------- src/server/adapter.rs | 5 +++-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/architecture/http-adapters.md b/docs/architecture/http-adapters.md index 2ead72e..648e43f 100644 --- a/docs/architecture/http-adapters.md +++ b/docs/architecture/http-adapters.md @@ -514,20 +514,24 @@ number: `to_openapi` projects `error_schemas` to the gateway endpoint's response definitions. The `/call` endpoint's responses are the shared components-referenced status map: the protocol-level errors, plus any -operation-level errors (keyed by the registry's declared `http_status`; -entries whose codes lack the `HTTP_` prefix carry +operation-level errors (keyed by the registry's declared `http_status`. +Entries whose codes lack the `HTTP_` prefix carry `x-runtime-behavior: 500` — the runtime mapper is purely code-driven, -see review-001 PRJ-04): +see review-001 PRJ-04. Declarations at a protocol status from +`HTTP_`-prefixed codes are **merged oneOf** into the shared +protocol response instead of overwriting it — the runtime emits both, +see review-002 PRJ-17): ```yaml # /call endpoint responses responses: '200': { $ref: '#/components/schemas/CallOk' } # envelope { request_id, result, output } '400': { description: plain-text extractor rejection (framework body) } - '401': { $ref: '#/components/responses/Unauthorized' } + '415': { description: plain-text extractor rejection (missing Content-Type, PRJ-19) } + '401': { oneOf: [CallErrorForbidden, CallErrorInvalidOperationType] } # identity split, PRJ-20 '403': { $ref: '#/components/responses/Forbidden' } - '404': { $ref: '#/components/responses/NotFound' } - '422': { oneOf: [CallErrorInvalidInput, CallErrorInvalidOperationType] } + '404': { $ref: '#/components/responses/NotFound' } # merged oneOf when ops declare HTTP_404 (PRJ-17) + '422': { oneOf: [CallErrorInvalidInput, CallErrorInvalidOperationType] } # + plain-text extractor shape rejection (PRJ-19) '429': { ... } # only when ops declare errors at 429; x-runtime-behavior: 500 for non-HTTP_* codes '500': { oneOf: [CallErrorInternal, CallFailure] } # INTERNAL + non-HTTP_* operation codes '503': { ... } # only when ops declare errors at 503 @@ -538,7 +542,13 @@ The operation-declared errors are surfaced on the `/call` and `/publish` endpoints' responses — the gateway projects the registered operations' `error_schemas` as response definitions. This makes the adapter contract from alkcall ADR-022 faithful on the error axis — no -silent dropping of error contracts. See alkcall ADR-016. +silent dropping of error contracts. The `/batch` endpoint documents no +HTTP 500 (review-002 PRJ-21): every per-entry dispatch failure is an +in-band `results[]` entry; the only HTTP error status is the +request-level cap failure (400). `BatchResultEntry.error` references +the `BatchError` component (review-002 PRJ-16b): the serialized +`CallError` as a oneOf over the six protocol-code envelopes plus a +generic arm carrying the operation-declared codes. See alkcall ADR-016. ## Why diff --git a/src/server/adapter.rs b/src/server/adapter.rs index c092d4d..72d684f 100644 --- a/src/server/adapter.rs +++ b/src/server/adapter.rs @@ -892,9 +892,10 @@ mod tests { let text = String::from_utf8_lossy(&response); assert!(text.starts_with("HTTP/1.1 200 OK"), "got: {text}"); assert!(text.contains("application/json"), "got: {text}"); - // The 6-endpoint gateway doc with the version from the /publish addition. + // The 6-endpoint gateway doc; the version tracks the projection + // truthfulness pass. assert!(text.contains("\"/publish\""), "publish path in doc"); - assert!(text.contains("1.2.0"), "info.version 1.2.0 in doc"); + assert!(text.contains("1.3.0"), "info.version 1.3.0 in doc"); assert!(text.contains("gatewayPublish"), "publish operationId"); let _ = server_task.await; From e98e495137dca740d22c482711860dd08a935414 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 30 Aug 2026 23:04:25 +0000 Subject: [PATCH 4/4] test(full_surface): refresh /openapi.json version pin to 1.3.0 --- tests/full_surface.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/full_surface.rs b/tests/full_surface.rs index 8941555..9edbf75 100644 --- a/tests/full_surface.rs +++ b/tests/full_surface.rs @@ -265,7 +265,7 @@ async fn full_surface_gateway_over_http() { .unwrap(); assert_eq!(resp.status(), 200); let body: serde_json::Value = resp.json().await.unwrap(); - assert_eq!(body["info"]["version"], "1.2.0"); + assert_eq!(body["info"]["version"], "1.3.0"); assert!(body["paths"].get("/publish").is_some()); assert!(body["paths"].get("/call").is_some()); }