A logical request's Retry-After waits are now bounded by
max_total_retry_duration, and a retry storm can no longer re-arm a
full ceiling per attempt:
- BudgetClock anchored per logical request by RetryGateMiddleware
(into the request Extensions, shared across retry attempts) and
read by the inner RetryAfterMiddleware every attempt; the monotonic
anchor projects the hard stop through wall-clock steps.
- maybe_sleep_for truncates the sleep to the remaining budget;
a spent budget skips the sleep entirely.
- record() keeps the EARLIEST deadline per URL (retry storms cannot
extend the first-seen deadline); a refresh that cannot make it
under the budget hard stop drops the entry so the next attempt
starts immediately instead of parking.
- Middleware without a budget anchor (budget = 0) keeps the prior
semantics; the shared client now wires
HttpClientConfig.max_total_retry_duration into the Retry-After
middleware.
- Wire tests (tests/retry_after_budget.rs): always-429 responder with
a 300 s Retry-After is bounded by budget + one attempt; separate
logical requests still honor the recorded throttle window.
- FWD-12 atomic reload pairing and the FWD-15 stream-client split
untouched (stack built in build_client_with_pems for both).
- 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.
§5 records the post-OAI-12 from_yaml contract: duplicate keys rejected
loudly on YAML (with the empirical correction that serde_json 1.0.151's
Value path last-wins rather than errors — the YAML side is the stricter
one), non-finite floats rejected with JSON-pointer context, merge keys
applied via apply_merge (shallow, referencing keys win; the one
deliberate YAML 1.2 deviation), scalar-key stringification matching the
core schema, and the no-new-bounds note (the walk stays inside
yaml_serde's parse-time limits).
Verification: cargo doc --no-deps clean; module-doc cross-check in
openapi_spec.rs matches this contract.
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).
- AxumFraming arms were exercised only indirectly via tungstenite
(shared generic pumps); drive the axum message types directly with
an in-process fake WebSocket (futures mpsc-backed Sink+Stream
stand-in for the split halves)
- text test: read pump maps a text message to the WriteMsg close
carrying 1002 + the text reason
- cap-trip test: an above-MAX_CHUNK_LEN header through the write pump
closes with 1011 naming the violation
Verification: scripts/verify.sh OK (345 passed), test-support suite
ok, clippy -D warnings clean, fmt clean
- WsTimeouts { idle, write } request extension mirrors ChannelsPolicy:
a deployment layers it on a WS route (bare-registry routes included)
to set the pump knobs per route
- precedence: extension present replaces the router state entirely;
absent falls back to SessionState (adapter-configured idle) and the
crate default write window — a Default impl never clobbers the
adapter-configured idle knob
- upgrade.rs module + handler docs now state the real defaults for
bare-registry routes (60 s idle + 60 s write, 64-session semaphore,
handler-private WsSessions) and the extension surface
- split_ws_to_bytes_idle_with_write exposes the WS-18 write window to
run_channels_session; acceptance test drives a bare-registry route
with a 150 ms extension idle window (1001 eviction observed)
Verification: scripts/verify.sh OK (343 passed), test-support suite
ok, clippy -D warnings clean, fmt clean
- DEFAULT_WS_WRITE_TIMEOUT (60 s, WS-01 knob family): one outbound WS
send that stays unsent past the window (peer stopped reading)
evicts the connection — the send path was slot-bounded but
time-unbounded
- bound is per send call: a slow-but-draining peer resets it with
every message emitted; only a fully stalled sink trips it
- on timeout the pump signals the stream error (InvalidData naming
the write stall) and ends WITHOUT a close frame — a clogged socket
cannot receive one and the close send would park on it
- test-support split helper with both knobs explicit backs the
scaled eviction test (clogged duplex, eviction well inside the
5 s slack)
Verification: scripts/verify.sh OK (343 passed), clippy -D warnings
clean, fmt clean
Documents the CON-18 disposition in the v1 session-lifetime section
(teardown handle still future work, but a dead import no longer leaves
a permanent monitor task) and adds the consequence pair: dead imports
self-clean after EOF + bounded grace; registrations landing past the
grace window fall back to the 30s sweeper deadline.
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
- WriteMsg::CloseWith now carries (code, reason); the write pump sent
the hardcoded "text messages not supported" string for the idle
(1001) and inbound-frame (1011) closes as well as the text (1002)
close
- close_reason module: canonical per-cause reason strings, shared by
the close frames and the stream-error diagnostics (they already
matched for idle/oversize; now single-sourced)
- reason asserts added to the idle-stall and forever-dribble 1001
tests (reason names the no-chunk-progress cause) and a new text
frame test asserts 1002 + the distinct text reason
Verification: scripts/verify.sh OK (342 passed), clippy -D warnings
clean, fmt clean
SSE payload contract (non-JSON frames carry {data, event}; JSON frames
surface as themselves), the placeholder routing rule (placeholder keys
never double-emit as query; structural path values are INVALID_INPUT),
and the literal-percent trade-off (% in values always encoded; % in
assembly-supplied template text survives — the assembly owns the
upstream-semantics choice, per the ADR-066 trust boundary).
- byte-cap check moved ahead of poll_ready/try_send so the mux sees
the InvalidData stream error at the failing call instead of the
write pump emitting a mid-stream 1011 close (the chunk was already
committed to the wire path)
- pump-side over-cap arm replaced by a debug_assert (defense-in-depth
invariant; the error was unreachable for single-call writes once the
pre-send check exists)
- write_tx_mut renamed write_tx_ref with an updated doc contract
- WS-05's over-cap pump test replaced by a pre-send rejection test
(error surfaces at the first write, names the cap) + a cap-edge
test (exactly PENDING_BUFFER_CAP still flows)
Verification: scripts/verify.sh OK (341 passed), clippy -D warnings
clean, fmt 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.
Review 002 GW-15 [major]: /publish lost both claimed memory bounds in
the GW-06 streaming rewrite. Unauthenticated POST /publish with chunked
'a'-forever (no newline) grew the heap with the upload until OOM, and
each poll re-scanned the whole buffer (O(n^2) on top).
- BufferedLines: cap the unterminated tail against
MAX_PUBLISH_LINE_BYTES immediately after every chunk read (checked
before yielding, even with no \n seen) and re-check on the
trailing-EOF mem::take path; a breach aborts the whole reader
(pending lines included) with the same terminal INVALID_INPUT
LineCap error. Complete lines keep the baseline at-cap semantics.
- Whole gateway router: explicit request-body-limit layer
(GATEWAY_BODY_LIMIT = 2 MiB + 64 KiB framing headroom). A raw-Body
handler never consults axum's DefaultBodyLimit (that is an extension
extractors read), so /publish had no whole-body cap at all. The layer
pre-rejects oversized declared Content-Length and wraps chunked
uploads in a counting stream; both answer plain-text 413. It
deliberately sits above the per-line cap so a single over-cap line
still surfaces the semantic line-cap error. Upstream body read
failures are not flagged as limit-exceeded (disconnects are not 413).
- New wire tests: streamed never-newline over-cap rejections (both the
streamed multi-chunk and trailing-EOF shapes), 413 on chunked
over-limit uploads, 413 on oversized declared Content-Length, cap
breach batched with complete lines, at-cap line still round-trips,
declared-length over-limit pre-rejection.
Module status mapping note (GW-16 tracks the drift): hand-rolled
pre-dispatch rejections use 400/INVALID_INPUT while mid-stream chunk
errors map 422 through gateway::error; normalizing is GW-16, not GW-15.
Verification: cargo test 305+5 pass, cargo test --all-features pass,
cargo clippy --all-targets -- -D warnings pass (--all-features too),
cargo fmt --check pass.
- 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