Files
alkhttp/docs/reviews/001-initial-implementation-review.md
T
glm-5.3-flash 54f8e2310e docs(review 001): initial implementation review — findings, coverage, remediation plan
Consolidated review of the 17-task initial implementation (tree 4a825d3):
8 subsystem passes + cargo-llvm-cov coverage analysis. Baseline: 256
tests green, clippy/fmt/doc clean, 93.86% line coverage.

Findings: 1 borderline-critical ($ref recursion aborts the process),
~30 major (extra_routes mounted without auth middleware, /schema leaks
Internal ops, /mcp unbounded body, forwarding URL construction/SSRF,
redirect credential leakage, non-idempotent retries, no default
timeouts, SSE chunk-boundary event loss, MCP schema ACL skip, from_wss
lost-EOF hang, projection-vs-runtime fidelity), and ~45 minor. Includes
verified-solid list, coverage gap analysis, and a 7-unit remediation
plan.
2026-08-28 17:22:46 +00:00

73 KiB
Raw Blame History

Review 001 — Initial Implementation Review (the 17-task build)

Status

Verified, open for remediation.

Scope

Consolidated code review of the full initial implementation of alkhttp — the 17-task build that landed the server foundation, the WebSocket byte-adapter and channels session, the gateway (dispatch + 6 routes + publish), the reqwest client host, and the five adapters (from_openapi, from_jsonschema, from_wss, from_mcp, to_openapi, to_mcp) — produced against the tree at 4a825d3 ("full-surface integration suite + docs sync + publish prep").

The review consolidates eight passes (one per subsystem: server core, WebSocket, gateway, client host + forwarding core, from_openapi/from_jsonschema, to_openapi/to_mcp, from_wss/from_mcp, and a cross-cutting hygiene pass) plus a cargo-llvm-cov coverage analysis. The most consequential findings (auth layering, /schema visibility, forwarding URL construction, SSE parsing, Notify semantics) were re-verified directly in source and, where noted, empirically with scratch harnesses outside the repo. Findings that are cross-crate (alkcall-side) are flagged as such.

Baseline verification (this pass)

cargo test --all-features            → 256 passed, 0 failed (227 unit + 29 integration)
cargo clippy --all-features --all-targets -- -D warnings → clean
cargo fmt --check                    → clean
cargo doc --no-deps                  → 0 warnings
cargo check --no-default-features    → clean
cargo llvm-cov --all-features        → 93.86% lines, 91.75% functions, 94.23% regions

The suite is green and the coverage number is high, but the integration tests all exercise happy paths over real I/O; the gaps are at the edges (auth layering on merged routers, malformed/hostile upstreams, error paths, projection-vs-runtime fidelity). That is exactly where the findings below live.

Verdict

  • The architecture held. The gateway pattern (fixed endpoints, no per-op surface), the no-env-vars credential invariant, the Internal-by-default privilege model, the HTTP_ error-code discipline, the WS byte-adapter framing, and the feature matrix are all implemented as specified and largely well tested. There is no "cannot function end-to-end" defect of the kind alkcall Review 001 found (P-01/C-01); the happy paths are genuinely exercised by integration tests over real sockets.
  • The defects cluster in four places: (1) auth/visibility at the seamsextra_routes mounts without the bearer middleware, /schema leaks Internal ops, and the MCP schema tool skips the per-op ACL check; (2) the outbound forwarding core — URL construction (unencoded path params, Url::join semantics, no host validation), cross-host redirect credential leakage, retries on non-idempotent methods, missing timeouts, and an SSE parser that silently drops events at TCP chunk boundaries; (3) projection fidelity — the to_openapi document describes responses the gateway does not emit; (4) WS/adapter robustness edges — a lossy EOF notification that can hang from_wss calls forever, head-of-line stalls, and uncapped inbound buffering multipliers.
  • One borderline-critical defect: from_openapi stack-overflows the process (abort, not a catchable panic) on valid, common, self-referential OpenAPI specs.

Severity legend

  • [critical] — the protocol cannot function end-to-end as committed; or a decided spec invariant is violated in a way that corrupts data or causes silent permanent state damage.
  • [major] — a decided behavior is missing, wrong, or a real reliability hazard; works in the happy path but fails a spec-required edge case, or is a realistic security weakness.
  • [minor] — drift, convention violation, dead code, or a doc/spec inconsistency with no correctness impact.

Part A — Server core (adapter, auth, decoy, state)

SRV-01 [major] — extra_routes are mounted without the bearer-auth middleware

ADR drift: ADR-046 §4 ("Custom routes carry the same auth middleware by default; per-route opt-out is the deployment's choice"). Verified: YES (empirically). src/server/adapter.rs:170-183 — the bearer_auth_middleware is applied via route_layer before extra routes are merged. axum's route_layer wraps only routes registered before the call (verified against axum 0.8.9 path_router.rs and with a scratch harness replicating build_router's shape: the middleware ran only for default-surface routes; an extra-route handler saw no stashed identity).

Amplifier: ResolvedIdentity extraction never fails (src/server/auth.rsRejection = Infallible), so a custom-route handler using ResolvedIdentity compiles and silently receives None on every request — indistinguishable from "caller sent no token". If a custom handler treats None as anonymous-allowed, that is an unauthenticated access path; at minimum the documented default (auth applied) is not the implemented default. Fix: merge extras first (or apply the layer after the merge), so the documented default holds and opt-out remains the deployment's explicit choice.

SRV-02 [major] — /schema discloses the full spec of Visibility::Internal operations

ADR drift: ADR-015 §2 / http-server.md ("Internal operations return 404 … an HTTP client cannot stub its toe on a path for an operation it can't call"). Verified: YES. src/gateway/routes.rs:120-137schema_handler runs only access_check_for_op; it never applies the is_internal_op pre-check that /call (:99), /batch (:147), /subscribe (:164), and /publish (:240) all apply. Internal ops carry AccessControl::default() (no restrictions), so the pre-check passes even for an unauthenticated caller, and the services/schema handler returns the full spec (visibility, input/output/error schemas, access_control) for any registered name. POST /call on the same op correctly 404s (asserted in tests/full_surface.rs). One unauthenticated GET /schema?name=… returns the internal op's contract, defeating the invisibility invariant on the discovery axis and bypassing the per-caller /search surface. There is no test covering /schema on an internal op.

SRV-03 [major] — /mcp reads the request body with no size limit (unbounded memory)

Verified: YES (mcp feature only). src/server/adapter.rs:141-150 nests rmcp's StreamableHttpService; the bearer middleware only stashes identity. axum's 2 MiB DefaultBodyLimit applies to axum extractors; the nested rmcp service consumes the raw body via body.collect().await with no cap (verified against rmcp 1.8.0 server_side_http.rs), and hyper imposes no body limit. A single POST /mcp with a multi-GB chunked body is buffered entirely in memory; a handful of concurrent requests OOMs the process. The gateway routes themselves are correctly capped at 2 MiB (axum extractors). Fix: wrap the nest with an explicit DefaultBodyLimit (or a body-limit layer) sized for MCP.

SRV-04 [major] — No read/idle timeouts on the hyper connection driver (slow-loris surface)

Verified: YES. src/server/adapter.rs:217-224HyperBuilder::new(TokioExecutor::new()) sets no timer, no header_read_timeout, no keep-alive/h2 keep-alive knobs. hyper's h1 header_read_timeout (30 s default) is silently ignored without a Timer (verified against hyper 1.11 common/time.rs: Time::Emptywarn! + None). A client that opens a connection and dribbles partial headers parks a task + buffers indefinitely; each HttpAdapter::handle admits one such connection and there is no concurrency cap in this crate (the accept loop is the consumer's). Mitigation is partly the transport layer's job, but this crate configures none of the knobs hyper provides.

SRV-05 [minor] — with_decoy consumes extra_routes via .take()

Verified: YES. src/server/adapter.rs:104build_router(state, self.extra_routes.take()) leaves self.extra_routes == None; a subsequent .with_decoy(d2) rebuilds the router without the custom routes, silently. The builder state and the live router diverge. Should be a clone (or build-once semantics with an explicit error).

SRV-06 [minor] — RESERVED_PATHS is exported but never enforced; per-method merges leak through

Verified: YES. src/server/adapter.rs:38-49 — the constant has no reader outside its re-export. Actual collision behavior: same-method overlap panics in axum's merge (sanctioned by ADR-046 §3's "construction panics/warns"), but a different method on a reserved path (e.g. custom POST /search) silently merges in — served on a reserved path, and (per SRV-01) without auth middleware. The "default wins" claim of ADR-046 §3 is false for the per-method case, and the exported constant doesn't deliver the contract its export implies.

