Internal/ACL-restricted op specs were readable through
POST /call {"operation":"services/schema","input":{"name":...}}
(the MCP call/batch tools identically): the outer-name pre-checks pass
(services/schema is External) and alkcall's services_schema_handler
projects any registered spec with no visibility/ACL check of its own
(alkcall CF-004 is the complete fix there).
- GatewayDispatch.invoke/invoke_streaming now apply the GET /schema
route's is-internal + access-control checks to the meta-op's inner
name input before dispatch (404 Internal / FORBIDDEN ACL), one
interception point covering /call, /batch, /subscribe and the MCP
call/batch tools; /publish cannot reach the Query-typed meta-op
- the visibility+ACL check is one shared fn (schema_disclosure_denial)
used by the HTTP /schema route, the dispatch guard, and the MCP
schema tool, so transports cannot drift
- when CF-004 lands, this guard remains as defense-in-depth (ADR-071)
Tests: dispatch-spine guard unit tests; /call 404 + 401/403 matrix,
/batch NOT_FOUND entry, /subscribe error event; MCP call/batch tools
via services/schema with an Internal inner name (mcp feature).
Verify: cargo test (405), --all-features (523), clippy default and
--all-features --all-targets -D warnings, fmt --check — all pass.
GW-16: empty body / malformed first line / missing header fields /
per-line cap / batch over-cap rejections now route through
call_error_to_http_response_with_identity, mapping INVALID_INPUT to
422 — same status as mid-stream chunk errors. One error class, one
status.
GW-17: invoke_sink wraps the registry sink invoke in the same 30 s
tokio::time::timeout the Once-op invoke uses; a hung sink handler
surfaces as a TIMEOUT (504, retryable) error envelope instead of
holding the HTTP connection forever. The sink wrapper bounds the
whole dispatch (chunk pacing included), matching http-server.md's
deadline contract.
Docs: http-server.md error table documents the 422 triggers and the
sink deadline; http-adapters.md batch cap status corrected.
to_openapi: gateway spec version 1.3.0 -> 1.4.0 (ADR-045 minor):
/publish framing faults and /batch cap reject documented at 422 (the
400 slots moved with the runtime); /publish 400 slot removed; 504
description covers the sink dispatch.
Verification: scripts/verify.sh OK (397 tests); cargo test
--all-features OK (513 tests); clippy --all-features --all-targets -D
warnings OK; cargo fmt --check OK.
- assemble_request_url now verifies after Url::set_path that the decoded
URL path segments are byte-identical to base_dir + decoded rendered
segments; a mismatch (any future normalizer rewrite in the url crate)
fails loudly with INTERNAL instead of silently re-routing an
authenticated request
- property-style corpus test over dot/percent/binary values: accepted
values must survive byte-identical as one literal segment with no lone
dot segments; failures may only be INVALID_INPUT
Verification: scripts/verify.sh (383 passed) and --all-features (499
passed), clippy -D warnings, fmt --check all pass.
- value_to_path_segment now rejects scalar values whose decoded form is
exactly '.', '..', '%2e', '%2E', '%2e%2e', or '%2E%2E' (case-insensitive)
with INVALID_INPUT: url 2.5.8 Url::set_path silently normalizes lone
dot segments away, so such values would route to a different upstream
endpoint than the template describes, with namespace credentials attached
- rejection is exact-match on the full decoded segment: dotted values
like v1.2.3, .hidden-file, ..hidden, ... still render
- error names the failure mode but never echoes the raw value
- empirically pins the set_path normalization behavior in a test
(tenants/../resources -> /resources, /files/.. -> /, %2E%2E -> normalized)
Verification: scripts/verify.sh (382 passed) and --all-features (498
passed), clippy -D warnings, fmt --check all pass.
The FWD-08 remediation made every malformed credential fail loudly, but
an authed operation whose registry capability was entirely absent fell
through the build_request match: the request was sent with no credential
and no diagnostic, producing corrupted upstream 401s at call time.
- build_request now returns an INTERNAL error naming the missing
capability keys (api_key:{ns} / http_token:{ns}) when an auth scheme
is declared and Capabilities::get is empty; the request is not sent
- auth_scheme: None behavior unchanged (unauthenticated ops stay
unauthenticated); error message carries key names only, no secret
- module doc: loud-missing matrix now covers malformed name/value AND
absent capability
- tests: unit loud-error across all three schemes, unchanged-arm pin,
wire test asserting the upstream receives zero requests (mirrors the
FWD-08 test family)
- from_openapi/from_jsonschema no_env_vars tests updated: they pinned
the old silent fall-through; still assert no env material echoes
Verification: cargo test (380 passed), cargo test --all-features
(496 passed), clippy --all-targets -D warnings (default + all-features),
cargo fmt --check — all via scripts/verify.sh
- 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_<code> 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)
- enforce the same 100-operation cap the HTTP /batch endpoint enforces;
over-cap \x60calls\x60 reject with a structured INVALID_INPUT (retryable:
false, matching CallError::invalid_input) before any dispatch
- hoist MAX_BATCH_OPERATIONS to gateway/mod.rs and reuse it in routes,
to_openapi (removing a pre-existing duplicate literal), and to_mcp
- state the limit in the batch tool description and add maxItems to the
input schema (doc previously advertised no limit)
- GatewayDispatch gains a per-instance invoke_count spy accessor so the
over-cap test proves zero dispatches (process-global counters raced
under the parallel test runner)
- tests: over-cap -> INVALID_INPUT + invoke_count()==0; at-cap -> 100
results + invoke_count()==100
verification: scripts/verify.sh (352 passed) and scripts/verify.sh
--all-features (468 passed); cargo clippy --all-targets -D warnings and
cargo fmt --check clean
Coverage-confirmed dead code (every binary, zero hits):
- server/state.rs: drop FromRef<RouterState> impls for
Arc<OperationRegistry> and Arc<dyn IdentityProvider> — no route
extracts these types; the auth middleware receives the provider
directly via from_fn_with_state
- gateway/dispatch.rs: drop identity_provider() and resolve_bearer()
accessors; resolve_bearer's doc promised an auth hook the middleware
never calls (spec/code drift). Wire-or-delete resolved to delete:
bearer resolution lives in the middleware (SRV-11 single-resolve
ordering), the dispatch spine only needs the per-call
Option<Identity>. GatewayDispatch::new consequently takes the
registry alone (GatewayState loses its unused identity_provider
passthrough; dispatch.rs/to_mcp.rs tests simplified)
- websocket/upgrade.rs: drop FromRef<SessionState> for
Arc<OperationRegistry> — no router carries SessionState as its state
type; the inverse FromRef<Arc<OperationRegistry>> for SessionState
(custom upgrade routes, integration tests) remains
Verification: ./scripts/verify.sh (352 passed), ./scripts/verify.sh
--all-features (466 passed), clippy -D warnings, fmt --check.
from_yaml previously went straight yaml_serde::from_str::<serde_json::Value>
with zero post-parse normalization. Three verified corruptions flowed
through unimpeded:
- duplicate mapping keys silently last-won (serde_json's visit_map
insert semantics — verified against 1.0.151: the JSON path last-wins
too, so the YAML path is now deliberately the stricter one);
- .inf/-.inf/.nan scalars silently became Value::Null because
serde_json::Number::from_f64(non-finite) is None;
- YAML 1.1 merge keys (<<: *anchor) survived as literal '<<' properties;
- non-string mapping keys (null keys, collection keys) either stringified
through YAML's debug rendering or panicked the conversion.
The new from_yaml pipeline: explicit yaml_serde::Value parse (native
loud duplicate-key rejection with line/column) -> apply_merge() (merge
keys applied, shallow per yaml_serde semantics; scalar/invalid merge
values fail loudly) -> one structural normalization pass into
serde_json::Value that rejects non-finite floats and non-string keys
with the offending JSON pointer, and stringifies scalar keys exactly as
the YAML 1.2 core schema renders them (200: -> "200", matching the
JSON path's {"200": ...}).
The walk stays inside yaml_serde's own parse-time bounds (recursion
limit 128, alias jump limit, RepetitionLimitExceeded); no new budgets
and no resolver changes. 14 seam tests added at the from_yaml boundary.
Verification: cargo test 366 passed / 0 failed; clippy
--all-targets -D warnings clean; fmt --check clean (scripts/verify.sh).
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).
The review-001 CON-02 monitor swept the pending map once EOF held but
never exited: every fire-and-forget import whose peer died left a
spawned task + pending map + watch receiver alive for the process
lifetime, and once the read pump ended the watch sender dropped so the
old select's 'changed()' error branch spun.
- replaced the 1s never-exiting sweep with a 50ms post-EOF drain
(fast-fail) and a bounded grace window: the monitor ends after
PENDING_SWEEP_MAX_POST_EOF (8) consecutive empty drains (~400ms)
- post-EOF registrations now resolve in ~50ms with retryable
CONNECTION_CLOSED instead of waiting up to 1s for the next sweep
- removed the silent busy-spin: the loop now only wakes on the sweep
tick or the session-close signal (close path still fails all + exits)
- retained the monitor JoinHandle on WssDropMonitor (test builds) and
added a lifecycle test asserting the monitor joins after EOF+grace
- tightened call_registered_after_eof test: 500ms bound + CONNECTION_
CLOSED code assertion (was a pre-sleep + 5s timeout)
Verification:
- cargo test --features wss: 358 passed, 0 failed
- cargo test --all-features: 454 passed, 0 failed
- cargo clippy --all-targets -- -D warnings (default, wss, all): clean
- cargo fmt --check: clean
- scripts/verify.sh + verify.sh --all-features: VERIFY OK
The eager placeholder-binding check in spec_name_references_undeclared
short-circuited on an empty properties set, letting
FromJsonSchema::new accept a template whose placeholder could never be
bound (every later call failed INTERNAL, or INVALID_INPUT if a peer
supplied the key). Drop the early return so any placeholder against a
declared-nothing (or properties-less) input_schema is unbound, matching
the from_openapi unbound_placeholders path.
- add construction-time test: no-properties schema + {id} template
fails with the placeholder error; no-properties schema + placeholder-
free template still constructs and imports as Visibility::Internal
Verification: scripts/verify.sh (342 passed), scripts/verify.sh
--all-features (454 passed); clippy -D warnings and fmt --check clean
Module doc gains three sections: the FWD-18 routing rule (placeholder
keys never double-emit as query; structural path values are
INVALID_INPUT), the FWD-19 percent trade-off (% in values always
encoded; % in assembly-supplied template/base text survives verbatim —
documented instead of rejected, per the ADR-066 assembly trust
boundary), and the FWD-17 streaming payload contract. FWD-19 behavior
pinned by a wire-level template test.
The routing half of FWD-18 was already in place (placeholder keys skip
query routing before value-shape inspection); this pins it with a test
and decides the structural-value outcome: an object/array value under a
placeholder key now fails with INVALID_INPUT instead of splicing the
minified JSON into the path segment. Scalars (string/number/bool/null)
render as before.
Option (b) of the FWD-17 decision: payloads decode as JSON when valid
(number stays number, quoted string stays string); non-JSON payloads
surface as {"data": <raw>, "event": <name|null>} instead of
silently degrading to a JSON string. The parser captures the frame's
event: field (WHATWG last-wins) and resets all pending state on every
blank-line dispatch, so an event:-only frame (no data) cannot leak its
name into a later frame. JSON frames surface as themselves even under
a named event; the implicit 'message' default never wraps a payload.
Verification: cargo test (33 forward tests incl. 4 new FWD-17
contract/parser pins), clippy, fmt.
- path-item-level `parameters` parse into PathItem and merge into every
operation's input schema; operation-level entries override shared
name+in duplicates (last-insert wins)
- success sweep accepts `2XX` after concrete 2XX keys and before
`default` (SSE detection + output schema; concrete outranks wildcard)
- `4XX`/`5XX` error keys project to their class representative status
(`HTTP_400`/`HTTP_500`) instead of dropping silently; `default`
still drops loudly (no implied range)
- top-level `webhooks` fails import naming the feature (inbound
callbacks are outside the single-endpoint outbound adapter model)
- unbound-placeholder error names the parameter-merge state so the
diagnosis no longer dead-ends
Verification: cargo test (174 lib tests), cargo fmt
FWD-15: forward_stream now sends through SharedHttpClient::stream_client
— a client derived from the same config with the total request timeout
removed and connect + read timeouts retained. reqwest 0.13's per-request
override can lengthen a client-level total timeout but never clear it
(request-scoped None falls back to the client default), so the derived
client is the only correct mechanism. Both clients rebuild-and-swap
together atomically (FWD-12). A healthy >30s subscription survives; the
read timeout stays as the staleness guard, matching the gateway's
deadline: None dispatch contract (alkcall ADR-021).
FWD-14: the streaming branch enforces a total streamed-bytes cap per
subscription (HttpClientConfig::stream_total_byte_cap, default 1 GiB),
accumulated across every chunk fed to the SSE parser; exceeding it
terminates with a single terminal HTTP_413 error envelope. The SSE
line-cap check moved before extend_from_slice so the reassembly buffer
can never exceed the cap. Removing the total timeout without this cap
would open an unbounded-memory window, so both land together.
Wire tests: keepalive trickle past a scaled total-timeout deadline keeps
delivering; over-cap stream terminates with exactly one terminal error;
parser boundary tests for pre-extend cap checks.
Verified: cargo test (302+5), --all-features (373+41), clippy
--all-targets -D warnings (default + all-features), fmt --check,
doc --no-deps clean.
Replace rmcp's unbounded Peer::list_all_tools with a bounded walk over
list_tools: hard cap of 100 pages (MCP_MAX_TOOLS_LIST_PAGES) plus a 60 s
overall deadline (MCP_TOOLS_LIST_DEADLINE). Tripping either budget fails
loudly with AdapterError::DiscoveryFailed naming pages fetched and tools
accumulated — no silent truncation, no partial registration (import
fails closed, as before). A slow or hung page is cut off by a per-page
tokio timeout sized to the remaining budget.
Bounds are documented in the module doc. Integration test added: a
cycling-cursor server (next_cursor always Some("a")) terminates with
the clean budget error inside an outer 10 s guard; the existing 3-page
pagination test is unchanged and passes.
Verification: cargo test (304 pass), cargo test --features mcp,
cargo test --all-features (412 pass), clippy -D warnings both default
and --all-features --all-targets, cargo fmt --check, cargo doc --no-deps.
- build_error_schemas: default/wildcard response keys dropped with a
warn instead of emitting a dead HTTP_0 ErrorDefinition — /search
never advertises a code that can't match (the runtime mapper already
synthesizes HTTP_<actual> for unmapped statuses)
- check_parameter_style: non-default style/explode parameter forms
(spaceDelimited, pipeDelimited, deepObject, form+explode:false,
simple+explode:true) fail import with a feature-naming SchemaParse;
wire-equivalent defaults (form, simple) import unchanged — no more
silent "[1,2]" array mis-serialization
- servers overrides rejected at import at all three levels (document,
path, operation) — the adapter pins one base_url at assembly time
- trace-only paths: skip is now logged (warn naming path + methods),
documented-as-inert instead of silent
- detect_op_type + build_output_schema sweep 2XX/default keys for
text/event-stream — a default-declared SSE stream classifies as Sub
instead of returning one giant text body
Tests: 11 new (error-drop, style rejections + default accept, servers
3-level rejections + baseline, trace skip, SSE default/2XX detection).
Verified: cargo test (299), --all-features (370 + suites), clippy
--all-targets -D warnings (default + all-features), fmt --check.
Tasks: review-001-openapi-loud-degradation
- build_request takes the op's input_schema and rejects undeclared
input keys (INVALID_INPUT) before any outbound request is built;
explicit `additionalProperties: true` opts into catch-all input;
non-object inputs rejected (OAI-02)
- in: header parameters are stamped `wire: header` in the generated
input schema and sent as upstream request headers, not query params;
in: cookie fails import with a clear error (OAI-03)
- a spec parameter named `body` is rejected at import unconditionally
(OAI-07)
- FromJsonSchema::new returns Result and validates method/path template/
base_url at construction; registered visibility forced to Internal
like from_openapi; module doc corrected (OAI-09)
Verified: cargo test, cargo test --all-features, clippy (both feature
sets, -D warnings), cargo fmt --check
- /search, /schema: document the envelope wrapper and the real item/spec
fields (PRJ-01/02); /search drops unreachable 401/403, documents 404
(PRJ-15)
- error statuses: 422 for dispatch-path INVALID_INPUT /
INVALID_OPERATION_TYPE; extractor 400s documented as the plain-text
gap they are (PRJ-03); operation-declared errors projected by
http_status with x-runtime-behavior: 500 on non-HTTP_* codes (PRJ-04
project-honest decision) — no runtime changes
- /subscribe: 200+SSE only; event:error terminal contract documented
(PRJ-05, GW-12)
- components for requests/responses; CallRequest no longer inlined
per-path (PRJ-14); all library expect() paths removed (PRJ-11)
- error projections folded into BTreeMaps: same registry =>
byte-identical doc, sorted enums (PRJ-12)
- components.securitySchemes.bearerAuth + top-level security (PRJ-15)
- info.version 1.1.0 -> 1.2.0 (ADR-045 minor: additive documentation of
the settled runtime contract)
- 31 unit tests incl. golden print-level assertions mirroring the routes
tests' actual bodies and a determinism test
Verification: cargo test (288), clippy --all-targets -D warnings, fmt
--check, cargo doc --no-deps, cargo test --all-features (all green in a
clean worktree at HEAD; shared tree carries parallel agents' edits).
- index components/parameters + requestBodies in OpenAPISpec; resolve
bare $ref parameter and requestBody entries through the cycle-guarded
resolver; unresolvable refs abort spec parse loudly
- reject path placeholders with no matching input-schema property at
registration (no more silently percent-encoded literal placeholders)
- reject duplicate operationIds and path+method routes in one import
batch instead of silent last-write-wins registration
- also reject a parameter named 'body' shadowed by requestBody (OAI-07
adjacency, same code path)
Verified: cargo test (243), --all-features (322), clippy -D warnings
(default + all-features), fmt --check, doc --no-deps
- percent-encode path-parameter values with a WHATWG path-segment
superset (/, %, ?, #, \\, controls): traversal values, query/fragment
structure, and later-placeholder strings can no longer alter the
request line (FWD-01)
- single-pass template rendering; rendered values are never
re-substituted; unbound or unterminated placeholders error loudly
- append the request path to the base URL directory (https://host/v1
+ /chat/completions keeps /v1) instead of Url::join semantics,
with a post-assembly origin-equality check (FWD-02)
- base_url validation: https/http-only scheme allowlist, explicit
host required, userinfo rejected (credentials flow via
Capabilities only); request_path is never empty
Verification: cargo test (238 lib tests incl. 8 new FWD-01/02 tests),
cargo clippy --all-targets -- -D warnings, cargo fmt --check
- Replace the axum/tungstenite pump paths' Notify-based read-EOF signal
with a retained tokio watch channel: a late subscriber (monitor
spawned after session setup, or pump EOF before the receiver is
taken) still observes EOF (WS-02).
- from_wss drop monitor: on EOF (or session close) fail all pendings
retryable, then keep sweeping the pending map every 1 s — calls
registered after the initial fail_all (the forgotten-session import
path) resolve instead of hanging (CON-02).
- Tests: drop-during-registration race variants (forget + held
session) and a post-EOF registration resolved via the sweep; the
existing no-hang test stays green.
cargo test (219), cargo test --features wss (231, 3x for flake check),
cargo clippy --all-targets -- -D warnings, cargo fmt --check
Replace the axum/tungstenite pump paths' Notify-based read-EOF signal
with a retained tokio watch channel so a late subscriber observes EOF
regardless of when it fired. Extend the from_wss drop monitor to sweep
the pending map (1 s interval) once EOF is observed, so calls
registered after the initial fail_all also resolve retryable instead
of hanging.
cargo test; cargo clippy --all-targets -- -D warnings (default +
all-features); cargo fmt --check