rust-web-server 17.61.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.

- [ ] **Multi-span distributed tracing** — `OtelLayer` creates one flat span per request. Handlers cannot create child spans ("db.query", "http.outbound", "cache.lookup"). Add `thread_local!` span stack and a `SpanBuilder` API in `src/otel/`. 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.

- [ ] **CanaryLayer TLS backends** — `CanaryLayer` calls `proxy::proxy_http1()` only; no `https://` scheme detection. Closes GAPS_V3 §2.5.

- [ ] **Async `H2ReverseProxy`** — currently uses `tokio::task::block_in_place` to bridge sync middleware into the tokio runtime; panics on `current_thread` runtime and blocks a worker thread. Replace with a natively async internal implementation. Closes GAPS_V3 §2.12.

- [ ] **Distributed rate limiter** — `RateLimiter` and `CircuitBreaker` are per-process. Two instances behind a load balancer have independent state. Add a `RedisRateLimiter` and a `SqliteRateLimiter` (shared file) for deployments that need global enforcement. Closes GAPS_V3 §2.11 and §3.6.

- [ ] **DB migration rollback** (`conn.rollback_to(version)`) — `conn.migrate()` applies only forward. Add a `_down.sql` convention and `conn.rollback_to_version(n)` for deploy rollback. 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.

- [ ] **Multiple DB backends per binary** — `model-sqlite`, `model-postgres`, `model-mysql` are `#[cfg]`-exclusive. A binary cannot hold connections to both SQLite (hot cache) and PostgreSQL (analytics). Refactor `DbConnection` into an enum. Closes GAPS_V3 §3.8.

- [ ] **Tera template hot-reload** — `template::init()` compiles templates once at startup. Add a watcher that re-reads the template directory on `SIGHUP` / `POST /admin/config/reload`. Closes GAPS_V3 §3.12.

- [ ] **`100 Continue` support** — rws reads the full body unconditionally; clients sending `Expect: 100-continue` waste bandwidth on requests the server would reject. Reply with `100 Continue` before reading the body. Closes GAPS_V3 §1.4.

- [ ] **`wss://` proxy health checks** — `health.rs` parses `https://` backends and performs TLS health checks, but `wss://` scheme is not recognised. Health checks for `wss://` backends in `rws.config.toml` fall back to plain TCP and fail silently on TLS-only backends. Add `wss://` to `parse_backend_url`.

- [ ] **Proxy max body size** — no `max_body_size` in `MatchConfig` or `MiddlewareConfig`. A client can stream an unbounded POST to a proxied route consuming all RAM. Closes GAPS_V3 §2.13.

- [ ] **Circuit breaker persistence** — `CircuitBreaker` state resets on process restart; a backend that triggered the circuit just before a deploy appears healthy immediately on startup. Store state via the model layer. 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.

---

## 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) |