SRV-07 [minor] — Stealth-mode fidelity gap: 405 responses bypass the decoy

Verified: YES (empirically). The decoy is only the router fallback (src/server/decoy.rs:24-43); a request that matches a route but not a method (e.g. OPTIONS /search, DELETE /healthz) returns axum's bare 405 — no body, no Server: nginx header — so a single probe distinguishes alkhttp from nginx in decoy deployments. Real nginx sends its Server header on 405s.

SRV-08 [minor] — Decoy static-server path decoding bugs

Verified: YES. src/server/decoy.rs:106-127 — in a URI path + is a literal plus (only query strings form-decode it), but percent_decode maps it to space (a file named a+b.html becomes unreachable); and percent-decoding maps each decoded byte to char, so %C3%A9 yields mojibake (é) instead of é — any non-ASCII filename is unreachable. No traversal impact (the Component walk at :83-94 rejects ..; also verified %00 fails the read). Related, same file: resolve_static_path uses blocking is_dir()/is_file() syscalls on the async path (:97-101) — a convention violation; the read itself correctly uses tokio::fs::read.

SRV-09 [minor] — /openapi.json error path echoes internals and rebuilds the doc per request

Verified: YES. src/server/adapter.rs:250-254 — 500 body is format!("failed to serialize gateway spec: {e}") (raw serde error echoed to unauthenticated callers), and the handler rebuilds + serializes the whole projection on every request (no caching) — cheap DoS amplification. Companion finding: src/adapters/to_openapi.rs:67 uses .expect("to_openapi always emits a valid OpenAPI document") on this request-triggered path — the only unguarded expect reachable from a route handler (convention violation; also T-11).

SRV-10 [minor] — WS upgrade hardcodes NoCap; doc claims deployers can pass a stricter policy but there is no injection point

Verified: YES. src/websocket/upgrade.rs:28-29 says the assembly layer can pass a stricter channel policy, but :120 hardcodes Arc::new(NoCap) and HttpAdapter offers no parameter. alkcall documents NoCap as the explicit opt-out (the default is 256/identity). One authenticated WS client can open unbounded data channels on one connection. Minor (post-auth), but the comment misleads and the lever is missing. Fold-in: the WS upgrade route resolves the bearer token twice (router-wide bearer_auth_middleware + ws_bearer_auth) — harmless today, but a provider with token-use side effects double-counts.


Part B — WebSocket subsystem (byte adapter, upgrade, session)

WS-01 [major] — Single demux loop: one dribbled chunk stalls all channels indefinitely

Verified: YES. The WS read task hands whole messages to AsyncRead (src/websocket/byte_adapter.rs:110-126); alkcall's demux then does read_exact on header + payload with no read timeout, and route_payload awaits the per-channel bounded sender. A peer sends header [ch=0][len=16 MiB] then dribbles one byte per minute: the server holds the 16 MiB allocation forever and every outstanding channel-0 call/subscription on that connection hangs — Once-calls only at the 30 s sweeper, Sub/Pub pendings (registered timeout: None on the client side) hang until the socket dies. Memory is bounded; availability is not. There is no read-idle timeout anywhere on the WS path. Fix: an idle timeout on the WS read (close with 1001 on staleness) bounds the stall; consider it a deployment knob.

WS-02 [major] — Notify::notify_waiters is lossy — read_eof can be missed, in-flight calls hang forever

Verified: YES. src/websocket/byte_adapter.rs:138-139 (and :319 for the tungstenite twin) fires read_eof.notify_waiters(), which wakes only already-registered waiters and stores no permit. The consumer (src/adapters/from_wss.rs:156-166) spawns its drop-monitor after session setup; if the read task hits EOF before the monitor first polls Notified, the signal is lost. Because import() does std::mem::forget(session) (from_wss.rs:193), the close_rx fallback never fires either → the monitor never runs fail_all → imported-op calls in flight hang (Once-calls recover only at the 30 s sweeper, if a sweeper runs at all on this path — see CON-02; Sub/Pub pendings hang forever). The module doc at from_wss.rs:111-113 promises the opposite. Fix: a watch channel / CancellationToken / permit-storing notify_one, or a checked AtomicBool.

WS-03 [major] — No data-channel wiring: ADR-067's browser data channels are not implemented

