rust-web-server 17.77.0

A dependency-minimal Rust web platform: HTTP/1.1, HTTP/2, and HTTP/3 server, reverse proxy, and application framework with routing, middleware (auth, rate limiting, tracing), an async ORM, background jobs, object storage, and a mailer. Runs as a zero-code config-driven proxy or as a library crate. No third-party HTTP dependencies.
Documentation
[Read Me](../README.md) > [Spec](.) > TODO

# TODO — rws v17.43.0+

Consolidated, prioritized task list synthesized from GAPS_V3.md, IDEAS.md, ADMIN_ROADMAP.md, and all open roadmap items. Items are ordered within each tier by the ratio of impact to implementation effort.

**Status as of 2026-07-03:** Priority 1 is fully complete. Priority 2 is the current focus. Code inspection this date found two Priority 2 items were silent-failure bugs (config accepted but ignored, no error) rather than plain feature gaps — the static-site action and the load-balancer `strategy` field. Both were promoted to the top of the tier with exact file:line root causes, and both are now fixed (see below).

---

## ✅ Priority 1 — Complete

All six blocking gaps have been resolved. rws is now suitable for real production workloads.

- [x] **Upstream connection pooling** (`src/proxy/pool.rs`) — `ConnPool` (Mutex-backed, per-backend VecDeque of TcpStream) is embedded in `ReverseProxy`. Idle connections are reused when the backend sends `Connection: keep-alive`; chunked `Transfer-Encoding` is decoded so body length is known. Share pools across instances with `Arc<ConnPool>` via `ReverseProxy::with_pool()`. Closes GAPS_V3 §1.1 and §2.6.

- [x] **TLS to HTTP/2 upstreams** (`H2ReverseProxy`) — `H2ReverseProxy` now supports `https://` and `h2s://` backend URLs. `Backend::parse()` detects TLS schemes (port defaults to 443); `forward_h2_async` branches: plain path uses `TcpStream` directly; TLS path wraps in `tokio_rustls::TlsConnector` with ALPN `h2` before the h2 handshake. Generic `send_h2_request<T>` accepts both stream types. Requires `http2` feature. Closes GAPS_V3 §2.2.

- [x] **TLS to gRPC upstreams** (`grpcs://`) — `GrpcProxy` inherits TLS from `H2ReverseProxy`. `grpcs://` and `https://` backend URLs connect over TLS with ALPN `h2`. Closes GAPS_V3 §2.3.

- [x] **TLS to WebSocket upstreams** (`wss://`) — `WsProxy` now accepts `wss://host:port` backend URLs (port defaults to 443). TLS path uses `rustls::StreamOwned` + a single-thread polling loop (5 ms timeout per side, 1 ms sleep when idle) to avoid the deadlock that arises when sharing a TLS stream between two blocking relay threads. Plain `ws://` backends continue to use the two-thread `std::io::copy` approach. Requires `http-client` or `http2` feature; returns 502 otherwise. Closes GAPS_V3 §2.4.

- [x] **Persistent sessions** (`src/session/mod.rs`) — Added `DbSessionStore` backed by the model layer (`rws_sessions` table: id TEXT PK, data TEXT URL-encoded, expires_at INTEGER epoch). Auto-creates table on first `new()`. All methods return `Result`. Added `RedisSessionStore` backed by a hand-rolled RESP v2 client (no external crate); sessions keyed as `rws:sess:{id}`, TTL via `SET … EX`, auto-reconnect. `from_env()` reads `RWS_REDIS_HOST/PORT/PASSWORD/TTL_SECS`. 10 new tests. Closes GAPS_V3 §3.5.

- [x] **Streaming response passthrough through proxy** — Added `Response::stream_pipe: Option<Box<dyn Read + Send>>` and `Server::pipe_stream()`. `ReverseProxy::try_backend()` now reads only headers, detects streaming signals (`Content-Type: text/event-stream`, `Transfer-Encoding: chunked`, `Content-Length > 1 MB`), and for matching responses sets `stream_pipe` to a `ConcatReader(body_prefix, TcpStream)` instead of buffering. `pipe_stream` forwards chunked-backend bytes as raw passthrough; plain SSE bytes are re-encoded as chunks. Closes GAPS_V3 §1.2.

- [x] **Email / SMTP** (`src/mailer/mod.rs`, `mailer` feature) — Added `Mailer`, `Email`, `EmailBuilder`, `MailerError`, `SmtpTls`. Hand-rolled SMTP client (no external crate): plain TCP (`SmtpTls::None`), STARTTLS upgrade (`SmtpTls::Starttls`, requires `http-client`/`http2`), implicit TLS (`SmtpTls::Smtps`). AUTH PLAIN, RFC 5322 message builder with text/html/multipart bodies, SMTP dot-stuffing. `Mailer::from_env()` reads `RWS_SMTP_HOST/PORT/USER/PASSWORD/FROM/TLS/TIMEOUT_MS`. 14 tests. Closes GAPS_V3 §3.1 and GAPS_V2 §5.

---

## Priority 2 — High friction without these

Commonly needed; workarounds exist but are painful. **This is the current focus.**

**Two silent-failure bugs confirmed by code inspection on 2026-07-03 — promoted to the top of this tier.** Both accept config that parses successfully and produce different behavior than the config states, with no error or log line. That's worse than a missing feature (which fails loudly) and each is a small, isolated fix.

- [x] **Static site action in config-driven proxy is a no-op** (`type = "static"`) — Fixed. Added `StaticAdapter` (`src/proxy_config/mod.rs`, "StaticAdapter" section) implementing `Application`: resolves the request path against the configured `root`, tries each `index` entry in order for directory requests (default `["index.html"]`), rejects any `..` path segment (pre- or post-percent-decode) with `403`, and returns `404` for anything else missing. Also canonicalizes and checks `starts_with(root)` as defense-in-depth against symlinks inside `root` pointing outside it. Reuses `Range::get_content_range_of_a_file()` for MIME detection and body construction — same code path the built-in static controller uses. `builder.rs:86-88` now constructs `StaticAdapter::new(root, index)` instead of falling back to `App::new()`. 4 new tests in `src/proxy_config/tests.rs` (serve file, serve directory index, reject traversal, 404 on missing file); full `cargo test` passes (1132 unit + 72 doc tests). Docs updated: `docs/proxy/config-driven.mdx`, `DEVELOPER.md` (building blocks table + Use Case #52), `llms.txt`; removed the stale "Coming Soon" callout from `docs/reference/roadmap.md`. Closed GAPS_V3 §2.8 and IDEAS.md §5.

- [x] **`strategy` field on `[[upstream]]` is parsed but never read** — Fixed. Added `LoadBalanceStrategy` enum (`src/proxy_config/mod.rs`, "DynamicProxy" section) with `RoundRobin` (default, also the fallback for unknown/empty values), `Random`, `IpHash`, and `LeastConnections`. `DynamicProxy::new()` now takes a `strategy: String` (parsed once via `LoadBalanceStrategy::parse`) and a `connections: Arc<RwLock<HashMap<String, Arc<AtomicUsize>>>>` map; `next_backend(client_ip)` branches on the strategy — `IpHash` hashes the client IP with `DefaultHasher` for per-client stickiness, `LeastConnections` picks the live backend with the lowest counter, `Random` mixes a nanosecond timestamp with the existing round-robin counter (no new crate dependency). A `ConnectionGuard` (RAII, decrements on `Drop`) tracks in-flight counts around each proxied request for `LeastConnections`. Both `builder.rs` call sites (`proxy` and `grpc` actions) now look up `upstream.strategy` and pass it through. 6 new white-box unit tests exercise each strategy directly against `DynamicProxy`, plus one end-to-end test (`config_driven_app_ip_hash_strategy_is_sticky_end_to_end`) that spins up two real TCP backends and confirms a client IP is pinned to one of them through the full `ProxyConfig::from_str` → `builder::build` → `ConfigDrivenApp` path. Full `cargo test` passes (1139 unit + 72 doc tests). Also fixed a nested-table bug in `llms.txt`'s config-driven proxy example (`upstream = "api"` was written directly under `[route.action]` instead of `[route.action.proxy]`, which the hand-rolled TOML parser — keyed by exact section path — would silently fail to parse; caught while adding the strategy docs there). Docs updated: `docs/proxy/config-driven.mdx`, `docs/configuration/config-file.md`, `DEVELOPER.md` (building blocks table + Use Case #52), `llms.txt`; removed the "Coming Soon" load-balancing-strategies callout from `docs/reference/roadmap.md`. Closed GAPS_V3 §2.1 and IDEAS.md §3.

- [x] **Background job queue** (`src/jobs/mod.rs`, `jobs` feature) — Added. `Job` trait (blanket-implemented for `Fn() -> Result<(), String> + Send` closures, so a plain closure or a named struct both work) and `JobQueue::new(workers)`, an in-memory fixed worker pool. `.submit(job)` enqueues; a failing job retries on the same worker thread with exponential backoff (`.max_retries(n)` / `.backoff(initial, multiplier)`, default 3 retries / 500ms / 2x — `max_retries` counts retries *after* the first attempt); `.join()` drains and waits. Also added `PersistentJobQueue` (gated additionally on `model-sqlite`/`model-postgres`/`model-mysql`), backed by a `rws_jobs` table via the model layer: since a closure can't be serialized, persisted jobs are `(job_type, payload)` string pairs dispatched to a handler registered by name via `.register(job_type, fn)`. `PersistentJobQueue::new(pool).await` creates the table and resets any row left `running` by a crash back to `pending`; `.enqueue()`/`.enqueue_with_retries()` persist a job; `.start(workers)` spawns polling worker threads (each with its own single-threaded Tokio runtime, since the rest of the queue is plain-thread/std-only and doesn't otherwise require a runtime); `.tick().await` runs one poll-claim-execute cycle for tests or a caller-owned loop. Row claiming uses `UPDATE ... WHERE status = 'pending'` so concurrent workers (same process or cross-process against the same DB) can't double-claim a row. 5 `JobQueue` tests + 5 `PersistentJobQueue` tests (incl. crash recovery: a row manually left `running`, then a fresh `PersistentJobQueue::new` against the same pool picks it back up). Full `cargo test` (default features) unaffected since `jobs` is opt-in; verified separately with `cargo test --features jobs` (5 passed) and `cargo test --no-default-features --features jobs,model-sqlite` (10 passed). Docs: new `docs/features/jobs.md` page (registered in `astro.config.mjs`), `DEVELOPER.md` (building blocks rows + Use Case #62), `README.md`, `llms.txt` (new section + module index + `reference/api.md` + `getting-started/features.md`). Closes GAPS_V3 §3.3 and GAPS_V2 §7.

- [x] **File / object storage abstraction** (`src/storage/`, `storage-local` / `storage-s3` features) — Added. `Storage` trait (`put`/`get`/`delete`/`url`) plus `LocalStorage` (files under a root dir; rejects `..` key segments; `.with_base_url()` for serving uploads back over HTTP) and `S3Storage` (AWS S3, R2, MinIO — path-style addressing, `S3Storage::from_env()` reads `RWS_S3_BUCKET/REGION/ACCESS_KEY/SECRET_KEY/ENDPOINT`). `S3Storage` signs every request with hand-rolled AWS SigV4 (`src/storage/aws_sigv4.rs`) using `hmac`+`sha2` over the existing `crate::http_client::Client` — no AWS SDK. One deviation from the original GAPS_V2 spec text: `storage-s3` depends on `hmac`+`sha2` directly (same crates the `auth` feature already uses for JWT HS256) rather than `crypto` (Argon2 password hashing), since SigV4 needs HMAC-SHA256, not password hashing. 32 tests: 9 `LocalStorage`/`aws_sigv4` unit tests plus an 18-test suite for `S3Storage`/`aws_sigv4`/`S3Config::from_env` that spins up a local mock TCP "S3" server to verify the actual request path, headers (`Authorization`, `x-amz-date`, `x-amz-content-sha256`, exactly one `Host`), and body bytes end-to-end — not just the signer in isolation. Verified across `storage-local`, `storage-s3`, and both together; full `cargo test` (default features) unaffected. Docs: new `docs/features/storage.md` page (registered in `astro.config.mjs`) plus a cross-link from `docs/building-apps/forms-uploads.md` (the exact gap this closes); `DEVELOPER.md` (2 building-blocks rows + Use Case #63), `README.md` (new section + 2 feature-table rows), `llms.txt` (new section + module index + `reference/api.md` + `getting-started/features.md`). Closes GAPS_V3 §3.2 and GAPS_V2 §6.

- [x] **OpenAPI / Swagger schema generation** (`src/openapi/`, `openapi` feature) — Added. `OpenApiConfig::new(title, version)` + `build_spec(&config, &[RouteInfo])` produce a hand-built OpenAPI 3.0.3 JSON document (same technique as the MCP server's JSON-RPC responses — no `serde_json` dependency). `AppWithState::openapi(config)` / `AsyncAppWithState::openapi(config)` are the ergonomic entry points: each snapshots `self.route_entries()` at call time and registers `GET /openapi.json` (the spec) and `GET /docs` (Swagger UI via the `unpkg.com/swagger-ui-dist` CDN). Scope is deliberately paths/methods/path-params only (`:id`/`*path` → `{id}`/`{path}` with a `parameters` entry) — no request/response body schemas, since Rust has no runtime type reflection to extract a JSON Schema from a `#[derive(Validate)]` struct without a much larger macro-level feature; every operation gets a generic `200 OK` response, documented as an explicit scope boundary rather than a silent gap. As a side effect of wiring `AsyncAppWithState::openapi()`, gave it a `route_entries()` method it didn't have before (mirroring `Router`/`AppWithState`), and moved the shared `segments_to_pattern` helper (previously private to `Router`) into `src/router/matcher.rs` alongside the `Segment`/`parse_pattern`/`try_match` dedup from the earlier dispatch-mechanism fix, so both app types build route-info strings from the same code. 19 new tests: 12 for `build_spec`/`swagger_ui_html` in isolation (title/version/description, path-param conversion for both `:name` and `*name`, multi-method-per-path merging, JSON escaping), 4 end-to-end for `AppWithState::openapi()`, 3 end-to-end for `AsyncAppWithState::openapi()` (via `Application::execute`, not just unit-level). Verified across `openapi`, `http2,openapi`, and default+`openapi` feature combinations; default build (no `openapi` feature) unaffected. Docs: new `docs/features/openapi.md` page (registered in `astro.config.mjs`) plus `getting-started/features.md` and `reference/api.md`; `DEVELOPER.md` (building-blocks row + Use Case #64), `README.md`, `llms.txt` (new section + module index). Closes GAPS_V3 §3.4 and GAPS_V2 §8.

- [x] **Async ORM** — `src/model/` rewritten to use `sqlx 0.8` as the async database driver. `DbPool` wraps `sqlx::Pool<Db>` (cheap to clone); `DbTransaction` wraps `sqlx::Transaction<'static, Db>`. Old `DbConnection` and `PooledConnection` types removed. All ORM methods (`save`, `find_all`, `find_by_id`, `delete_by_id`, `count`, `exists_by_id`, `QueryBuilder` terminals, relation `.load()`, `migrate()`, `migration_status()`) are now `async fn` and return `Result<_, DbError>`. `DbSessionStore` updated to `async fn`. All model and session tests use `#[tokio::test]`. 16 model integration tests + 31 session tests pass. Closes GAPS_V3 §3.7.

- [x] **Per-route timeouts** — Added `src/timeout/mod.rs` (always compiled, no new feature flag or deps). A real design constraint discovered along the way: `TimeoutLayer` **cannot** be implemented as a `Middleware` (`next: &dyn Application`, a borrowed non-`'static` reference) and still return early — `std::thread::scope` is the only sound way to run borrowed data on another thread, and it always blocks until that thread finishes before returning, which would make a "timeout" that silently waits the full duration anyway (misleading, so rejected). Instead: `with_timeout`/`with_timeout_state` wrap an individual `Router`/`AppWithState` *handler closure* at registration time, where the surrounding code already owns `Arc<S>`/`Arc<F>` — giving them to a genuinely detached `std::thread::spawn` (not scoped) and racing via `mpsc::recv_timeout` achieves real early-return. `with_timeout_state` requires `S: Clone` for exactly this reason (owns a copy to hand to the background thread); `with_timeout_async` needs no such bound since `AsyncAppWithState` already passes state as owned `Arc<S>`, and is backed by `tokio::time::timeout` for genuine cancellation (dropping a suspended `Future` actually stops it — the only one of the four that can make this claim honestly). `TimeoutLayer::new`/`::from_arc` wraps a whole owned/shared `Application` (not per-route within a `Router`, but a real, correctly-implemented `Application`-level combinator) — this is what backs the config-driven proxy's new `timeout_ms` flat key under `[route.middleware]` (bounds that route's total time including its other middleware; wraps `CompiledRoute.handler`, which is already an owned `Arc<dyn Application + Send + Sync>`, so no plumbing changes needed beyond adding the field and one `if let` in `apply_middleware`). Added `#[derive(Clone)]` to `PathParams` (previously had no derives at all) since the sync timeout wrappers need to clone it across the thread boundary — safe, additive, backward-compatible. 17 new tests: 11 for the sync helpers/`TimeoutLayer` in `src/timeout/tests.rs` (including one asserting the call returns in well under the slow handler's full sleep duration — the actual point of the feature, not just "it compiles"), 3 for `with_timeout_async` (including a test that proves genuine cancellation via a flag that must never be set if the future is truly dropped, not just "result discarded"), 4 in `src/proxy_config/tests.rs` (TOML parsing + an end-to-end test with a real slow mock TCP backend proving `timeout_ms` cuts off a slow upstream, plus a control test proving routes without `timeout_ms` still wait for the full response). Verified across default/`http1`/`http2` feature sets — `http1` naturally excludes the `with_timeout_async` tests since that variant is `#[cfg(feature = "http2")]`. Docs: new `docs/features/timeouts.md` page (registered in `astro.config.mjs`) plus `getting-started/features.md` and `reference/api.md`; `DEVELOPER.md` (building-blocks row + `timeout_ms` row in the config-driven-proxy middleware table + Use Case #65); `README.md`, `llms.txt` (new section + module index). Closes GAPS_V3 §1.5.

- [x] **Request ID middleware** (`src/request_id/mod.rs`) — Added `RequestIdLayer` (implements `Middleware` — unlike `TimeoutLayer` from the previous item, this one has no borrow-lifetime obstacle, since it only needs to clone `Request` within the same call frame, not hand it to another thread). Echoes an incoming `X-Request-Id` unchanged if the caller already sent one (so one ID follows a request across service boundaries), else generates a fresh one via `generate_request_id()` (UUID-v4-*shaped*, not a spec-compliant UUID and not crypto-random — same non-crypto splitmix64-based technique already used for session IDs elsewhere in this crate; documented clearly as unsuitable for security tokens) and injects it into the request *before* the handler runs. Always sets the same value on the response. `.header(name)` overrides the header name (e.g. `X-Correlation-Id`). "Accessible to application code" (the TODO's own phrasing) needed no new context/extension mechanism: injecting the ID as a request header makes it readable through the header APIs handlers already have. Added a `RequestId` extractor to `src/extract/mod.rs` (`FromRequest` impl, never fails, empty string if unset) as the ergonomic way to read it, alongside the existing `Body`/`BodyText`/`Query`/`RequestHeaders`. Deliberately did *not* touch `src/log/mod.rs`'s access-log formats — that's a separate integration a caller can already build by reading the response header in their own logging middleware, out of scope for "add the middleware." 10 new tests in `src/request_id/tests.rs`, including one that proves the ID was injected into the request *before* dispatch (an app that echoes back the header it received as the body — not just asserting the response header exists, which alone wouldn't prove injection timing) and one proving an incoming ID is preserved byte-for-byte rather than replaced. Also fixed a stray orphaned closing code fence at the very end of `DEVELOPER.md` — a leftover artifact from the per-route-timeouts edit in the previous session, unrelated to this feature but caught while adding Use Case #66 right after it. Verified across default/`http1`. Docs: new `docs/features/request-id.md` page (registered in `astro.config.mjs`) plus `getting-started/features.md` and `reference/api.md`; `DEVELOPER.md` (2 building-blocks rows, extended the `extract` row for `RequestHeaders`/`RequestId` too, Use Case #66); `README.md`, `llms.txt` (new section + module index); `CLAUDE.md`'s "Built-in middleware" list (a factual architecture inventory, not an instruction) updated to include `RequestIdLayer`. Closes GAPS_V3 §1.6.

- [x] **JWT / Basic auth from `rws.config.toml`** — Wired `AuthConfig::Jwt`/`AuthConfig::Basic` (previously parsed-then-discarded no-op placeholders in `builder.rs::apply_middleware`, gated `#[cfg(feature = "auth")]`) to the existing `JwtLayer`/`BasicAuthLayer`. `type = "jwt"` reads `secret_env`, unchanged from the original design. **Renamed `AuthConfig::Basic`'s field from `users_file` to `htpasswd_file`** (and the parsed TOML key to match) — a small, deliberate breaking change to a `pub` enum whose variant was a complete no-op until this commit, bringing the actual key in line with what this very TODO entry, `IDEAS.md` §4, and `PROXY_SERVER_CONFIG.md` had already been promising. Added `BasicAuthLayer::from_htpasswd_file(path) -> Result<Self, String>` to `src/auth/mod.rs`, supporting plain-text passwords (Apache `htpasswd -p` format) and a `{SHA256}` scheme (**rws's own**, using the already-in-tree `sha2` crate) — deliberately **not** Apache's real `{SHA}` (SHA-1), `$apr1$` (iterated MD5), or bcrypt, since this crate has no third-party crypto dependencies beyond audited RustCrypto hash crates and hand-rolling SHA-1/MD5/bcrypt isn't a risk worth taking for an auth check; a real Apache-generated htpasswd file (which defaults to bcrypt/`$apr1$` in modern tool versions) will not verify here — documented prominently, with an `openssl` one-liner for generating `{SHA256}` entries instead of requiring Rust code. Both the JWT and Basic wiring fail open **with a stderr warning** per-route (unset/empty `secret_env`, missing/unreadable `htpasswd_file`, or no `auth` feature at build time) rather than either blocking every request or aborting the whole config load. Made `auth::base64_encode` `pub(crate)` so `proxy_config`'s tests could build `Authorization: Basic` headers without duplicating a base64 encoder. 22 new tests: 12 in `src/auth/tests.rs` (plain text, `{SHA256}`, unknown user, comments/blank lines, multiple users, missing file, missing header, **plus a fixed-vector test cross-checking `{SHA256}` output against independently-computed `openssl dgst -sha256 | openssl base64` output** — not just self-consistency) and 10 in `src/proxy_config/tests.rs` (2 parsing + 8 end-to-end covering both JWT and Basic, gated `#[cfg(feature = "auth")]`, including the fail-open-on-misconfiguration cases). Verified across default, `default+auth`, `http1`, and `http1+auth`. Docs: `docs/features/auth.md` (new htpasswd section + config-driven-proxy section), `docs/proxy/config-driven.mdx` (replaced the "Coming Soon" auth callout with real docs for all three `type`s), removed the matching stale entry from `docs/reference/roadmap.md`; `DEVELOPER.md` (2 building-blocks rows + extended per-route-middleware-keys table + Use Case #67); `README.md`, `llms.txt` (also fixed pre-existing, unrelated stale `build_jwt`/`verify_jwt` signatures and a `JwtLayer::new("...")` example that wouldn't actually compile — found while editing the adjacent section). Closes GAPS_V3 §2.7 and IDEAS.md §4.

- [x] **ForwardAuth middleware** (`src/auth/forward.rs`) — Added `ForwardAuthLayer` (implements `Middleware`, gated `#[cfg(feature = "auth")]` alongside the rest of `src/auth/`). For every request it sends a `GET` to a configured auth URL with all incoming request headers copied on, via the existing `crate::http_client::Client` — no new dependency. On `2xx`, the request proceeds; each `.copy_header(name)` value present on the auth response **replaces** (not appends alongside) any same-named header already on the forwarded request — a deliberate security fix over the naive "just append" approach, since `Request::get_header`'s first-match lookup would otherwise let a client-forged header of the same name win over the auth-service-verified one. On any other status, the auth response is returned to the client verbatim (status, headers, body), which required adding a new `pub fn headers(&self) -> &[(String, String)]` accessor to `crate::http_client::Response` (it previously only exposed single-name lookup) so all headers — not just a hardcoded subset — could be forwarded; hop-by-hop headers and `Content-Type`/`Content-Length` (which the framework derives from `content_range_list`, not from `Response.headers`) are excluded via a local `EXCLUDED_PASSTHROUGH_HEADERS` list, and the original numeric status is mapped back to a reason phrase via the existing `Response::status_code_reason_phrase_list()` since `http_client::Response::parse_status()` discards the reason phrase text. One deviation discovered during testing: the outbound `Client` **follows redirects by default** (up to `max_redirects`), which would silently resolve a `3xx` from the auth service (e.g. an OAuth login redirect) instead of returning it — fixed by calling `.max_redirects(0)` on the internal client so the auth service's own `3xx` reaches the verbatim-passthrough path intact. Auth-service unreachable (connection refused, timeout, DNS failure) returns `502 Bad Gateway` — fails closed. Implemented as `src/auth/forward.rs` (a submodule registered via `pub mod forward;` in `src/auth/mod.rs`) per this TODO entry's file path, rather than directly in `src/auth/mod.rs` as IDEAS.md §8 originally sketched. 10 new tests in `src/auth/forward/tests.rs`, each spinning up a real one-shot `TcpListener`-backed mock auth server (same pattern as `spawn_tagged_backend` in `src/proxy_config/tests.rs`) rather than mocking `http_client::Client`: 2xx pass-through with no `copy_header` configured, header copied from the auth response, **the client-forged-header-override security case** (asserts exactly one `X-User-Id` header reaches the downstream app and it's the trusted value, not the client's), a `copy_header` absent from the auth response leaving the original untouched, multiple `copy_header`s, 4xx verbatim pass-through (status/`WWW-Authenticate`/body preserved), a redirect (`302`+`Location`) preserved rather than followed, hop-by-hop/framing headers excluded from pass-through, auth-service-unreachable → 502, and confirmation that the full incoming header set (e.g. `Cookie`) actually reaches the auth service's request. Verified across default (`http3`), `auth`, and `http1,auth` feature combinations; full `cargo test` passes on all three (1180/1212/1212 unit tests respectively, no regressions). Docs: `docs/features/auth.md` (new "Forward-auth" section with the builder-options table); `DEVELOPER.md` (building-blocks row + Use Case #68); `README.md` (Security bullet + `auth` feature-table row); `llms.txt` (new "ForwardAuth middleware" section, middleware table row, module index line, feature table row, security checklist line). Closes GAPS_V3 §2.9 and IDEAS.md §8.

- [x] **Cookie signing and encryption** (`src/cookie/crypto_ext.rs`, `crypto` feature) — Added `signed_cookie(value, secret) -> String` / `verify_signed_cookie(signed, secret) -> Option<String>` (HMAC-SHA256, output `"<value>.<hex-signature>"`) and `encrypted_cookie(value, key) -> String` / `decrypt_cookie(encrypted, key) -> Option<String>` (AES-256-GCM, output `"<hex-nonce>.<hex-ciphertext-and-tag>"`), matching the exact function names/signatures this TODO entry and GAPS_V3 §3.11 specified. Extended the existing `crypto` feature (previously `dep:argon2` + `dep:rand_core` for `hash_password`/`generate_token`) with `dep:hmac` + `dep:sha2` (already optional deps, previously only wired to the `auth` feature) and one genuinely new dependency, `aes-gcm = "0.10"` (RustCrypto, pure-Rust, NCC-Group-audited, declared `rust-version = "1.56"` — comfortably under this crate's 1.75 MSRV floor, checked against crates.io's version metadata before pinning since this is the project's first symmetric-cipher dependency). `encrypted_cookie`'s `key: &[u8]` accepts any length — it's SHA-256-hashed into the 256-bit key AES-256-GCM requires, the same ergonomic convenience `build_jwt`'s HMAC secret already has (`HmacSha256::new_from_slice` accepts any key size natively; `Aes256Gcm` does not, hence the hash-first step). A fresh random 96-bit nonce (`rand_core::OsRng`) is generated on every `encrypted_cookie` call, so the same plaintext+key never produces the same ciphertext twice. Both `verify_signed_cookie` and `decrypt_cookie` return `None` on *any* failure (missing separator, malformed hex, tampering, wrong secret/key) rather than a typed error — deliberately matching `verify_jwt`'s existing `Option`-not-`Result` convention in `src/auth/mod.rs`, so a caller can't use error contents as a tampering oracle. `verify_signed_cookie` splits on the **last** `.` (`rsplit_once`) rather than the first, so a `value` that itself contains dots still round-trips correctly — the fixed-length hex HMAC signature that follows it can never itself contain a `.`; `decrypt_cookie` uses the *first* `.` (`split_once`) since its nonce segment is fixed-length hex for the same reason, just from the other end. Hit one real compiler ambiguity while wiring this up: `aes_gcm::aead::KeyInit` and `hmac::Mac` both define a method named `new_from_slice`, so once both traits are in scope in the same file, plain `HmacSha256::new_from_slice(...)` calls became ambiguous — resolved with fully-qualified syntax (`<HmacSha256 as Mac>::new_from_slice(...)`), no workaround needed for `Aes256Gcm::new_from_slice` since only `KeyInit` applies to it. 20 new tests in `src/cookie/crypto_ext/tests.rs`: sign/verify roundtrip, value-stays-readable (confirms `signed_cookie` is *not* attempting confidentiality), wrong-secret rejection, tampered-value rejection, tampered-signature rejection, missing-separator rejection, malformed-hex rejection, dotted-value roundtrip, composition with `SetCookie::new(...).build()`; encrypt/decrypt roundtrip, plaintext-not-visible-in-ciphertext (confidentiality sanity check), fresh-nonce-per-call (two encryptions of the same value differ), wrong-key rejection, tampered-ciphertext rejection, tampered-nonce rejection, missing-separator rejection, malformed-hex rejection, wrong-nonce-length rejection, any-length-key acceptance, composition with `SetCookie`. Verified across default (`http3`), `crypto`, `http1`, and `http1,crypto` feature combinations; full `cargo test` passes on all (1180/1192/1180/1192 unit tests respectively, no regressions); doctest in the new module's header (a full sign → build cookie → verify flow) also passes. Docs: `docs/building-apps/cookies.md` (new "Signed and encrypted cookie values" section with a comparison table); `DEVELOPER.md` (2 building-blocks rows + a `CryptoError` row that had been missing + Use Case #69); `README.md` (Security bullet + `crypto` feature-table row); `llms.txt` (new "Signed and encrypted cookies" section, `crypto` feature-table row, module index line, security checklist line); `docs/getting-started/features.md` and `docs/reference/api.md` (also added the still-missing `ForwardAuthLayer` entries there while touching these files — a gap left over from the previous session's ForwardAuth task that hadn't reached these two docs). Closes GAPS_V3 §3.11.

- [x] **RS256 / ES256 in `JwtLayer`** (`src/auth/mod.rs`) — Added `JwtLayer::rs256(public_key_pem) -> Result<Self, String>` and `JwtLayer::es256(public_key_pem) -> Result<Self, String>`, plus standalone `verify_jwt_rs256(token, &RsaPublicKey) -> Option<Claims>` / `verify_jwt_es256(token, &p256::ecdsa::VerifyingKey) -> Option<Claims>` (mirroring `verify_jwt`'s existing `Option`-not-`Result` convention). Took the TODO's explicitly offered second option — a new slim `auth-asymmetric = ["auth", "dep:rsa", "dep:p256", "dep:rand_core"]` feature — rather than folding `rsa`/`p256` into the base `auth` feature directly, since that would force every `auth` user (HS256 Bearer tokens, Basic auth via htpasswd) to compile two asymmetric-crypto crates they don't need; `auth-asymmetric` reuses the exact same already-declared optional `rsa`/`p256` deps the `sso` feature uses, so this is a zero-new-Cargo-dependency change, confirmed both crates' `pem` cargo feature (needed for `from_public_key_pem`) is already **on by default** in both crates as currently declared in `Cargo.toml` (`rsa = { version = "0.9", optional = true }`, `p256 = { version = "0.13", features = ["ecdsa"], ... }` — neither sets `default-features = false`). `JwtLayer`'s internal representation changed from a bare `secret: Vec<u8>` field to an enum (`JwtVerifier::Hs256(Vec<u8>)` plus two more variants gated `#[cfg(feature = "auth-asymmetric")]`) — `JwtLayer::new(secret)`'s signature and behavior are fully unchanged, so this is purely additive for existing callers. Both public-key constructors require SubjectPublicKeyInfo PEM (`-----BEGIN PUBLIC KEY-----`, i.e. `openssl {rsa,ec} -in key.pem -pubout` output) — deliberately not PKCS#1 (`-----BEGIN RSA PUBLIC KEY-----`) or raw JWKS `n`/`e`/`x`/`y` fields, since SPKI PEM is what the vast majority of tooling (`openssl -pubout`'s modern default, `jsonwebtoken`/`jose` library docs, jwt.io examples) produces for a standalone public key; `sso::JwksCache` remains the answer for fetching keys from a live rotating JWKS endpoint, which this explicitly does not attempt to replace — cross-referenced in both the module doc and `docs/features/auth.md` so users pick the right tool. Verification rejects a token whose header `alg` doesn't match the key type actually used to check it (an RSA key can't wave through a token whose header merely claims `HS256`), reusing a shared `finish_asymmetric_verification` helper for the claims-decode/expiry-check tail shared between the RS256 and ES256 paths (the header/payload/signature split is likewise factored into a private `split_jwt` helper). ES256 verification requires the raw 64-byte `r || s` signature format JWTs specify (checked via exact byte length before parsing), not ASN.1 DER. Hit no compiler surprises implementing this — `rsa::pkcs8::DecodePublicKey`/`p256::pkcs8::DecodePublicKey` and the shared `signature::Verifier` trait (re-exported under both `rsa::signature` and `p256::ecdsa::signature`, so importing it once under either path resolves `.verify()` calls for both key types, the same trick already used in `src/sso/jwks.rs`) all resolved on the first build attempt. 19 new tests in `src/auth/tests.rs` (a `#[cfg(feature = "auth-asymmetric")] mod asymmetric_jwt` block): real key generation (a 2048-bit RSA keypair generated once via `OnceLock` and reused across every RS256 test, since keygen is genuine cryptographic work — not mockable safely — but shouldn't be repeated 10+ times per test run; P-256 keygen is cheap enough not to need the same caching but does it anyway for consistency), sign-with-the-real-private-key-and-verify-with-the-public-key roundtrips (not just structural checks) for both algorithms, wrong-key rejection, tampered-payload rejection, expired-token rejection, malformed-token rejection, an RS256-specific test proving an HS256-header token is rejected by `verify_jwt_rs256` even though byte-parsing alone would succeed, an ES256-specific wrong-signature-length rejection test, and full `JwtLayer::rs256`/`::es256` middleware end-to-end tests (valid token passes through to `next`, missing token → 401, wrong key → 401, malformed PEM → constructor `Err`). Verified across default (`http3`), `auth`, `auth-asymmetric`, `sso`, and `http1,auth-asymmetric` feature combinations; full `cargo test` passes on all (1180/1212/1231/… respectively, no regressions) — `auth` alone still reports exactly 1212 tests, confirming the new `auth-asymmetric`-gated tests don't leak into builds that don't request them. Docs: `docs/features/auth.md` (new "JWT — RS256 / ES256" section with a note on when to prefer `sso::JwksCache` instead); `DEVELOPER.md` (2 building-blocks rows + Use Case #70); `README.md` (Auth bullet + new `auth-asymmetric` feature-table row); `llms.txt` (new RS256/ES256 code block under the existing Auth middleware section, feature-table row, middleware table row, module index line, security checklist line); `docs/getting-started/features.md` and `docs/reference/api.md` (extended the existing `JwtLayer` entries rather than adding new rows, since it's the same type gaining new constructors). Closes GAPS_V3 §3.10.

- [x] **Webhook signature verification** (`src/webhook/mod.rs`, new `webhook` feature) — Added `verify_webhook_signature(provider: WebhookProvider, body, secret, header_value) -> bool`, dispatching to three named per-provider functions: `verify_github_signature` (`X-Hub-Signature-256: sha256=<hex-hmac-sha256>`), `verify_shopify_signature` (`X-Shopify-Hmac-Sha256: <base64-hmac-sha256>`), and `verify_stripe_signature`/`verify_stripe_signature_with_tolerance` (`Stripe-Signature: t=<unix_ts>,v1=<hex>[,v1=...]`, signed payload is `"{timestamp}.{body}"`, default 300s replay-window tolerance via `STRIPE_DEFAULT_TOLERANCE_SECS`, matches on *any* `v1=` entry to tolerate Stripe's secret-rotation window). Deliberately did not support GitHub's legacy SHA-1 `X-Hub-Signature` — SHA-1 is broken and this crate has no SHA-1 dependency; `X-Hub-Signature-256` is GitHub's own recommended header for new integrations. New `webhook = ["dep:hmac", "dep:sha2"]` feature reuses the exact same already-declared optional deps `auth`/`crypto`/`storage-s3` use, so this is a zero-new-Cargo-dependency change. All comparisons go through `Hmac::verify_slice` (constant-time) rather than comparing decoded bytes with `==`. Self-contained `from_hex`/`base64_decode` helpers scoped to the module (same "each module owns its tiny encoders" pattern already used by `websocket`, `acme::crypto`, `auth`, `storage::aws_sigv4`, and `cookie::crypto_ext` — no shared crate-wide encoding module exists to reuse, and introducing one for this alone wasn't worth the coupling). 23 new tests in `src/webhook/tests.rs`: sign-with-a-known-secret-and-verify roundtrips for all three providers (not just structural checks), wrong-secret/tampered-body/malformed-header rejection per provider, GitHub missing-`sha256=`-prefix rejection, Stripe timestamp-outside-tolerance rejection plus a paired within-custom-tolerance-passes test proving the override actually works (not just that the default rejects), a multiple-`v1=`-entries test simulating a secret-rotation window, missing-timestamp/missing-`v1=`/empty-header rejection, and 4 dispatcher-level tests for `verify_webhook_signature` including a cross-provider negative case (a GitHub-shaped header rejected by the Shopify verifier). Verified across default (`http3`), `webhook`, and `http1,webhook` feature combinations — default build's 1180 tests unaffected, `webhook` adds exactly 23 (1203 total), no regressions. Docs: new `docs/features/webhooks.md` page (registered in `astro.config.mjs`); `DEVELOPER.md` (2 building-blocks rows + Use Case #71); `README.md` (Security bullet + `webhook` feature-table row); `llms.txt` (new "Webhook signature verification" section, feature-flags table row, security checklist line, module index line). Closes GAPS_V3 §3.14.

---

## Priority 3 — Improves quality and completeness

Genuine gaps that real applications hit; none are blockers with a workaround.

- [x] **Multi-span distributed tracing** (`src/otel/mod.rs`) — Replaced the single-slot thread-local `Cell<Option<ActiveSpan>>` with a `RefCell<Vec<ActiveSpanCtx>>` stack, and added a public RAII `Span` type: `otel::span(name)` (`SpanKind::Internal`) and `otel::client_span(name)` (`SpanKind::Client`) start a child span nested under whatever span is currently active on the thread, inheriting its `trace_id` and sampling decision (a child never independently resamples — avoids broken partial traces). `.set_attribute(key, value)` (generic `AttributeValue::{String,Int,Float,Bool}`), `.set_error()`/`.record_error(msg)`; dropping the span (or calling `.end()`, identical effect) records it. `OtelLayer` itself was rewritten onto this same `Span::start_root` primitive instead of hand-rolling `SpanData` construction, unifying the root-span and child-span code paths. `SpanData` gained `kind: SpanKind` and generic `attributes: Vec<(String, AttributeValue)>` fields (hybrid design — existing `http.*` fields stayed first-class rather than a full rewrite, since HTTP is universal enough to deserve dedicated fields and this kept the 6 existing `OtlpHttpExporter` tests working via `..Default::default()`); both exporters render the new fields, and OTLP `int` attributes are now correctly string-encoded per the OTLP JSON mapping (a latent bug fixed as a side effect). Also added `setup_with_exporter(config, exporter)` alongside `setup()` so `CapturingExporter` — previously wireable only by hand — is usable through the real public API in tests. 19 new tests in `src/otel/tests.rs`: nesting (parent/child ID relationships, 3-levels-deep chains, drop/`.end()` restoring the parent as current, a parentless span getting a fresh trace), sampling inheritance, attribute/status/kind recording (via a crate-private `finish()` — renamed from `to_span_data` after a `clippy::wrong_self_convention` lint, since it takes `&mut self` not `&self`), and exporter-rendering coverage for `kind`/generic attributes/omitting `http.*` on non-HTTP spans. Verified across default/`storage-azure`/`storage-s3+storage-local` feature combinations and a full clean rebuild; hit and fixed an unrelated environment quirk along the way (a stale `cargo test --doc` cache after heavy edits — `cargo clean -p rust-web-server` resolved it, not a code bug). Docs: `docs/features/tracing.md` (new `## Child spans` section + amended stack/propagation wording); `DEVELOPER.md` (2 building-blocks rows + "Child spans:" subsection in Use Case #34); `README.md`, `llms.txt`. Closes GAPS_V3 §3.15 and IDEAS.md §9.

- [x] **Regex URI rewriting** (`src/rewrite/mod.rs`, new `rewrite-regex` feature) — Added the exact `RequestRule::RewriteUri { pattern: Regex, replacement: String }` variant this TODO entry specified, cfg-gated on `#[cfg(feature = "rewrite-regex")]` so the enum, the `regex::Regex` import, and the match arm all vanish entirely from builds that don't request the feature. New builder method `RewriteLayer::request_uri_regex_rewrite(pattern: &str, replacement: &str) -> Result<Self, regex::Error>` — the one rewrite builder that can fail (invalid regex), so unlike the other infallible `.request_*`/`.response_*` methods it returns a `Result` and is meant to be chained with `?`. Semantics deliberately match nginx's `rewrite` directive rather than a generic find-and-replace: `pattern.captures(uri)` — if it matches anywhere in the URI (no implicit anchoring; callers add `^`/`$` themselves), the **entire** URI is replaced by `replacement` via `Captures::expand` (supports `$1`/`$2` numbered groups and `${name}` named groups, e.g. `(?P<locale>[a-z]{2})` → `$locale`), not just the matched substring — because a path-rewrite feature that only replaced the matched portion in place would silently do the wrong thing whenever `replacement` reorders or drops segments (e.g. `^/api/v\d+/(.*)$` → `/$1`, dropping the version segment, is exactly the motivating use case and only works with whole-URI replacement); if `pattern` doesn't match, the URI is left untouched, consistent with `request_uri_strip_prefix`'s existing no-op-when-absent behavior. New `rewrite-regex = ["dep:regex"]` feature and a `regex = { version = "1", optional = true }` dependency — explicitly not hand-rolled, unlike nearly everything else in this crate: this TODO entry itself already specified pulling in the `regex` crate, and a hand-rolled regex engine is out of scope for what "no third-party HTTP dependencies" is meant to protect against (this isn't an HTTP-protocol concern, and every other significant non-HTTP capability in this crate — TLS via rustls, the ORM via sqlx, templates via tera — already reaches for an established crate rather than reinventing it). 6 new tests in `src/rewrite/tests.rs` (all `#[cfg(feature = "rewrite-regex")]`): numbered-capture expansion, named-capture expansion, no-op on non-match, an unanchored mid-string match proving the *whole* URI (not just the matched substring) gets replaced, invalid-pattern-returns-`Err`, and composition with an existing rule (`request_uri_add_prefix`) chained after the regex rewrite via `?`. Verified across default (`http3`, unaffected — 1180 tests) and `rewrite-regex` (1186 tests, +6, no regressions); both the base rewrite doctest and a new gated regex doctest compile cleanly with and without the feature. Docs: `docs/features/rewrite.md` (new `.request_uri_regex_rewrite()` section, registered page already existed); `DEVELOPER.md` (extended the `RewriteLayer` building-blocks row + extended Use Case #38 rather than adding a new one, since it's the same middleware gaining one method); `README.md` (extended the rewriting bullet + new `rewrite-regex` feature-table row); `llms.txt` (feature-flags table row, middleware table row extension, module index line). Closes GAPS_V3 §2.10 and IDEAS.md §7.

- [x] **CanaryLayer TLS backends** (`src/canary/mod.rs`) — `parse_backend_url` now detects `https://`/`h2s://`/`grpcs://` (defaulting to port 443, matching `proxy::Backend::parse`'s existing scheme conventions) and dispatches those backends to the already-existing `proxy::proxy_https1` instead of always calling `proxy_http1` — previously an `https://` canary backend URL was silently downgraded to plain HTTP, so canary/A-B routing to a TLS-only backend simply didn't work. `rotation` changed from `Vec<(String, u16)>` to `Vec<(String, u16, bool)>` to carry the per-backend TLS flag. Requires the `http-client` or `http2` feature (both pull in `rustls`); without one, a TLS backend fails cleanly and the next backend in the rotation is tried, rather than hanging. 8 new tests in `src/canary/tests.rs` for scheme/port/TLS detection (all 6 schemes plus a mixed-TLS-and-plain-backend rotation); 3 existing tests updated for the 3-tuple rotation type. Verified across default/`http1`-only/`http2`-only feature combinations, clippy clean. Docs: `docs/proxy/canary.md` (replaced the "Plain HTTP only" callout with a `## TLS backends` section); `DEVELOPER.md` (building-blocks row + new subsection in Use Case #45); `README.md`, `llms.txt` (also fixed a stale `.add(backend, weight)` API description in `llms.txt` while touching that line — the real API is `WeightedBackend::new(url, weight)`). Closes GAPS_V3 §2.5.

- [x] **Async `H2ReverseProxy`** (`src/proxy/mod.rs`, new `src/async_bridge/mod.rs`) — Extracted the sync-to-async bridging pattern `AsyncAppWithState::execute` already used correctly (a scoped OS thread running its own single-threaded tokio runtime, rather than `tokio::task::block_in_place`) into a shared `pub(crate) fn block_on_isolated<F, Fut, T>(f: F) -> T` helper, and pointed `H2ReverseProxy`'s `try_backend_h2` at it instead of `block_in_place`. This fixes both defects the original GAPS_V3 entry named: it no longer panics under a `current_thread` tokio runtime (which `block_in_place` requires `multi_thread` for), and it no longer gives up immediately when called with *no* tokio runtime active at all (the old `Err(_) => Err("no async runtime for H2 proxy...")` branch) — now it builds a temporary runtime on the spot, same as the already-working `AsyncAppWithState` path did. `AsyncAppWithState::execute` itself was refactored onto the same shared helper (previously duplicated the ~15-line pattern inline) — one implementation, two call sites, not two copies to keep in sync. 6 new tests in `src/async_bridge/tests.rs` covering all three calling contexts (no runtime, `current_thread`, `multi_thread`) plus output-by-value and borrowed-capture cases; the `current_thread` test is the direct regression proof — it would have panicked under the old `block_in_place`-based implementation, since `#[tokio::test]` defaults to exactly that flavor. Also added one true end-to-end test in `src/proxy/tests.rs` (`h2_reverse_proxy_does_not_panic_under_current_thread_runtime`) driving the real `Middleware::handle` entry point under `#[tokio::test]` against an unreachable backend, proving the whole call chain — not just the extracted helper in isolation — doesn't panic. Verified across default/`http2`-only/`http1`-only feature combinations and a full clean rebuild; clippy clean. Docs: `DEVELOPER.md`, `README.md`, `llms.txt`, `CLAUDE.md`, `docs/proxy/grpc-proxy.md`, `docs/features/http2.md`, and `docs/getting-started/features.md` all had explicit `tokio::task::block_in_place` mentions rewritten to describe the new mechanism (this was a case where the stale claim was scattered across more places than the usual "4 required docs," since the limitation had been called out as a caveat in several proxy-related pages). Closes GAPS_V3 §2.12.

- [x] **Distributed rate limiter (Redis)** (`src/rate_limit/mod.rs`) — Added `RedisRateLimiter`: a fixed-window counter (`SET … NX EX` + atomic `INCR`) backed by a Redis server, so every `rws` instance behind a load balancer shares the same budget instead of each enforcing its own in-process limit. Reuses the RESP v2 client originally written for `RedisSessionStore`, which was extracted into a new crate-internal `src/redis_protocol` module (no third-party Redis client). `check`/`remaining`/`reset` return `Result` since they do network I/O; `from_env()` reads `RWS_REDIS_HOST/PORT/PASSWORD` + the existing `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS/WINDOW_SECS`. 15 new tests (`src/rate_limit/tests.rs`) against a ~100-line in-process fake RESP server rather than requiring a live Redis in CI. Partially closes GAPS_V3 §2.11 and §3.6 — `SqliteRateLimiter` (shared-file variant) and a distributed `CircuitBreaker` are still open; `SqliteRateLimiter` in particular needs its own design pass since the model layer's `DbPool` is async-only while `RateLimiter`/`Middleware::handle` are sync.

- [x] **DB migration rollback** (`src/model/migration.rs`) — `pool.migrate()` only ever moved schema forward. Added a `.down.sql` convention: an up file `NNNN_name.sql` may have a companion `NNNN_name.down.sql` in the same directory. `pool.rollback_last(dir)` undoes the single most recently applied migration (highest version string among applied rows — the same order `migrate` applies files in); `pool.rollback(dir, n)` undoes up to the last `n`, stopping early once nothing is left. Both run the down file's SQL and delete the `_schema_migrations` row in one transaction, and return `Err` if the down file is missing (rollback is opt-in per migration, not automatic). `MigrationStatus` gained a `has_down: bool` field. `read_sql_files` (used by both `migrate` and `migration_status`) now excludes `*.down.sql` from the "up" file list, since it previously matched any `*.sql` including down files. 5 new tests in `src/model/tests.rs`. Closes GAPS_V3 §3.9.

- [x] **Bounded, correctly-accumulated request bodies** (`src/server/mod.rs`, `src/h2_handler/mod.rs`, `src/h3_handler/mod.rs`, `src/entry_point/mod.rs`) — Investigating this item surfaced that the actual bug was worse than "buffers before any handler runs": the HTTP/1.1 sync path (`Server::process`, `process_h1_tls`) performed exactly **one** `stream.read()` call per request and passed the *entire pre-allocated, zero-initialized buffer* — not `&buffer[..n]` — to `Request::parse()`. Two distinct defects followed from that: (1) any body larger than `RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES` (default 10000) was silently **truncated to whatever fit in one syscall**, not rejected or streamed, despite `docs/building-apps/forms-uploads.md` claiming such uploads were "rejected at the TCP read stage" — they weren't rejected, they were corrupted; and (2) every request that read fewer bytes than the buffer's capacity (the overwhelmingly common case — e.g. any `GET` with no body) got the buffer's leftover zero-padding appended to `request.body`, since `cursor_read`'s `read_to_end()` has no way to know where real data ends and padding begins. Fixed both: `Request::parse(&buffer[..n])` trims to bytes actually read (defect 2), and both HTTP/1.1 entry points now loop — after the first read, if the parsed `Content-Length` declares more body than arrived, additional `stream.read()` calls extend `request.body` until it's fully received (defect 1; a plain, uncapped size cap only, not the originally-proposed `BodyReader`/async-iterator API — see below for why).
  - **New global `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES`** (default `0`, unlimited — opt-in, not a default-on behavior change): checked against the declared `Content-Length` *before* any of the oversized body is read off the socket. Exceeding it returns `413 Payload Too Large` (`Server::payload_too_large_response()`, a new sibling to the existing `bad_request_response()`) and **closes the connection** rather than attempting keep-alive — the client's unread body bytes are still queued on its side of the socket, so trusting the next bytes to be a fresh request line would misparse the connection. HTTP/2 (`h2_handler`) and HTTP/3 (`h3_handler`) already accumulated bodies correctly via an async loop over `DATA` frames (no truncation bug there, since `body_stream.data().await`/`stream.recv_data().await` naturally spans as many chunks as needed) — they only lacked the cap, now added as a per-chunk check that aborts and sends `413`/`StatusCode::PAYLOAD_TOO_LARGE` as soon as the running total exceeds the limit, without waiting for the full (oversized) body to finish arriving first. `h3_handler::send_error_response` was generalized to take a `StatusCode` parameter (previously hardcoded to `500`) to support this second call site.
  - **Why not the originally-proposed `BodyReader` / async iterator API**: that would mean handlers receive body bytes incrementally instead of a fully-materialized `Vec<u8>` — a breaking change to `Request` (every controller, extractor, `TestClient`, and the `Application`/`Controller` trait signatures all assume `request.body: Vec<u8>` is complete and synchronously available) with no viable non-breaking migration path, since sync (`App`/`Router`/`AppWithState`) and async (`AsyncAppWithState`) handlers would need fundamentally different streaming primitives. That's a framework-wide breaking redesign, not a scoped fix; the honest, immediately-useful subset of "streaming" that *is* deliverable without breaking anything is exactly what this closes: bodies are no longer required to fit in one read, and the server never buffers more than an operator-configured ceiling before rejecting. Handlers still see a complete `Vec<u8>` body once execution starts.
  - **Why there's no per-route `max_body_size`** (the TODO's other original ask, and GAPS_V3 §2.13's specific proxy-focused wording): route resolution — `Router`'s pattern matching, `AppWithState`'s dispatch, and the config-driven proxy's `RouteMatcher` — all run against an already-fully-built `Request`, which by construction means the body has already been read by the time any per-route logic could apply a *different* limit than the global one. Enforcing a per-route cap *before* buffering (the only place a size cap actually protects memory) would require restructuring all three protocol read loops to resolve routing from the request line and headers alone, ahead of reading the body — a materially larger, separate architectural change than this TODO entry's own wording suggested it needed to be. `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` is deliberately one global ceiling that already closes the real risk GAPS_V3 §2.13 describes ("a client can stream an unbounded POST to a proxied route consuming all RAM") for *every* route, proxied or not, since all of them funnel through the same read paths this fixes. A handler needing a stricter route-specific limit can check `request.body.len()` itself post-hoc.
  - Added `max_body_size: u64` to `config_reload::ConfigSnapshot` (mirroring the existing `request_allocation_size: i64` field) and to its module-doc hot-reload table, so `config_reload::current()` exposes it the same way; it's read fresh from the env var on every new connection (same reload semantics as `request_allocation_size`), not routed through the `RwLock<ConfigSnapshot>` cache used for CORS/rate-limit.
  - 15 new tests: 5 in `src/server/tests.rs` (a `BodyCapturingApp` test double, since `Server::process` takes `app` by value — shares an `Arc<Mutex<Option<Request>>>` to inspect what the app actually received) covering a body split across two physical reads being fully reassembled, a `GET` request's body being empty rather than zero-padded (the regression test for defect 2), an oversized-`Content-Length` request being rejected with `413`+`Connection: close` and the app never being called, a within-limit request working normally, and `0` meaning unlimited even for a body requiring multiple reads; 2 in `src/config_reload/tests.rs` for `ConfigSnapshot::from_env()` reading and defaulting `max_body_size`; 3 in `src/entry_point/tests.rs` for `get_max_body_size()` directly (default, set, and unparseable-value fallback — this file previously had exactly one test in it, `base()`, so these also establish minimal direct coverage for `entry_point`'s getters, previously exercised only transitively). Deliberately did **not** add new tests for `h2_handler`/`h3_handler` — neither module has any existing unit tests at all (both require a real TLS/QUIC handshake to exercise, which is why they've historically been verified via manual/integration testing rather than `cargo test`), so adding the cap there follows the same code-reading verification the rest of those modules rely on, not a regression in this change's own rigor. Verified across default (`http3`, 1190 tests), `http2` (1190 tests), and `http1` (1171 tests) — all pass, no regressions.
  - Docs: `docs/building-apps/forms-uploads.md` "Size limits" section rewritten (it previously described the old, actually-inaccurate truncation behavior, and had a stale env var name missing `_IN_BYTES`); `docs/configuration/env-vars.md` (new row + corrected the `REQUEST_ALLOCATION_SIZE_IN_BYTES` description); `DEVELOPER.md` (building-blocks row for `get_max_body_size`, hot-reload table row, new Use Case #72 with the per-route-limitation rationale spelled out); `README.md` (new Security bullet); `llms.txt` (env var block, security checklist line); `CLAUDE.md` (both new env vars added to the Configuration section's constant list, an architecture-inventory update rather than an instruction). Closes GAPS_V3 §1.3 and §2.13.

- [x] **Pagination helpers** (`src/pagination/mod.rs`, new module; `src/model/query.rs`) — Added exactly the three things this entry named. `Page<T>` (offset pagination: `items`, `page`, `per_page`, `total_items`, `total_pages`, `has_next`/`has_prev`/`next_page`/`prev_page`, `.map(f)`) and `CursorPage<T>` (keyset pagination: `items`, `next_cursor: Option<String>`, `has_next`, `.map(f)`) both live in a new standalone `src/pagination/mod.rs` — deliberately **not** under `src/model/`, and with **no feature gate**, since neither type has any actual dependency on `QueryBuilder`, `sqlx`, or a `model-*` feature; a caller paginating a `Vec` from an external API can construct one directly via `Page::new(items, page, per_page, total_items)`. Both types have a `.link_header(...)` method building exactly the RFC 8288 header this entry asked for: `Page::link_header(base_url)` returns `rel="first"/"prev"/"next"/"last"` entries as applicable (omitting `first`/`prev` on the first page, `next`/`last` on the last, `None` for a single page or unparseable `base_url`), reusing the existing `crate::url::URL::parse`/`::build` (backed by `url-build-parse`) to add/overwrite `page`/`per_page` query params while preserving any others already on the URL — no new dependency, and no naive string concatenation that would mishandle an already-querystring'd base URL; `CursorPage::link_header(base_url, cursor_param)` builds a single `rel="next"` entry the same way. `QueryBuilder::paginate(page, per_page)` (requires a `model-*` feature) runs a `COUNT(*)` with the same filters (via a hand-written `Clone` impl for `QueryBuilder` — `#[derive(Clone)]` would have added a spurious `T: Clone` bound since `T` only appears behind `PhantomData`) and a `SELECT … LIMIT … OFFSET …`, then wraps both into a `Page<T>`; overrides any `.limit()`/`.offset()` set earlier in the chain, since `page`/`per_page` are authoritative once you call it. `QueryBuilder::paginate_after(cursor, per_page)` does keyset pagination: forces `ORDER BY {primary_key} ASC` (overriding any earlier `.order_by()` — required for well-defined "rows after cursor" semantics), fetches `per_page + 1` rows in a single query to detect a next page without a separate `COUNT(*)`, and sets `next_cursor` to the last row's primary key stringified via the existing `Model::primary_key_value()` — requires a numeric (parsed as `i64`) primary key and returns `Err` for a non-numeric supplied cursor, rather than silently misbehaving. 29 new tests: 22 in `src/pagination/tests.rs` covering `Page`/`CursorPage` construction, `has_next`/`has_prev`/`next_page`/`prev_page` at first/middle/last/single-page positions, `.map()`, and `.link_header()` (all four rels present/omitted correctly, existing query params preserved, invalid URL → `None`) entirely without a database; 7 integration tests in `src/model/tests.rs` against a real SQLite `:memory:` pool (`test_14`/`test_15` series) covering multi-page `.paginate()` iteration with correct `total_pages`/boundary flags, filters applied identically to both the count and select queries, an empty-table edge case, full forward iteration via `.paginate_after()` proving every row is visited exactly once in primary-key order with no duplicates/gaps, the last-page-has-no-`next_cursor` case, a rejected non-numeric cursor, and filters under keyset pagination. Verified across default (`http3`, unaffected since `pagination` has no feature gate — 1212 tests), `http1` (1193 tests), and compiles cleanly under `model-sqlite`/`model-postgres`/`model-mysql` (integration tests only run against `model-sqlite`, the only backend with an in-process `:memory:` mode; Postgres/MySQL share the identical `QueryBuilder` code path, so this is a compile-time guarantee for those backends rather than a runtime-tested one, consistent with how the rest of the model layer's backend parity is verified in this codebase). Docs: new `docs/database/pagination.md` page (registered in `astro.config.mjs` right after Query Builder), `docs/database/query-builder.md` updated (its existing "Pagination" section now points to the new page, and its "Complete example: paginated list endpoint" rewritten to use `.paginate()` + `.link_header()` instead of hand-rolled `COUNT`+`LIMIT`/`OFFSET` — closing the exact "every list endpoint re-implements pagination" complaint this TODO entry opened with); `DEVELOPER.md` (3 building-blocks rows + Use Case #73); `README.md` (Database/ORM bullet); `llms.txt` (model-layer code block extended, module index line). Closes GAPS_V3 §3.13.

- [x] **Multiple DB backends per binary** (`src/model/`) — `model-sqlite`, `model-postgres`, `model-mysql` used to be `#[cfg]`-exclusive in effect: enabling more than one silently picked SQLite (a priority-ordered `type Db = ...` alias) and the other two backends compiled but were unreachable. Added `Backend` (`src/model/backend.rs`): an enum with one variant per compiled-in `model-*` feature — matching on it is exhaustive with no wildcard arm needed, so it's a compile error to add a backend and forget a match arm. `DbPool`/`DbTransaction` (`src/model/pool.rs`) are now enums with one variant per backend, each wrapping its own `sqlx::Pool<_>`/`sqlx::Transaction<'static, _>`; every method (`execute`, `query_rows`, `begin`, …) dispatches via `match self`. `DbConfig` gained a `backend: Backend` field; `DbConfig::from_env()` infers it automatically when exactly one `model-*` feature is compiled in, and requires the new `RWS_DB_BACKEND` env var (erroring otherwise) when more than one is. `placeholder()` (SQLite/MySQL `?` vs PostgreSQL `$N`) and the repository's INSERT-id strategy (`last_insert_rowid` / `RETURNING` / `last_insert_id`) both moved from compile-time `#[cfg]` branches to runtime dispatch on `pool.backend()`, threaded through `repository.rs`, `query.rs`, `relation.rs`, and `migration.rs`. Verified a combined build (`--features model-sqlite,model-postgres,model-mysql`) compiles cleanly with no warnings beyond pre-existing ones — the three backends genuinely coexist in one binary now. 6 new tests in `src/model/tests.rs` covering `Backend` parsing/inference and `DbConfig::from_env`'s ambiguity handling; all 27 pre-existing model-sqlite tests still pass unchanged, since they exercise the same dispatch path every other backend now shares. Closes GAPS_V3 §3.8.

- [x] **Tera template hot-reload** (`src/template/mod.rs`) — `template::init()` used to compile templates once at startup with no way to re-read them short of a restart. Added `TeraEngine::reload(&mut self)` and a global `template::reload()`: both re-glob the original template directory/pattern, picking up edited content and added/removed files. Built atomically — via a fresh `tera::Tera::new(pattern)` swapped into place only on full success, rather than delegating to `tera::Tera::full_reload()` (which mutates the live template set in place and leaves it half-broken if a later file in the glob fails to parse, confirmed by reading `tera`'s `load_from_glob` source). A syntax error during reload now returns `Err` and leaves the previously-loaded templates serving. Wired into `config_reload::reload()` via a new `pub(crate) template::reload_if_initialized()` — so `SIGHUP` (the same hook already used for CORS/rate-limit/TLS-cert reload) picks up edited templates too, as a no-op if `template::init()` was never called. `global()`'s return type changed from `&'static TeraEngine` to a `RwLockReadGuard` to allow the swap. 9 new tests: edited-content reload, newly-added-file reload, `from_raw` engines rejecting reload (no glob to re-read), a broken-template reload proving the previous templates keep serving, and the full global `init`/`render`/`reload` lifecycle. Closes GAPS_V3 §3.12.

- [x] **`100 Continue` support** (`src/server/mod.rs`) — `Server::process` and `process_h1_tls` (HTTP/1.1, plain and TLS) now send `Server::continue_response()` (`100 Continue`) immediately after parsing headers with `Expect: 100-continue`, before the body-continuation read loop — the read loop would otherwise block waiting for a body the client is itself waiting to be told to send. Checked *after* the existing `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` check, so an oversized declared `Content-Length` still gets `413` without ever inviting the client to upload it. An `Expect` value other than `100-continue` gets `417 Expectation Failed` (`Server::expectation_failed_response()`) without reading any body. 6 new tests. Not covered: rejecting *before* the body based on anything decided later in the pipeline (auth, routing) — `Controller`/`Router` dispatch only runs after the full `Request` (including body) is built, and resolving a route from headers alone ahead of reading the body would be a much larger restructuring of the read pipeline (same restructuring `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES`'s docs already flag as future work). HTTP/2 (`h2_handler`) and HTTP/3 (`h3_handler`) read bodies as async per-frame `DATA` streams rather than one blocking read, so they don't have the read-ahead deadlock this exists to avoid and don't implement it. Closes GAPS_V3 §1.4.

- [x] **`wss://` proxy health checks** (`src/proxy_config/health.rs`, `src/ws_proxy/mod.rs`) — `parse_backend_url` now recognizes `wss://` (TLS, default port 443) and `ws://` (plain, default port 80) alongside the existing `https://`/`http://`/`h2://`; previously an unrecognized `wss://` prefix was left attached to the host string, so `to_socket_addrs()` DNS resolution always failed and the backend was permanently marked unhealthy. Built out the full feature beyond the parser fix: `WsProxyConfig` gained a `health_check: Option<HealthCheckConfig>` field, parsed from an optional `[ws_proxy.health_check]` section (identical shape to `[upstream.health_check]`); `WsProxy` was refactored from a fixed `Vec<WsBackend>` to a `live: Arc<RwLock<Vec<String>>>` that round-robin reads from on every connection (`WsProxy::new` seeds `live` with every backend and never updates it, preserving today's always-live behavior; the new `WsProxy::with_live_backends(all, live)` — public, for library use too — takes a caller- or health-checker-managed list); `builder.rs` spawns `health::start_health_checker` for any `[[ws_proxy]]` with `health_check` configured, sharing its live list with the `WsProxy` instance. A WebSocket upgrade attempt gets `503 Service Unavailable` if the live list is ever empty (all backends unhealthy), rather than the old behavior of always being routed somewhere. The health probe itself is a plain HTTP `GET` (optionally over TLS), not a real WebSocket handshake — matching how nginx/Traefik health-check WS upstreams. 12 new tests. Closes this gap.

- [x] **Proxy max body size** (`src/proxy_config/`) — added `max_body_size: Option<u64>` to `MiddlewareConfig`, parsed as a flat `[route.middleware] max_body_size = <bytes>` scalar (same convention as the existing `timeout_ms`). New `PerRouteMaxBodySize` middleware rejects with the new `AppError::PayloadTooLarge` (413) if `request.body.len()` exceeds the configured value; wired as the outermost layer in `apply_middleware()` — a cheap, non-blocking length check that runs before `TimeoutLayer` or any other middleware would even engage. This is explicitly *not* a substitute for the existing global `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` (which already closed the memory-exhaustion risk GAPS_V3 §2.13 actually describes, per the "Bounded, correctly-accumulated request bodies" entry above): `RouteMatcher` only runs after the complete `Request` — body included — already exists, so a per-route check can only run after that memory is already spent. What it adds is the ability for one route to be *stricter* than the global ceiling (e.g. a small JSON API route capped tighter than a separate file-upload route), which the global-only limit couldn't express. 8 new tests (config parsing/defaults, oversized/within-limit/zero-means-unset behavior via `ConfigDrivenApp::execute`, plus a new `AppError::PayloadTooLarge` unit test). Closes GAPS_V3 §2.13 (the per-route half; the memory-protection half was already closed).

- [x] **Circuit breaker persistence** (`src/circuit_breaker/mod.rs`) — `CircuitBreaker`'s state (an in-process `HashMap`) reset on every restart. Added `RedisCircuitBreaker`: identical method names and Closed→Open→HalfOpen state machine, but every operation is a Redis round trip (`GET` then `SET` against one string key per backend, `rws:cb:{backend}`, encoded as `"state|failures|opened_at"`) via the same hand-rolled RESP client `RedisRateLimiter`/`RedisSessionStore` already use — no new Cargo dependency, no feature gate. Bonus beyond restart survival: since state lives in Redis rather than the struct, every `rws` instance pointed at the same server shares one circuit per backend. Every method returns `Result` (network I/O can fail) so callers decide fail-open vs fail-closed themselves, same as `RedisRateLimiter`. **Diverged from this entry's literal "store state via the model layer" wording**: `DbPool` is `async fn`-only while `CircuitBreaker`'s methods and `Middleware::handle` (what `RetryLayer` implements) are both synchronous — the identical async/sync mismatch the `RedisRateLimiter` entry above already flagged as the reason `SqliteRateLimiter` was left unbuilt. Went with the Redis-backed variant instead, consistent with that precedent, rather than leaving this closed with an unresolved design problem or forcing an async bridge into a sync `Middleware` trait; a model-layer-backed variant remains a further option if one is specifically needed. Also deliberately **not** atomic across concurrent instances racing the same backend (unlike `RedisRateLimiter`'s `INCR`) — a circuit breaker is a self-healing heuristic, not a hard resource/security boundary, so an occasional lost update has no real consequence. 13 new tests reusing the `RedisRateLimiter` fake-Redis-server test harness, including one constructing a second, independent `RedisCircuitBreaker` instance against the same backing store to prove state actually survives what a restart would do. Closes GAPS_V3 §2.14.

---

## Priority 4 — Nice to have

Low urgency; workarounds are acceptable or audience is small.

- [ ] **Admin UI** (`src/admin/`) — 7-phase roadmap in `spec/ADMIN_ROADMAP.md`. Embedded single-page HTML at `GET /admin` backed by a JSON REST API (`/admin/api/*`). Covers live config editing, IP filter management, proxy backend management, metrics dashboard, session inspector, and SSE access log tail. Gated behind `RWS_ADMIN_TOKEN`. Closes GAPS_V2 §9 and GAPS_V3 §3.17.
  - [ ] Phase 1: `RuntimeConfig` + `AdminAuthLayer` + skeleton endpoint
  - [ ] Phase 2: Mutable rate-limit, CORS, IP filter via API
  - [ ] Phase 3: Reverse proxy backend management
  - [ ] Phase 4: JSON metrics endpoint
  - [ ] Phase 5: Session inspector
  - [ ] Phase 6: SSE access log tail
  - [ ] Phase 7: Embedded admin UI HTML

- [ ] **i18n / localization** (`src/i18n/mod.rs`) — `Accept-Language` is already parsed (`src/language/mod.rs`) but there is no locale resolver or string translation helper. Add a thin loader for `locales/*.toml` files and a `t(key, locale)` lookup. Closes GAPS_V3 §3.16 and GAPS_V2 §10.

- [ ] **Access log rotation** — logs go to stdout only. For bare-metal deployments, add `RWS_CONFIG_ACCESS_LOG_FILE` + `RWS_CONFIG_ACCESS_LOG_MAX_MB` / `MAX_FILES` and a background rotation thread. Alternatively, document `logrotate` + `SIGHUP` for the sidecar model. Closes GAPS_V3 §1.7 and IDEAS.md §6.

- [ ] **WebSocket `permessage-deflate` compression** (RFC 7692) — text-heavy WebSocket traffic (chat, JSON events) is 3–10× larger without compression. Negotiate `Sec-WebSocket-Extensions: permessage-deflate`; `flate2` is already in the dep tree. Closes GAPS_V3 §1.8.

- [ ] **WebSocket over HTTP/2** (RFC 8441) — clients that upgraded to HTTP/2 via ALPN must downgrade to HTTP/1.1 for WebSocket. Implement the RFC 8441 bootstrap to avoid the renegotiation round-trip. Closes GAPS_V3 §1.9.

- [ ] **GraphQL** — no integration with `async-graphql` or `juniper`. Add a thin `src/graphql/mod.rs` adapter that wraps `async-graphql`'s schema behind an `Application`. Closes GAPS_V3 §3.18 and GAPS_V2 §11.

- [ ] **WebAssembly / `wasm32-wasi` target** — OS threads, `TcpStream`, and `aws-lc-rs` do not compile to WASM. A `wasm32-wasi` shim layer would enable running rws handlers inside Wasmtime, WasmEdge, or Fastly Compute. Closes GAPS_V3 §3.19.

- [ ] **HTTP/2 and HTTP/3 server push** — no server-push API exposed to handlers. Pre-push CSS/JS alongside an HTML response. Minor gap given cache interaction problems. Closes GAPS_V3 §1.10.

---

## Enterprise readiness

Surfaced by a 2026-07-04 review of what specifically blocks enterprise adoption — procurement/security-review concerns and enterprise-IdP/scale features — as distinct from the general feature gaps above. Two kinds: repo governance/process (no code involved) and code features.

**Governance / process:**

- [ ] **CI pipeline** — `.github/` has issue templates only, no `.github/workflows/`. Nothing runs `cargo test`, a multi-feature build matrix, or a security scan automatically on push/PR — there's no automated proof the 1200+ test suite actually gates merges. Add a GitHub Actions workflow: `cargo test` across `http1`/`http2`/`http3`/`model-*` feature combinations, `cargo build --no-default-features --features <each>` for every feature flag, `cargo fmt --check`, `cargo clippy`.

- [ ] **`SECURITY.md`** — no documented vulnerability disclosure process or security contact. A standard requirement in enterprise vendor-security questionnaires before a dependency gets approved. Add a supported-versions table and a reporting contact/process (GitHub private vulnerability reporting or an email alias).

- [ ] **Triage the open Dependabot alerts** (6 as of 2026-07-04: 2 high, 2 moderate, 2 low) — surfaced on every push; nothing in the repo shows they've been reviewed, patched, or dismissed with rationale. Check the GitHub Security tab / run `cargo audit` and either patch, pin, or document why each is a non-issue.

- [ ] **`CHANGELOG.md`** — version history lives only in commit messages (`feat(x): ... (vX.Y.Z)`); no per-release document for upgrade planning. Could plausibly be generated from commit messages given the existing `(vX.Y.Z)` convention already in every commit.

- [ ] **`cargo-deny`/`cargo-audit` policy file** (`deny.toml`) — no automated license-compliance or advisory-database check today; depends entirely on manual review. Wire into the CI pipeline above once it exists.

- [ ] **Support/SLA model** — single maintainer, no formal support contract. A real adoption blocker for regulated enterprises (finance, healthcare) that require a vendor support agreement or a bus-factor above 1 — not fixable by writing code, but worth naming explicitly rather than leaving implicit.

**Code features:**

- [ ] **SAML 2.0 SSO** (`spec/SSO.md` Phase 7 — scoped, not started) — the `sso` feature covers OAuth2/OIDC only. Many enterprise IdPs — especially older Active Directory Federation Services deployments — still require SAML, not OIDC. `spec/SSO.md` already sketches the API (`SamlSp`/`SamlConfig`) and the one new dependency needed (`quick-xml`, SAML-2.0-XML-parsing only). Larger lift than most items in this file — XML canonicalization and XML-signature verification is a materially different problem than the JWT-based OIDC flow already built.

- [ ] **RBAC / authorization framework** — `auth`/`sso` cover authentication only (who are you); there's no built-in role/permission/policy model. Every app built on rws has to write its own authorization layer from scratch. A minimal `Role`/`Permission` middleware (e.g. `RbacLayer::require(role)`), shaped like the existing `IpFilter`/`RateLimitLayer`, would close the common case without pulling in a full policy-engine dependency.

- [ ] **Secrets-manager integration** — JWT signing keys, DB credentials, and TLS keys are read from plain env vars or files. Enterprises with a central secrets store (Vault, AWS Secrets Manager, Azure Key Vault) have to bridge that themselves today (e.g. an init container populating env vars). A thin `secrets::resolve(key)` abstraction with pluggable backends would let config values reference `vault://path` / `aws-sm://name` instead of a raw value.

- [ ] **SqliteRateLimiter** — the Redis half of distributed rate limiting (`RedisRateLimiter`) and distributed/persistent circuit breaking (`RedisCircuitBreaker`) are both done, tracked in Priority 3 above. There's still no shared-file `SqliteRateLimiter` for rate limiting in deployments without Redis — re-flagged here because it's specifically what breaks once an enterprise deployment runs more than one replica behind a load balancer with no Redis available, not just a "nice to have."

---

## Data / ML field readiness

Surfaced by a 2026-07-04 review of what's missing for Data/ML use cases specifically. There is currently zero ML-specific tooling in this codebase (no gRPC server, no Arrow/Parquet, no Protobuf, no tensor/ONNX bindings — confirmed by grep, not assumption). But the *serving/ops* side of an ML workload is an HTTP-plus-pipeline problem this framework is already reasonably well-equipped for: routing, JSON, validation, SSE/WebSocket streaming, multipart/chunked file upload, object storage, `JobQueue`/`Scheduler` for batch pipelines, `Page<T>`/`CursorPage<T>` pagination, and the full rate-limit/auth/metrics/tracing ops story. The gaps below are specifically what a Rust HTTP framework — not an ML runtime — should reasonably add on top of that.

- [ ] **Documented ML-runtime integration example** — no `docs/` page showing "serve a `candle` or ONNX Runtime (`ort`) model behind an rws handler." Cheap to add (no new hard dependency — a docs example, not a feature flag), and removes a lot of "does this even work for ML?" uncertainty for someone evaluating the framework.

- [ ] **CSV extractor/responder** — common tabular-data interchange format; `Json<T>` exists via the `serde` feature, CSV doesn't, despite the framework hand-rolling every other parser (JSON, multipart, URL-encoded, WebSocket, chunked encoding) from scratch. A `Csv<T>` extractor/responder (`serde`-based, like `Json<T>`) would be a small, self-contained, in-character addition.

- [ ] **Native gRPC server** — only `GrpcProxy` exists (forwards to an *existing* gRPC backend). Many ML-serving stacks (Triton, KServe, Seldon) are gRPC-native; exposing your *own* Rust-implemented gRPC service (not just proxying to one) needs Protobuf codegen (`prost`/`tonic`), which isn't part of the framework today. Larger, separate lift — comparable in scope to the SAML item above, not a quick add.

- [ ] **Columnar/array data format support** (Arrow, Parquet, NumPy `.npy`) — datasets and feature vectors are usually exchanged in these formats, not JSON, for size/speed reasons on large numeric payloads. Nothing here today; would mean an `arrow`/`parquet` crate dependency behind a new feature flag, plus a body extractor/responder pair analogous to `Json<T>`.

- [ ] **Dynamic/request batching middleware** — real inference services often coalesce concurrent requests into one batched model call for GPU throughput (e.g. wait up to N ms or until M requests queue, then invoke the model once). No queue-with-timeout batching primitive exists; would be a new middleware, not tied to any specific ML runtime.

- [ ] **Content negotiation** (JSON vs. CSV vs. Arrow on the same endpoint, via `Accept`) — would matter once an endpoint serves both humans (JSON/CSV) and pipelines (Arrow/Parquet) — currently every endpoint returns one fixed format.

---

## Robotics readiness

Surfaced by a 2026-07-04 review of what's missing for robotics use cases specifically. No MQTT, CoAP, ROS, serial/GPIO, or WebRTC support exists in this codebase today (confirmed via grep, not assumption). The *fleet/gateway/ops* side of robotics — telemetry ingestion, device authentication, command queuing, OTA distribution, fleet monitoring — is already reasonably well covered by existing building blocks; the gaps below are what a Rust HTTP framework should reasonably add on top of that, plus explicit scope boundaries worth stating rather than leaving implicit.

- [ ] **ROS / ROS 2 bridge** — ROS 2 (DDS-based) is the dominant robotics middleware, not HTTP. A `rosbridge`-style adapter — HTTP/WebSocket ↔ ROS topics/services, JSON-encoded, compatible with the existing `rosbridge_suite` protocol other tools already speak — would let rws act as a cloud/web gateway for a robot's ROS graph without needing real DDS integration. Given the WebSocket implementation already exists, this is the single highest-leverage robotics-specific addition.

- [ ] **MQTT** — the dominant pub/sub protocol in robotics/IoT (ROS topic bridges, industrial robots, AWS IoT Core / Azure IoT Hub style fleet telemetry). Nothing here today; would need a new feature (client, and/or a minimal embedded broker).

- [ ] **CoAP** — the constrained-device analogue of HTTP (UDP-based, built for embedded/battery-powered devices). Not supported; comparable scope to the WebSocket implementation already in the crate.

- [ ] **Binary/schema-based telemetry serialization** — the same underlying gap as the Data/ML section's Protobuf item; high-frequency sensor streams (IMU, lidar, joint states) are usually Protobuf/Cap'n Proto/FlatBuffers over WebSocket, not JSON, for size and parse-speed reasons.

- [ ] **Document the real-time scope boundary** — not a code gap: this is a general-purpose async/thread-pool server, fine for telemetry, fleet management, and command/control at "soft real-time" (tens of ms), not for closed-loop motor-control loops (kHz), which belong in a real-time layer regardless of HTTP framework choice. Worth a docs callout so it's an explicit boundary rather than an implicit unknown for someone evaluating rws for a control-loop use case.

- [ ] **Document the embedded/`no_std` scope boundary** — same boundary as the already-tracked WASM target gap: rws needs `std` (threads, `TcpListener`, tokio), so it targets Linux-class robot computers (Raspberry Pi, Jetson, industrial PCs), not microcontroller firmware itself.

---

## Cross-reference

| This file | Source spec |
|---|---|
| Priority 2 + 3 items | [GAPS_V3.md](GAPS_V3.md) — §1–§3 priority table |
| Storage, jobs, OpenAPI | [GAPS_V2.md](GAPS_V2.md) — §6–§8 |
| LB strategies, ForwardAuth, regex rewrite, access log | [IDEAS.md](IDEAS.md) — §1–§10 |
| Admin UI phases | [ADMIN_ROADMAP.md](ADMIN_ROADMAP.md) |
| GAPS_V3 shortest path | [GAPS_V3.md §Shortest path](GAPS_V3.md) |