ADR drift: ADR-067 (websocket.md §"Data channels for browsers"), ADR-048's connection-local overlay contract. Verified: YES. src/websocket/upgrade.rs:40,62-98 hands the base registry to install_channel_zero and runs Dispatcher::run_loop_single_stream over it; there is no ChannelCore/register_openable/ChannelOperations wiring anywhere in src/ (grep-verified). A browser can never open a data channel — the exact capability ADR-067 says the channels design exists to provide; and ADR-048's bidirectionality (hub calls browser-registered ops via the connection-local overlay) is unused. This may be a deliberate v1 cut (the WS tasks scoped channel-0 dispatch only), but the spec promise and the implementation have not been reconciled in writing — either wire it or file the OQ / amend the ADR. (Related: the overlay tests in tests/ws_overlay_ops.rs cover the dispatcher's overlay mechanics over duplex, not browser-opened data channels.)

WS-04 [minor] — Write-side chunk parser does no length validation

Verified: YES. src/websocket/byte_adapter.rs:161-163 (twin at :343-345) parses the outbound length field with no MAX_CHUNK_LEN check and no error path. Today the only producer is alkcall's mux (capped at 16 MiB per payload), so the parse always re-syncs; but if any future producer writes non-chunk-framed bytes, the parser silently waits to accumulate 8 + len (up to ~4 GiB) from misaligned offsets — permanent silent corruption plus a huge pending allocation. A one-line validation that fails the stream would make the invariant loud.

WS-05 [minor] — Write-task pending buffer is unbounded in bytes (~1 GiB worst case per connection)

Verified: YES. byte_adapter.rs:143,155 — slot count is bounded (64 WriteMsgs) but bytes are not; each message is up to 16 MiB, so a slow WS sink plus a large streaming publish buffers up to ≈64 × 16 MiB plus a partial chunk before backpressure engages. Steady state is small; the bound is worth a byte cap since pending is the only unbounded accumulator on the write path.

WS-06 [minor] — Inbound per-connection memory bound is 64 slots × 64 MiB ≈ 4 GiB

Verified: YES. READ_SLOTS = 64 bounds messages, not bytes (byte_adapter.rs:56); the plan's "~1 MiB" cap applies only to the write side (WS_MESSAGE_CAP). Inbound WS messages are capped only by axum/tungstenite's default max_message_size (64 MiB); neither WebSocketUpgrade::max_message_size nor max_frame_size is configured. A peer flooding 64 MiB binary messages pins up to ~4 GiB whenever the demux drains slower than the socket delivers (e.g. during the WS-01 stall). Set an explicit message-size cap consistent with the plan.

WS-07 [minor] — poll_shutdown does not close the WS sink; the documented close mapping is not the behavior

Verified: YES. byte_adapter.rs:269-276 vs the module doc at :25-28 — poll_shutdown drops a fresh clone of write_tx while the original stays in the struct, so the channel never closes and the trailing ws_sink.close() doesn't run at shutdown time. In the server path the teardown cascade still completes (traced end-to-end), so REQ-CH-01 sentinels do flush, but (a) the Close frame is deferred to an unrelated cascade step, and (b) any consumer that calls shutdown() and keeps the stream alive (the from_wss path does — it forgets its session) never emits a WS Close frame at all. The comment describes intent, not behavior.

WS-08 [minor] — _pumps detached in the server session; no forced-teardown lever

Verified: YES. upgrade.rs:36 drops WsPumps immediately — fine in the normal path (cascade traced), but WsPumps::abort() (the documented "forced teardown" lever) is never callable on the server path, so a stuck session (WS-01's dribble) can only be evicted at the TLS/socket layer outside this crate.

WS-09 [minor] — No per-connection or global cap on WS sessions

Verified: YES. The upgrade route has bearer auth (good) but no concurrency limit, no idle timeout, and each session holds ≥ 4 spawned tasks plus buffers until the socket dies. Post-auth DoS only, but the assembly layer cannot add a cap because the built-in route is built inside HttpAdapter (extra routes only add routes; middleware can't wrap this route from outside). A semaphore in ws_upgrade_handler is the cheap lever.

WS-10 [minor] — Dispatcher/mux tasks outlive a failed session task

Verified: YES. upgrade.rs:65-97 spawns the dispatcher loop detached; if the session future is dropped/aborted, the spawned tasks keep the ChannelManager/MuxHandle alive, which keeps the writer (and the WS sink) alive — the socket then closes only when the peer goes away. Self-healing in practice; a leak window tied to peer behavior.

WS-11 [minor] — ~60 lines of pump logic duplicated between the axum and tungstenite paths

Verified: YES. byte_adapter.rs:119-180 vs :299-364 — read task, write task, and close handling are copy-pasted with only the message enums differing, while the module doc claims "one implementation, both directions" (:30-31, :279-284). Any fix (WS-04, WS-06) must be applied twice; factor the pump bodies over a generic sink/stream of messages.

WS-12 [major, cross-crate] — alkcall demux TooLarge skip allocates up to 4 GiB from an 8-byte peer header

Location: alkcall src/channels/adapter.rs:144-148 (let mut discard = vec![0u8; length as usize];). Verified: YES. Any header with length > 16 MiB reaches this arm; the skip buffer is allocated before reading. Via the WS path this is trivially reachable by any authenticated browser (8 bytes of header); K connections × [len = 0xFFFFFFFF] + dribble → OOM. Correct sync recovery (verified by alkcall's own resync test), wrong memory shape — stream-skip with a bounded buffer. Not an alkhttp defect, but it detonates through byte_adapter.rs's read path; fix on the alkcall side.


Part C — Gateway (routes, dispatch, error)

GW-01 [major] — /publish skips publish_schema validation that the wire path enforces

Verified: YES. src/gateway/routes.rs:257-276 parses each NDJSON line and feeds the stream straight to invoke_sink; OperationRegistry::invoke_sink performs only not-found/visibility/ACL/ handler-kind checks — publish_schema validation lives in alkcall's wire Dispatcher only. A Pub op that registers publish_schema receives attacker-controlled arbitrary JSON over HTTP while the same op over the call protocol aborts invalid chunks. Handlers written against the validated-wire guarantee get a transport-dependent invariant. Fix: validate in the route (or move validation into the shared invoke_sink spine so both transports enforce it).

GW-02 [major] — Per-identity GET /search / GET /schema responses carry no cache headers

Verified: YES (grep: no Cache-Control/Vary anywhere in src/). /search and /schema are GETs whose bodies are per-caller (AccessControl-filtered, 200 vs 403 depending on the bearer). With no Cache-Control: no-store (or at least Vary: Authorization), shared caches/CDNs are permitted to serve caller A's authenticated response to caller B — leaking the op inventory and, compounded with SRV-02, full schemas. Standard mitigation; matters for any deployment behind a cache.

GW-03 [minor] — INVALID_OPERATION_TYPE maps to 500 on /call///batch but 400 on /publish

Verified: YES. src/gateway/error.rs:17-21 lists only five protocol codes; INVALID_OPERATION_TYPE falls through to _ => 500 (a client-fault class reported as server-fault), while routes.rs:288-297 maps the identical condition to 400 on /publish. Pollutes error-rate alerting and retry logic; undocumented drift in http-server.md's mapping table.

GW-04 [minor] — SSE error events are not terminal; the stream continues after an Err

Verified: YES. routes.rs:299-313 maps each envelope independently and keeps going; the documented contract (http-server.md:219-223) and the wire dispatcher both treat an Err as terminal ("the stream ends after it"). A streaming handler that yields Err and then yields again makes HTTP serve event:error followed by more data: events, while the same op over WS emits call.error and stops — two transports disagree about stream semantics. Fix: take_while on Ok (emit the error, end the stream).

GW-05 [minor] — The 30 s DEFAULT_TIMEOUT deadline is set but never enforced

Verified: YES. src/gateway/dispatch.rs:34,165 records a deadline; nothing on the gateway path ever enforces it (registry.invoke does not wrap the handler in a timeout; the only real timeout lives client-side in alkcall's pending map). A hung handler holds the HTTP request open indefinitely. Either enforce (tokio::time::timeout around the invoke for Once ops) or remove the dead metadata.

GW-06 [minor] — /publish buffers the entire NDJSON body before dispatch

Verified: YES. routes.rs:187-191 takes axum::body::Bytes and materializes all chunks before invoke_sink (:257-266), contradicting ADR-068 step 4 ("stream each NDJSON line") and the module doc's disconnect claim (:182-183). Mitigated by the 2 MiB body cap, so memory is bounded — but there is no true streaming, no backpressure, and a silent 2 MiB-max publish semantic difference vs the wire Pub path. The vacuous test at routes.rs:1572-1583 (see HY-13) implicitly concedes the buffering.

GW-07 [minor] — Retry-After is never emitted on any live gateway error path

Verified: YES. The header machinery lives in call_error_to_http_response (error.rs:61-75), but the main gateway error path builds responses by hand (routes.rs:319-333) without it; the only production call site is not_found_response, where retryable can never be true. A retryable HTTP_429/HTTP_503 from a handler reaches callers with no Retry-After, while http-server.md promises the mapping.

GW-08 [minor] — /batch has no cap on the number of operations

Verified: YES. routes.rs:139-157 dispatches each entry sequentially; the only bound is the 2 MiB body. A maximal batch of cheap ops occupies a worker for the sum of all handler latencies (30 s "deadline" unenforced per GW-05). Also note results are purely positional — CallRequest has no caller-supplied id field.

GW-09 [minor] — /batch internal-op entries emit request_id: null while dispatched entries carry a UUID

Verified: YES. routes.rs:358-365 vs :335-340 — mixed envelope shape within one response body; clients correlating on request_id see two shapes.

GW-10 [minor] — /publish first line without a chunk key silently publishes Value::Null

Verified: YES. routes.rs:213unwrap_or(Value::Null). A first line carrying only {"operation": "…"} publishes a null chunk 1 instead of failing with INVALID_INPUT (the class of error the route does reject for a missing operation at :216-226). Since null is also a legitimate payload, client error is indistinguishable from intent.

GW-11 [minor] — /publish runs four registry lookups + ACL check that invoke_sink then repeats

Verified: YES. routes.rs:240-255 vs alkcall registration.rs:289-344 — duplicated enforcement (harmless because dispatch re-checks and wins, but pure duplication, and inconsistent with /call///batch, which deliberately skip the pre-check and rely on the registry).

GW-12 [minor] — ACL denial on /subscribe surfaces as HTTP 200 + SSE event:error; on /call the same denial is 401/403

Verified: YES. subscribe_handler returns Sse (always 200); pre-dispatch failures (unknown op, internal op, ACL) stream as event:error frames (tests at routes.rs:1066-1132 assert the 200s). Defensible per ADR-049 (errors-on-the-stream) but a status-fidelity asymmetry across the "sole invoke path" that the docs don't call out — and standard HTTP monitoring never sees auth failures on /subscribe. (See also PRJ-05 — the OpenAPI doc doesn't document this either.)

GW-13 [minor] — SSE stream has no keep-alive/heartbeat

Verified: YES. routes.rs:172Sse::new(stream) with no .keep_alive(...) and no retry: field. Subscriptions are specced as unbounded, so quiet-but-alive streams are normal state; typical LB/proxy idle timeouts (30-60 s) will silently terminate them.

GW-14 [minor] — Stale module doc: /publish claimed to be "a separate module"

Verified: YES. routes.rs:1-9 says /publish (ADR-068) "is a separate module"; publish_handler and all its helpers live in this same file (:184-297). (Same staleness echoed in AGENTS.md §7's "5 gateway endpoints" framing — ADR-068 made it 6.)


Part D — Outbound forwarding core + client host

FWD-01 [major] — Path-parameter values substituted without percent-encoding; Url::join normalizes dot-segments

Verified: YES (empirically). src/adapters/forward.rs:56-71, 133-141value_to_path_segment returns the raw string; Url::join then normalizes ... Verified: template /repos/{owner}/{repo}/issues with {owner} = "../../admin"https://api.example.com/admin/... (traversal); {owner} = "a?admin=true" → the ? splits the URL and discards the template tail into query semantics; # fragments inject likewise. A value containing a later placeholder also gets expanded by that later key (iterative substitution over a BTreeMap-ordered input). Attack: peer-controlled input into a path param escapes a path-scoped prefix on the upstream (cross-tenant IDOR), with the operation's injected credentials attached. Contrast: the query path is correctly encoded via query_pairs_mut. Fix: percent-encode each segment (utf8_percent_encode with a path-segment set) before substitution, and reject/encode ?/#.

FWD-02 [major] — base_url path prefix silently dropped; no scheme/host validation of the effective URL (SSRF)

Verified: YES (empirically). forward.rs:74-78Url::join resolves against the base directory: base_url = "https://api.openai.com/v1"

  • template /chat/completionshttps://api.openai.com/chat/… (the /v1 is lost). Every test in both adapters uses an origin-only base_url, so the suite can't see it. Worse: a path key that is an absolute URL (https://169.254.169.254/…, http://localhost:9090/…) replaces scheme+host entirely — verified — and reqwest only rejects non-http(s) schemes at send time, so any http/https host is allowed. Credential injection happens after URL construction, so a spec-controlled absolute path sends the namespace's credentials to an arbitrary host. Today specs are assembly-layer-supplied (trusted per ADR-066), which keeps this out of critical territory — but nothing enforces that trust boundary (no allowlist, no post-join host-equality check). Fix: require the joined URL to keep the base host (fail loudly otherwise), require https (or explicit opt-out), and handle the base-path-prefix case (append to the base path, not the origin).

FWD-03 [major] — Custom credential headers follow cross-host redirects (reqwest default policy)

Verified: YES. src/client/http_client.rs:119-172 never sets .redirect(...), so reqwest's default (limited(10)) applies; its cross-host scrub removes only Authorization/Cookie/cookie2/ Proxy-Authorization/WWW-Authenticate (verified in reqwest 0.13 source). HttpAuthScheme::ApiKey { header_name } credentials (forward.rs:110-117) and all default_headers are not in that list. Attack: an upstream open redirect (or DNS hijack) 302s to an attacker host → the API key is delivered intact. Fix: an explicit redirect policy — none, or a limited same-host policy — for the shared client.

FWD-04 [major] — Non-idempotent requests (POST) are retried; retry budget has no total-duration cap

Verified: YES. http_client.rs:165-167 installs RetryTransientMiddleware with the default strategy, which classifies 5xx/408/429/timeout/connect-error as retryable regardless of method (verified in reqwest-retry 0.9.1; is_incomplete_message is explicitly retried). A POST whose backend committed the write then returned 500 (or dropped mid-response) is re-sent up to 3 times → duplicate side effects. Backoff sleeps (default max_retry_interval 30 min each) occur outside reqwest's per-attempt timeout, and ExponentialBackoff::build_with_max_retries(3) has no total-duration cap → total wall time unbounded from the caller's perspective. Fix: skip retries for non-idempotent methods (or make it a per-adapter policy), and cap total retry duration.

FWD-05 [major] — No request/connect timeout by default; Retry-After deadlines uncapped → unbounded hangs

Verified: YES. HttpClientConfig::default() sets request_timeout: None and no connect timeout exists anywhere (http_client.rs); reqwest applies no timeout unless configured. retry_after.rs:27-37 accepts any u64 seconds with no maximum, and maybe_sleep_for sleeps before every request to that URL. A hostile backend answering once with 429 + Retry-After: 315360000 (~10 years) stalls every subsequent call to that URL indefinitely. Overflow is safe (checked_addNone), and zero/past deadlines are filtered — the gap is the missing cap and the missing default timeout. Fix: default request timeout (the gateway's 30 s deadline is the natural anchor), a connect timeout, and a Retry-After ceiling.

FWD-06 [major] — SSE parser drops events at TCP chunk boundaries and corrupts split multi-byte UTF-8

Verified: YES (empirically with the verbatim parser). forward.rs: 409-417 retains only the last line of each chunk as remaining, discarding any pending multi-line data_buffer; a chunk ending exactly at data: …\n (blank line not yet arrived) silently loses the event (verified: chunks "data: {\"n\":1}\n" + "\ndata: {\"n\":2}\n\n" yield only event 2). Single-chunk delivery (as in the tests) works, which is why the suite passes. Also String::from_utf8_lossy per chunk (:357) corrupts multi-byte characters split at chunk boundaries → JSON parse failure → event degraded to a raw string. Plus the trailing partial line has no length cap (unbounded buffering) and EOF with a pending event drops it (SSE says dispatch at EOF). For a subscription forwarder this is silent data loss with no error signal. Fix: an incremental byte-level parser carrying state across chunks.

FWD-07 [major] — Vendor JSON content types (application/*+json) decoded as per-byte arrays; no response size cap

Verified: YES. forward.rs:236content_type.contains("application/json") misses application/vnd.api+json, application/hal+json, application/problem+json, which fall to the binary branch and return Value::Array of one Number per byte. There is no response size limit on any read path (response.json()/.text()/.bytes()), so a hostile upstream controls caller-side memory. forward_stream never checks content-type at all (a 200 HTML response parses to an empty stream with no error). Fix: match on the mime essence type (application/.*+json suffix semantics), cap response sizes, and treat non-SSE content on a Sub op as an error.

FWD-08 [minor] — Invalid credential values silently produce unauthenticated requests

Verified: YES. forward.rs:104-126 (and :87-94 for default_headers) — all auth arms drop the header on HeaderValue::try_from failure with no error, no log. A credential with a control character (or a typo'd header_name) means the request goes out unauthenticated and the caller only sees the upstream's eventual 401. (No leak risk — nothing is logged; verified no tracing calls in the forwarding path.) A loud error would surface misconfiguration at call time instead.

FWD-09 [minor] — Blocking std::fs::read in build_client (reachable via public reload)

Verified: YES. http_client.rs:129,144,150 — small-file reads on the construction path; SharedHttpClient::reload (:111-116) is public and documented as hot-reload, so an async-context caller does blocking I/O on the async path (convention violation; one-shot small reads, so impact is a stalled worker). tokio::fs::read (or spawn_blocking) is the cheap fix.

FWD-10 [minor] — Upstream error bodies discarded

Verified: YES. forward.rs:215-227 (and :333-346 for the stream twin) — on non-2xx the body is dropped; the surfaced message is only "HTTP {status}: {reason}". Upstream diagnostics (validation details, rate-limit info) never reach the caller, and unconsumed bodies hinder connection reuse. The HTTP_<status> mapping itself is correct.

FWD-11 [minor] — Retry-After bookkeeping keyed on pre-redirect URL; eviction order inverted; thundering herd on wake

Verified: YES. retry_after.rs:127-130 records under req.url() (pre-redirect), so a redirector's rate limit pollutes the origin's entry; eviction drops the earliest deadline (preferentially discarding soonest-actionable entries while retaining year-long ones, compounding FWD-05); on expiry all concurrent waiters wake simultaneously (no jitter).

FWD-12 [minor] — Duplicated helpers, dead-ish parameter, non-atomic reload stores

Verified: YES. value_to_path_segment and value_to_query (forward.rs:133-151) are byte-identical; forward's op_type parameter only toggles ACCEPT (if a Sub were ever routed here, the text/ branch would buffer the whole SSE stream — currently unreachable but inviting misuse); reload performs two separate ArcSwap::store calls (a reader can observe new config with the old client); forward.rs:197 unwrap_or_else(|_| "null") masks an (unreachable) serialization failure by sending a null body.


Part E — from_openapi / from_jsonschema / openapi_spec

OAI-01 [major → borderline critical] — Unbounded $ref recursion → stack overflow (process abort) on valid, common specs

Verified: YES (empirically). src/adapters/openapi_spec.rs:199-221resolve_refs_recursive recurses with no cycle detection and no depth budget; a self-referential component ({"$ref":"#/components/schemas/Node"} inside Node — trees, linked lists, cursor pagination: common, valid OpenAPI) recurses until the stack is exhausted. The 128-level parse limits of serde_json/yaml_serde don't help (each $ref hop re-enters from a fresh clone). Verified empirically: the exact algorithm against a recursive schema → thread has overflowed its stack; fatal runtime error → abort. Not a catchable panic; import() kills the whole process (startup crash-loop, or remote DoS if specs are ever runtime-refreshed/peer-supplied). Fix: a depth budget + visited set, erroring cleanly on cycles.

OAI-02 [major] — No input-schema enforcement: extra keys become upstream query params; body sent verbatim

Verified: YES. forward.rs:60-72 — every input key that isn't a path placeholder (and isn't literally "body") is appended as a query parameter regardless of the registered input_schema; neither the registry nor the adapter validates input against the schema at call time (ADR-066's "input schema validation before send" is not implemented anywhere). A composed facade forwards peer input; a peer adds "debug": "true" or "impersonate_id": "…" and it reaches the upstream. Violates the schema-scoped contract /schema advertises.

Verified: YES. openapi_spec.rs:35-40 records in_ (dead field — never read); build_request has no header-parameter branch. Upstream auth/trace headers never arrive (confusing failures), and values that were specified as headers — which don't normally land in upstream access logs — end up in query strings, which typically do.

OAI-04 [major] — Parameter-level and requestBody-level $refs are silently dropped, producing broken operations

Verified: YES. openapi_spec.rs:236-262 — a parameter entry of the form {"$ref": "#/components/parameters/Id"} (extremely common in real specs) has no name, is silently skipped, and never reaches resolve_refs_recursive; same for requestBody: {"$ref": …}. Only components/schemas is indexed (:162-174). Result: the op registers with {id} in the template but id absent from the schema; at call time the placeholder is substituted with the literal {owner} text and percent-encoded (%7Bowner%7D) — a well-formed request to a nonsense path, with credentials attached. Silent misbehavior, no error at import or call time.

OAI-05 [minor] — Operation-ID collisions silently overwrite registrations

Verified: YES. from_openapi.rs:51-65,160 — generated IDs collide by construction (/x/{id}/y and /x/y both → get_x_y); alkcall's registry insert silently replaces; import() doesn't detect duplicates within its own batch. One op silently shadows another; /search shows fewer ops than the spec declares.

OAI-06 [minor] — Unsupported OpenAPI features degrade silently

Verified: YES. "default"/wildcard response keys become ErrorDefinition { code: "HTTP_0", http_status: None } — entries that never match a real status (from_openapi.rs:142-145); trace ops are silently skipped (openapi_spec.rs:12-13); servers overrides, parameter style/explode, and array/object query serialization are unsupported and silent (arrays become "[1,2]"); a default-declared SSE stream is missed by detect_op_type and would return one giant text string. None fail loudly at import; each produces an op that misbehaves only at call time.

OAI-07 [minor] — Magic "body" input key collides with spec parameters named body

Verified: YES. from_openapi.rs:96-102 + forward.rs:66-67 — a declared parameter named body is overwritten by the requestBody schema and diverted to the request body at call time, unreachable as the query/path/header parameter the schema advertised.

OAI-08 [minor] — Guarded expects in openapi_spec lib code

Verified: YES. openapi_spec.rs:144,170expect("paths is object") etc., each immediately preceded by an is_object() check (unreachable today, but AGENTS.md §2 says no expect outside tests; if let costs nothing).

OAI-09 [minor] — from_jsonschema defers all config validation to call time

Verified: YES. from_jsonschema.rs:36-51 — malformed method/ path_template/base_url surface as CallError::internal on first invoke rather than at construction. Also doc drift: the module doc says "Internal by default" (:8) but the adapter passes the caller's OperationSpec through verbatim — from_openapi hardcodes Visibility::Internal (:169, tested); from_jsonschema does not.


Part F — Projections (to_openapi, to_mcp)

PRJ-01 [major] — /search 200 response schema is wrong twice (missing envelope wrapper, wrong item fields)

Verified: YES. to_openapi.rs:100,335-351 documents {operations: [{name, description}]}; the actual body is the envelope wrapper ({request_id, result, output} — proven by the routes tests reading body.output) and items carry name/namespace/op_type with no description. The summary "Returns names + descriptions" is false. Clients generated from this doc are broken on day one.

PRJ-02 [major] — /schema 200 response schema is wrong (missing wrapper; omits op_type/visibility/access_control/channel_open/publish_schema)

Verified: YES. to_openapi.rs:124,353-364 documents the bare spec; the runtime body is the envelope with spec_to_json's fuller inner object. Same wrapper-miss as PRJ-01.

PRJ-03 [major] — Documented 400 INVALID_INPUT doesn't match the runtime mapping (422) or axum's rejection bodies

Verified: YES. The doc maps 400 for /call///batch///schema; the runtime mapper is INVALID_INPUT → 422 (error.rs:51), and axum's extractors reject malformed bodies with plain-text 400/415/422 bodies that have none of the documented {code, message, retryable} shape. The doc has no 422 response at all. /publish is correct here — the inconsistency between two pages of the same doc underlines the drift.

PRJ-04 [major] — Operation-level error statuses projected under statuses the runtime never produces

Verified: YES. to_openapi.rs:544-563 projects an op error declared at 429 under a 429 response; the runtime mapper is purely code-driven and ErrorDefinition.http_status is never consulted at runtime — a RATE_LIMITED error surfaces as 500 unless the code is literally HTTP_429. A consumer building backoff logic around the documented 429 gets 500s. The test operation_errors_projected_onto_call enshrines the wrong behavior. Either project non-HTTP_* codes under 500, or honor http_status at runtime.

PRJ-05 [major] — /subscribe documented HTTP error statuses are structurally unreachable; in-band SSE errors undocumented

Verified: YES. subscribe_handler always returns 200 + SSE (GW-12); unknown op, internal op, ACL denial, and handler errors are all event:error frames (asserted by tests). The doc claims full protocol-status responses and never mentions event: error frames; a client built from it waits for a 404 that will never come and hangs reading the stream.

PRJ-06 [major, security] — MCP schema tool skips the per-operation AccessControl check that HTTP /schema performs

Verified: YES. to_mcp.rs:148-174 dispatches straight to services/schema with no pre-check (compare routes.rs:125-127); the handler returns the full spec — including access_control.required_scopes — for any registered op the caller is forbidden to call, including unauthenticated callers. An unprivileged MCP client reads the exact scopes needed for escalation. Per-caller filtering is asymmetric between the two projections of the same dispatch spine; the test schema_returns_full_operation_spec enshrines the leak (fetches with identity: None and asserts access_control is present).

PRJ-07 [major] — MCP search tool advertises a query filter it silently ignores

Verified: YES. The input schema advertises "Optional substring filter" (to_mcp.rs:56-59) but call_tool drops arguments entirely for search (:382) — an LLM passing {"query": "fs"} receives the full unfiltered listing (wasted context; in large registries, context overflow).

PRJ-08 [major] — MCP search does not exclude Pub operations, contradicting ADR-068

Verified: YES. The filter is !matches!(op_type, "sub" | "subscription" | "Sub") (to_mcp.rs:268-274); ADR-068 says to_mcp "excludes both Sub and Pub". A discovered Pub op can never be invoked via the call tool (invoke on a Sink returns INVALID_OPERATION_TYPE) — the advertised discovery surface is a lie for the entire Pub class. (The "subscription"/"Sub" match arms are dead — op_type_str only emits lowercase.)

PRJ-09 [minor] — MCP batch item shape contradicts its own tool description

Verified: YES. call returns the raw output as structuredContent; batch items are {"isError": …, "output"|"error": …} — the description claims "each shaped like a call result". False on both success and error shapes.

PRJ-10 [minor] — structuredContent emitted as non-object values (MCP spec says object)

Verified: YES. to_mcp.rs:293-298 passes the operation output through verbatim — a string/array/null output produces a non-object structuredContent, and batch returns a top-level array. rmcp accepts it; strict MCP clients may not (client impact unverified).

PRJ-11 [minor] — expect/unwrap in to_openapi library code

Verified: YES. to_openapi.rs:67 (see SRV-09 — the reachable one), :440 (guarded by is_object), :505,512 (guarded by len() == 1). All currently unreachable-in-practice; convention violation.

PRJ-12 [minor] — Generated doc is nondeterministic when the same error code is declared with different statuses

Verified: YES. collect_operation_errors dedupes by code while iterating a HashMap — two regenerations of /openapi.json from identical registry state can differ in which status wins (SipHash randomized per process). Spec-diffing consumers see phantom changes. Dedupe by (code, status) or sort.

PRJ-13 [minor] — Hand-rolled MCP argument errors omit retryable, diverging from the CallError wire shape

Verified: YES. to_mcp.rs:159-163,200-204,230-234 — the structured errors lack retryable (required by the OpenAPI error schemas and always present on CallError); a non-string operation reports the misleading "missing required field: operation".

PRJ-14 [minor] — components.schemas defined but never referenced; inline duplication guarantees drift

Verified: YES. to_openapi.rs:88-91,534-542schema_call_request() is inlined verbatim 4× and also emitted into components; nothing uses $ref. Shape changes must be edited in lockstep or the doc self-contradicts.

PRJ-15 [minor] — /search documents 401/403 that cannot occur, omits the 404 that can; no securitySchemes anywhere

Verified: YES. services/list has default ACL, so /search returns 200 even unauthenticated (per-op filtering happens inside the listing — tested); the doc's 401/403 for /search can't occur. And the doc declares no securitySchemes despite Bearer being the contract (ADR-004) — generated clients won't know to authenticate.


Part G — Consumer adapters (from_wss, from_mcp)

CON-01 [major] — from_mcp imports only the first tools/list page

Verified: YES. from_mcp/mod.rs:86 — a single list_tools call; next_cursor is never followed. rmcp 1.8 provides list_all_tools() for exactly this. Any server with enough tools to paginate silently truncates; no error, no log. One-line fix.

CON-02 [major] — from_wss drop monitor is one-shot and racy; calls racing the drop hang forever

Verified: YES. Two code-verified gaps beyond WS-02's lost notification: (1) the monitor runs fail_all exactly once and exits — a call whose pending entry is registered after fail_all ran is never resolved, and no pending-entry sweeper exists on the client path (alkcall's sweeper runs only inside Dispatcher::run_loop, which from_wss never takes; the module doc's "no hang until the 30s sweeper deadline" promise is false on this path); (2) the race window above. The test connection_drop_fails_in_flight_calls_retryable_no_hang covers only calls registered before the drop. Fix: a watch channel (WS-02) plus a periodic sweep of the pending map while the session lives.

CON-03 [major] — from_wss accepts plaintext ws:// and sends the Bearer token over it

Verified: YES. from_wss.rs:114-128IntoClientRequest accepts any scheme; nothing enforces wss:// and the Authorization header is attached unconditionally. The crate's own tests dial ws://. A config typo (ws://prod-node/alk/channels) silently ships a long-lived bearer credential over plaintext. (TLS validation itself is fine — rustls-tls-webpki-roots, no danger options anywhere.) Fix: refuse ws:// when a token is present (or unconditionally unless explicitly allowed).

CON-04 [minor] — content_block_union_schema audio variant requires a non-existent "audio" property

Verified: YES. from_mcp/mod.rs:240-248 — the property is data but required lists audio; valid audio blocks never satisfy the schema. Published as output_schema and inside error_schemas; the variant test checks only the enum tags. Consumers validating handler output against the declared schema reject valid audio results.

CON-05 [minor] — from_mcp per-call capability read is dead code; module doc claims per-call credential use

Verified: YES. mod.rs:139-143 computes the token's length and discards it; the credential actually used is the transport-pinned import-time token (rmcp's config is immutable post-construction). An integrator injecting per-call/per-user tokens into OperationContext.capabilities gets silence. Doc + dead read should be corrected to "import-time credential".

CON-06 [minor] — 401 classification by substring match on Debug output

Verified: YES. mod.rs:103-116format!("{error:?}").contains("401") misclassifies transport errors whose URL contains :4010/ (port 4010) as Unauthorized, and misses differently-worded auth failures. Fragile in both directions; affects only the surfaced error variant.

CON-07 [minor] — from_wss doc claims imported handlers read per-call credentials; they carry no capabilities at all

Verified: YES. from_wss.rs:7-9 vs alkcall from_call.rs:141-148 — bundles register with Capabilities::new(); only the dial-time token ever authenticates. Same class of doc-vs-reality drift as CON-05 (ADR-014's chain is respected, but the doc describes an implementation that doesn't exist).

CON-08 [minor] — std::mem::forget(running) leaks the rmcp session — no teardown, no DELETE, SSE stream left open

Verified: YES. mod.rs:98 — the fire-and-forget pattern skips rmcp's session teardown; each import() leaves an open server-side session + long-lived GET SSE stream until the remote times it out. Repeated imports accumulate. No close path exists on FromMCP.

CON-09 [minor] — from_wss import leaks the whole session by design; no shutdown handle for reconnect scenarios

Verified: YES. from_wss.rs:189-193 — deliberate (ADR-070 v1), but a reconnecting assembly layer calling import() again stacks a second full session with duplicate op names and no way to tear the first down. Worth an ADR-070 note or a close()/handle API in v1.1.

CON-10 [minor] — [[test]] full_surface missing test-support in required-featurescargo test --features mcp fails to compile

Verified: YES (empirically). Cargo.toml:73-75 vs tests/full_surface.rs:26 importing test-support-gated items (websocket/mod.rs:23-24). cargo test --features mcp fails with unresolved imports; masked because CI uses --all-features.

CON-11 [minor] — Transport-level tools/call failures map to undeclared INTERNAL

Verified: YES. mod.rs:149-152 — remote down/timeout/JSON-RPC error → CallError::internal, which is not in the declared error_schemas (only MCP_TOOL_ERROR is); MCP JSON-RPC error codes are flattened, losing fidelity the rest of the crate maintains via HTTP_<status>.

CON-12 [minor] — Remote tool names interpolated into op names without sanitization

Verified: YES. mod.rs:169-171 — a remote-controlled tool_name containing / yields a three-plus-segment op name, breaking the two-segment ns/op convention the gateway and namespace reasoning assume. (Registry acceptance of such names unverified — if it rejects, downgrades to cosmetic.)

CON-13 [minor] — Auth tokens held as plain String in adapter builders

Verified: YES. from_wss.rs:48, from_mcp/mod.rs:37auth_token: Option<String> with a public accessor; alkcall's Secret<String> zeroizing wrapper is used correctly at the registration boundary but the builder-held copy is unguarded plaintext. No Debug derives exist, so accidental logging is unlikely — but holding Secret<String> would match the crate's own posture.


Part H — Hygiene / cross-cutting

HY-01 [major] — openapiv3 is a production dependency used only by a test

Verified: YES. Cargo.toml:44 vs sole usage to_openapi.rs:1103 (inside #[cfg(test)]). Every consumer compiles openapiv3 (and its tree) for nothing. Move to [dev-dependencies].

HY-02 [major] — 110 missing-docs warnings under -W missing_docs

Verified: YES (ran with RUSTDOCFLAGS=-W missing_docs). Default cargo doc is 0-warning only because missing_docs is off. Worst: openapi_spec.rs (28), http_client.rs (28), forward.rs (10), server/adapter.rs (10), dispatch.rs (7), routes.rs (6); gateway/mod.rs and server/mod.rs lack module docs. For a crate prepping crates.io, this is the largest single hygiene gap.

HY-03 [minor] — Guarded expect/unwrap spots that would panic silently if their guard is edited

Verified: YES. to_openapi.rs:440 (guarded by is_object), :505,512 (guarded by len() == 1), openapi_spec.rs:144,170 (guarded by is_object). Safe today; the pattern is one edit away from a panic. (The unguarded ones are SRV-09/PRJ-11.)

HY-04 [minor] — unwrap in shipped test_support

Verified: YES. upgrade.rs:161serde_json::to_vec(envelope).unwrap() inside pub mod test_support, which ships behind the opt-in test-support feature. Documented as intentional; note it is published API surface with a panic inside.

HY-05 [minor] — Unused dependencies and feature slack

Verified: YES (grep + build). bytes declared, zero direct use in src/ (the only Bytes is axum's re-export); parking_lot declared, zero direct use (obtained transitively via alkcall); tokio features = ["full"] drags in process/signal extras. All cheap to prune.

HY-06 [minor] — HttpClientConfig.retry_policy exposes reqwest_retry::ExponentialBackoff in the public API

Verified: YES. http_client.rs:34 — a semver anchor to an upstream concrete type and an awkward construction surface; consider an owned config struct.

HY-07 [minor] — READ_SLOTS is pub in a public module (implementation constant leak)

Verified: YES. byte_adapter.rs:56 — reachable as alkhttp::websocket::byte_adapter::READ_SLOTS but deliberately not re-exported; either privatize or document it as API.

HY-08 [minor] — tokio-tungstenite declared three times with different feature sets

Verified: YES. wss (:19), test-support (:20), and the dev-dependency (:59) each pull it; feature unification currently lands on the superset, but test-support extending wss would remove the fragility.

HY-09 [minor] — 8 + len can overflow on 32-bit targets

Verified: YES. byte_adapter.rs:163,345len is a parsed u32 as usize; 8 + u32::MAX overflows in debug on 32-bit. Saturating add (or the WS-04 validation) removes it.

HY-10 [minor] — Stale/contradictory docs

Verified: YES. from_wss.rs:86-88 says dropping the session tears the WS down "(via WssSession::drop or std::mem::forget)" — mem::forget prevents Drop and does the opposite (the comment at :189-192 says so); routes.rs:1-9 (GW-14); [ADR-051]: https://docs.rs/alkhttp placeholder link definitions and a relative filesystem link in websocket/mod.rs:2 break on docs.rs.

HY-11 [minor] — The whole docs/ tree (672K) ships in the package

Verified: YES. The exclude list omits docs/reviews/ but keeps docs/architecture/; fine if intentional (ADR links), worth a decision before publish.

HY-12 [minor] — Duplicate dependency roots

Verified: YES (cargo tree -d). getrandom 0.3/0.4 + cpufeatures 0.2/0.3, driven by tokio-tungstenite 0.28 here vs tungstenite 0.29 via axum's ws. Aligning tokio-tungstenite with axum's tungstenite collapses the duplicates.

HY-13 [minor] — Vacuous test: publish_body_is_fully_consumed_before_dispatch_not_required

Verified: YES. routes.rs:1572-1583 — an empty test body (comment only) that passes vacuously; its own comment cites an "infra-integration-suite" socket-level test that does not exist in tests/. Wire the socket-level early-disconnect test or delete the stub.


Part I — Coverage (cargo-llvm-cov, all features)

Overall: 93.86% lines / 91.75% functions / 94.23% regions — high, but the uncovered residue concentrates exactly where the findings live.

COV-01 — adapters/forward.rs 82.3% lines / 67.9% functions (lowest in the crate)

Uncovered regions (JSON export): the binary-response branch (247-264), the Basic-auth arm (122-125), the ApiKey arm (116-119), the value_to_* helpers (137-151), non-2xx error mapping branches (205-213), the SSE stream error path (374-383), and the +json/ content-type branches (239-244). These are precisely FWD-01/03/07/08/10 — the highest-risk code in the crate is also its least-tested.

COV-02 — client/http_client.rs 81.4% lines

Uncovered: the mTLS/CA-bundle build paths (133-164) and the Debug impl (87-95). No test constructs a client with a CA bundle or client cert; FWD-03 (redirect policy) has no test because no redirect test exists.

COV-03 — websocket/byte_adapter.rs 82.2% lines

Uncovered: WsPumps::abort/read_eof (91-108 — the WS-02 mechanism is untested), the tungstenite pump twins (309-337 — the duplicated code is untested by the axum-path tests), and the write-side split/drain edge paths. A tungstenite-path test suite (or dedup, WS-11) is needed.

COV-04 — server/decoy.rs 87.1% lines

Uncovered: percent_decode (112-122 — SRV-08's bugs are untested), hex_digit/mime_for_path (129-154), and static-site serving (129-154). The traversal guards are tested; the decoding is not.

COV-05 — gateway/dispatch.rs 91.0% lines

Uncovered: the capability-inheritance path (52-64) and error branches (202-211, 270-273).

COV-06 — server/adapter.rs 91.9% lines

Uncovered: the mcp router arm (127-132 — never exercised without the feature in unit tests), ProtocolHandler::handle/ALPN entry (192-196, 228-230), the h2 enable-connect branch (235-243), and the /openapi.json error path (250-256 — SRV-09 untested).

COV-07 — server/state.rs 62.5% lines

Uncovered: FromRef<RouterState> for Arc<dyn IdentityProvider> (49-52) — only the decoy FromRef is tested.

COV-08 — Semantic test gaps (decided behaviors with no test)

The high line-coverage hides the gaps that let the findings land:

  1. /schema on an Visibility::Internal op (SRV-02) — the four other routes are tested; schema is not.
  2. extra_routes auth layering (SRV-01) — no test mounts an extra route and asserts identity resolution.
  3. /mcp body limit (SRV-03).
  4. SSE across real TCP chunk boundaries (FWD-06) — the parser is only tested with single-chunk delivery.
  5. Cross-host redirect behavior with custom credential headers (FWD-03).
  6. Retry policy on a non-idempotent op (FWD-04).
  7. Recursive $ref specs (OAI-01 — a test importing a self-referential spec would have caught the abort immediately).
  8. Absolute-URL path templates / base-path-preserved base URLs (FWD-02).
  9. /publish with publish_schema-registered ops (GW-01).
  10. from_wss drop while a call is being registered (CON-02's race).
  11. from_mcp paginated tools/list (CON-01).
  12. The tungstenite pump path end-to-end (COV-03).
  13. MCP search with a query argument (PRJ-07); MCP search containing Pub ops (PRJ-08); MCP schema ACL denial (PRJ-06).

Cross-cutting: what is solid (verified)

Listed so the remediation plan can focus on what's actually broken:

  • Auth enforcement on the default surface. Every gateway route + /openapi.json + /healthz sits under bearer_auth_middleware (adapter.rs:154-173, applied before merges of the default router); the WS route carries the fail-closed ws_bearer_auth (401 before upgrade, tested). Dispatch re-runs visibility + ACL with internal: false and forwarded_for: None hardwired — wire callers cannot spoof forwarding or escalate to internal (verified in alkcall's registry code, defense in depth regardless of entry point).
  • No-env-vars invariant (ADR-014). Zero std::env::var reads in src/; the only occurrences are negative tests asserting env is ignored. Credentials flow exclusively context.capabilities.get(namespace)expose_secret() → header.
  • Credential hygiene. No tracing calls anywhere in the forwarding path (nothing logs headers/tokens); Secret<String> zeroizing used at the registration boundary; header names/values pass through HeaderName/HeaderValue::try_from (CRLF injection structurally prevented — the failure mode is silent drop, not injection); no danger_accept_invalid_certs anywhere; stdio transport unreachable for MCP (rmcp feature set excludes child-process).
  • Internal-op invisibility on /call, /batch, /subscribe, /publish (pre-checks + registry re-checks; /call on Internal → 404, tested) — the gap is /schema (SRV-02) only.
  • Body limits on axum-extracted routes. /call /batch /subscribe /publish are capped at 2 MiB by axum's default (crate never disables it); JSON depth bombs rejected by serde_json's recursion limit; YAML bombs bounded by yaml_serde's alias/depth limits (verified against yaml_serde 0.10.7 source).
  • /publish terminal-error shape matches ADR-068 (plain HTTP status + JSON body, never an NDJSON line — tested for 200/400/401/ 403/404/500); OQ-02 framing implemented as decided.
  • Batch semantics: per-entry envelopes, one bad op doesn't fail the batch, order preserved (tested).
  • WS framing core is correct. Byte-stream treatment both directions; split headers/payloads reassemble (read_exact-based demux); oversized chunks split legally across messages (16 MiB round-trip test); WS Close and abnormal termination → EOF → all channels cleared (traced end-to-end; disconnect_mid_call test); no busy-wait (the POC spin was replaced with poll_ready); text message → 1002 close. Identity cannot be escalated via client-supplied auth_token on channel 0 (NoopProvider fallback verified in alkcall).
  • WS upgrade auth is header-only, fail-closed, and non-WS requests cannot reach the session. No per-request token in URLs anywhere.
  • Error mapping basics (ADR-023). Protocol codes → statuses per the documented table (incl. FORBIDDEN → 401-without-identity/403-with); HTTP_<status> parsing with malformed-code → 500 fallback (tested); unknown codes → 500, never panic.
  • Producer/consumer naming, module structure, feature matrix. No server/client framing in API names; one module per file, all re-exported; --no-default-features, mcp-only, wss, and --all-features all compile; clippy clean in both modes; all 19 cited ADR numbers resolve to real files.
  • No panics/locks-across-await/blocking-I/O on the hot paths. The exceptions are individually filed (SRV-09, HY-03/04, FWD-09, SRV-08's is_dir); locks that exist are parking_lot or poison-safe std locks per convention.

Remediation plan

Ordered by dependency and severity; each unit is independently shippable with its own acceptance gate. The overarching gate mirrors the alkcall review lesson: each unit's acceptance gate is the test that would have caught its worst finding.

Unit 1 — Auth/visibility at the seams (SRV-01, SRV-02, SRV-03, PRJ-06)

The highest-value unit; all four are small, mechanical, and security-bearing.

  • SRV-01: apply the auth layer after merging extra routes (keep per-route opt-out possible by documenting that extras may carry their own layers); add a test mounting an extra route asserting identity is resolved (and one for the explicit opt-out shape).
  • SRV-02: add the is_internal_op → 404 guard to schema_handler (mirror /call); test /schema?name=<internal> → 404 unauthenticated and for unauthorized identities.
  • SRV-03: body-limit the /mcp nest (explicit DefaultBodyLimit); test a large body → 413.
  • PRJ-06: run the same access_check_for_op pre-check in the MCP schema tool (or filter the returned spec per identity); fix the enshrining test.

Gate: the four new tests above; full suite green.

Unit 2 — Outbound request construction + retry/timeout policy (FWD-01..05, OAI-01..04, FWD-11)

The largest unit; mostly inside forward.rs/http_client.rs/ openapi_spec.rs.

  • FWD-01: percent-encode path segments; reject/encode ?/#; iterate substitution safely (single-pass template rendering, not iterative replace).
  • FWD-02: post-join host-equality validation (fail loudly on host change), scheme allowlist (https default), and correct base-path appending (fix Url::join directory semantics).
  • OAI-01: depth budget + visited set in resolve_refs_recursive; clean error on cycles. Acceptance gate: an import test with a self-referential schema returns an error instead of aborting.
  • OAI-02: enforce input_schema at call time (reject undeclared keys, or document the pass-through as explicit), implement ADR-066's validate-before-send for from_jsonschema.
  • OAI-03/OAI-04: honor in: header; resolve parameter/requestBody $refs (index components/parameters + requestBodies); error loudly on unresolved path placeholders instead of sending literal %7Bowner%7D.
  • FWD-03: explicit redirect policy (none or limited same-host).
  • FWD-04: no retries for non-idempotent methods (or per-op idempotency config); cap total retry duration.
  • FWD-05: default request + connect timeouts (anchor to the gateway's 30 s), cap Retry-After (e.g. 300 s ceiling, configurable).
  • FWD-11: key Retry-After on the effective URL; evict longest-deadline-first (or soonest); add jitter.

Gate: tests for each bullet — traversal value, absolute-URL template, redirect-with-API-key, POST-retry, recursive-$ref import, header-param, $ref parameter, timeout default.

Unit 3 — Gateway correctness (GW-01, GW-03..GW-13)

  • GW-01: publish_schema validation in the shared spine or the route.
  • GW-03: map INVALID_OPERATION_TYPE → 400 (or 422) consistently; document in http-server.md.
  • GW-04: SSE error events terminal (take_while Ok / emit-and-end).
  • GW-05: enforce or remove the 30 s deadline.
  • GW-06: either stream the NDJSON body (axum Body → framed stream) or document the 2 MiB buffered semantic in ADR-068.
  • GW-07: route error responses through call_error_to_http_response so Retry-After fires.
  • GW-08: cap batch size (constant, e.g. 100).
  • GW-09: generate request ids for internal-op batch entries.
  • GW-10: reject a first line missing chunk with INVALID_INPUT.
  • GW-11: drop the redundant /publish pre-checks (mirror /call).
  • GW-12/GW-13: keep 200-on-stream (ADR-049) but document it in the projection (PRJ-05) and add SSE keep-alive.
  • GW-14: fix the stale module doc.

Gate: a /publish test with a publish_schema-registered Pub op rejects an invalid chunk; SSE error-terminal test; batch-cap test.

Unit 4 — WS/adapter robustness (WS-01..WS-11, HY-09)

  • WS-02 (do first): replace notify_waiters with a watch channel or permit-storing signal; CON-02 falls out of this + a pending-map sweep.
  • WS-01: idle timeout on the WS read path (configurable; default bounds the dribble stall).
  • WS-04/HY-09: validate len > MAX_CHUNK_LEN on the write side (saturating add).
  • WS-05/WS-06: byte-based caps — cap pending growth and set an explicit inbound max_message_size consistent with the plan's ~1 MiB intent.
  • WS-07: make poll_shutdown actually drop the held sender so the documented close mapping holds.
  • WS-08/WS-09/WS-10: keep _pumps handle for forced teardown; add a session semaphore; document the detached-task semantics.
  • WS-11: dedup the pump bodies (single generic implementation; also fixes COV-03).
  • Cross-crate: file/land the alkcall demux fix for WS-12 (stream-skip instead of vec![0u8; length]).

Gate: a from_wss test that drops the connection mid-registration (CON-02); a dribble-stall test bounded by the idle timeout; the tungstenite path covered by shared tests.

Unit 5 — Consumer adapter fixes (CON-01..CON-13)

  • CON-01: list_all_tools().
  • CON-03: refuse ws:// with a token (or unconditionally with an explicit escape hatch).
  • CON-04: fix the audio variant required list.
  • CON-05/CON-07: correct the doc claims (import-time credential) and remove the dead capability read.
  • CON-06: classify on typed error variants where rmcp exposes them; fall back to substring only as a last resort.
  • CON-08/CON-09: add explicit close/teardown handles (or document the leak + ADR-070 note).
  • CON-10: add test-support to full_surface's required-features.
  • CON-11/CON-12/CON-13: declared error codes for transport failures; sanitize/validate remote tool names; Secret<String> for held tokens.

Gate: cargo test --features mcp compiles and passes (CON-10); pagination test against a paginating MCP server.

Unit 6 — Projection fidelity (PRJ-01..PRJ-05, PRJ-07..PRJ-15)

The to_openapi document needs a systematic diff against the runtime (routes tests are the oracle): envelope wrappers (PRJ-01/02), the 422-vs-400 mapping (PRJ-03), error-status honesty (PRJ-04), the /subscribe 200+in-band-error contract (PRJ-05), securitySchemes (PRJ-15). MCP: honor/drop the query filter (PRJ-07 — honoring is trivial), exclude Pub (PRJ-08), fix the batch doc (PRJ-09), wrap non-object outputs or document (PRJ-10), add retryable (PRJ-13), deterministic error merge (PRJ-12).

Gate: a test that generates the doc and asserts it against a hand-written golden file matching the routes tests' actual bodies; MCP search respects query and excludes Pub.

Unit 7 — Hygiene + coverage (HY-01..HY-13, COV-01..07, SRV-04..SRV-10, OAI-05..OAI-09, FWD-08..FWD-10, FWD-12)

Mechanical, parallelizable cleanup:

  • HY-01 (openapiv3 → dev-deps), HY-05 (unused deps), HY-08 (feature triplication), HY-11/HY-12.
  • HY-02: missing_docs sweep (110 warnings) before crates.io.
  • SRV-05..SRV-10, OAI-05..OAI-09, FWD-08..FWD-10, FWD-12: per-finding small fixes.
  • COV-01..07: backfill the uncovered regions identified in Part I, prioritizing the forwarding-core error/binary/auth paths and the tungstenite pump path.
  • HY-13: wire or delete the vacuous /publish test.

Gate: cargo doc -W missing_docs clean; cargo tree -d reduced; coverage of forward.rs ≥ 95% lines.

Suggested sequencing

Unit 1 (auth seams)        → no deps; do first (smallest, highest value)
Unit 3 (gateway)           → no deps; independent
Unit 2 (outbound requests) → no deps; largest; can start in parallel
Unit 5 (consumer adapters) → no deps; small
Unit 4 (WS robustness)     → depends on nothing in-tree; coordinate WS-12 with alkcall
Unit 6 (projections)       → after Unit 3 (shares routes.rs/error mapping docs)
Unit 7 (hygiene/coverage)  → continuous; land the dep/doc items any time

Units 1, 3, 5 touch disjoint files and can proceed in parallel. Unit 2 and Unit 4 both touch byte_adapter.rs only via HY-09 (trivial). Unit 6's doc-golden work should follow Unit 3 so the runtime contract is settled before it is documented.

On the baseline objective

The extraction is structurally sound: the architecture held, the wire formats are right, the no-env-vars and Internal-by-default invariants are real and tested, and the end-to-end integration suites (the thing alkcall's review found missing) exist and are substantive. What needs attention is the edge behavior: the seams where the default surface meets extension points (extra routes, /schema, MCP), the outbound forwarding path (URL construction, redirects, retries, timeouts, SSE parsing), and the projections' fidelity to the runtime they describe. None of these block the crate's core function; all of them are the kind of thing that surfaces as a production incident rather than a failed test. Treat Unit 1 and Unit 2 as the gate for any deployment-facing milestone.


Verification log (this pass)

  • All findings carry file:line references verified against tree 4a825d3 by the subsystem passes; the consolidating pass re-verified: SRV-01/SRV-02 (adapter.rs:170-183, routes.rs:95-137, 240, 380-399), SRV-09 (adapter.rs:243-256, to_openapi.rs:64-68), GW-01/GW-03 (routes.rs:213, 257-266; error.rs:17-59), WS-02/WS-04 (byte_adapter.rs:91-103, 135-180, 343-345; from_wss.rs:154-166, 189-195), WS-10 (upgrade.rs:114-143), FWD-02/FWD-03/FWD-05 (forward.rs:56-151; http_client.rs:105-173), SRV-08/COV-04 (decoy.rs:100-136), and the vacuous test (routes.rs:1572-1583).
  • Subsystem passes verified dependency-level claims against vendored sources: axum 0.8.9 (route_layer/merge semantics, SSE, extractor body limits), hyper 1.11 (timer gating), reqwest 0.13.4 (redirect scrub, scheme rejection), reqwest-retry 0.9.1 (retryable strategy), reqwest-middleware 0.5.2 (stack order), rmcp 1.8.0 (body collect, list_all_tools, streamable-http transport, bearer application), tokio-tungstenite/tungstenite 0.28 (max_message_size), yaml_serde 0.10.7 (alias/depth limits), retry-policies 0.5.2 (backoff bounds), and alkcall 0.1.1 (registry/discovery/dispatch/wire).
  • Three findings were confirmed empirically with scratch harnesses outside the repo (no repo changes): the extra-routes auth bypass (SRV-01), the $ref recursion abort (OAI-01), and the SSE chunk-boundary event loss (FWD-06).
  • Coverage figures: cargo llvm-cov --all-features (summary + JSON export), 2026-08-28.
  • Cross-crate finding WS-12 is filed against alkcall, not this crate; it is included because the WS path makes it trivially reachable.