diff --git a/docs/stories/00-status.md b/docs/stories/00-status.md index 857919e..3bae234 100644 --- a/docs/stories/00-status.md +++ b/docs/stories/00-status.md @@ -1195,9 +1195,9 @@ the language arc as v1 history. ### ▸ porch — the web framework track New 2026-08-26, from [the Fiber v3.5.0 parity study](../plan/exploration/fiber/00-fiber-parity.md). -Supersedes language iteration 39, now a pointer. **Stories 2, 3 and 4 are +Supersedes language iteration 39, now a pointer. **Stories 2, 3, 4 and 5 are `ready` (brainstormed 2026-09-06, forks locked, validated against -`.dev/reference/fiber`); 5–8 remain `refine`.** Ordered by dependency; the +`.dev/reference/fiber`); 6–8 remain `refine`.** Ordered by dependency; the first slice is deliberately the cheapest so the store pattern and gate shape are proven before the runtime and `Resp` are touched. @@ -1207,7 +1207,7 @@ proven before the runtime and `Resp` are touched. | 2 | [Randomness and cookies](porch/02-randomness-and-cookies.md) | ✅ **`ready` 2026-09-06** — the foundation; the reference read settled that **exactly one language enhancement is needed**. Phase A is that language work: a bare-name `random_bytes(n) -> Bytes` builtin in the compiler's crypto-family table (`emit.ml` `b_*` + `types.ml` function list — **not** `wob.h`'s module enum; next free id `84`/`90`, confirm before use), `getrandom(2)`-sourced, refuses loudly. Then `Resp` gains `cookies: multi SetCookie` beside the unchanged `headers` map (the map can't emit two `Set-Cookie` lines; `multi` already exists), `Cookie:` parsing (structural 400 in `parse_request`, on-demand `cookie()` helper), and signed cookies (`base64(value).base64(mac)`, app-supplied key) | | 3 | [Sessions](porch/03-sessions.md) | ✅ **`ready` 2026-09-06** — after 2. Six decisions locked: row is a pure auth primitive (id/principal/created_at/last_seen, no payload bag); **wall-clock `time.now`, not monotonic `time.ticks`** (sessions survive restart); rotation = login always mints a fresh id (no anon-session model); throttled `last_seen` touch at `idle/20` (not a WAL write per request); `Session` writes `req.principal`; config refuses absolute < idle. Pure `.wo` on iteration 2 + the `@table` engine — no new runtime work | | 4 | [CSRF](porch/04-csrf.md) | ✅ **`ready` 2026-09-06** — after 2 + 3. Five decisions locked: fiber's **hybrid** transport (session-stored `CsrfToken` @table keyed by token + double-submit cookie, both must pass; no CSRF for sessionless apps); opt-in single-use (checkout the example, admin multi-use); a double-click yields a distinct `SPENT` refusal with **no coupling to the lang-41-blocked idempotency**; trusted origin/referer/`Sec-Fetch-Site` as the second layer; refusal classes distinguishable in logs, opaque in body. Plain `@table` CRUD — no actor pool, not blocked on lang-41 | -| 5 | [Routing + response ergonomics](porch/05-routing-response-ergonomics.md) | ⬜ **independent, any time** — `patch`/`options`/`head`/`all`, named routes + URL building, per-route body limit (today `BODY_MAX` is one compile-time number), request ids, `Location`/`Vary`/`Attachment`, and q-value ranking (retires a standing 🔶) | +| 5 | [Routing + response ergonomics](porch/05-routing-response-ergonomics.md) | ✅ **`ready` 2026-09-06** — **independent, any time; NO upstream dependency (not even iteration 2)**. Five decisions: `head` auto-registers with an opt-out (+ `patch`/`options`/`all`); request ids mirror the limiter's trust model with a **non-crypto** source (so no CSPRNG dependency); per-route `body_limit` is a **second check after routing** (global `BODY_MAX` stays the pre-routing ceiling, over-limit = 413); adding `name`/`body_limit` to `Route` is corpus-free; `Vary` accumulates by comma-join. Plus named routes + runtime-checked URL building, q-value ranking (retires the 🔶) | | 6 | [Streaming core](porch/06-streaming-core.md) | ⬜ the riskiest and highest-leverage slice: incremental writes + chunked framing + an explicit commit point. `serialize()` always emits `Content-Length` today. Chunked REQUEST bodies are deliberately refused (request smuggling) and that refusal must survive | | 7 | [SSE + compression](porch/07-sse-and-compression.md) | ⬜ after 6. SSE fits the actor/fiber model unusually well; compression carries a real fork — pure-`.wo` DEFLATE (now expressible after iteration 36's bit operators) vs a C builtin. CRC32 finally gets its consumer | | 8 | [Static files + lifecycle](porch/08-static-and-lifecycle.md) | ⬜ static half after 6. Byte ranges, `Last-Modified`/`Cache-Control`, index resolution, listing off-by-default, shutdown hooks (the ledger's "no user teardown hooks yet"), plus healthcheck/favicon/redirect/rewrite/skip | diff --git a/docs/stories/porch/05-routing-response-ergonomics.md b/docs/stories/porch/05-routing-response-ergonomics.md index 37245f5..8a857e4 100644 --- a/docs/stories/porch/05-routing-response-ergonomics.md +++ b/docs/stories/porch/05-routing-response-ergonomics.md @@ -2,25 +2,28 @@ track: porch iteration: "5" status: pending -readiness: refine +readiness: ready --- # porch 5 — routing and response ergonomics: the parity that is merely missing > Part of [Story — `porch`, the writeonce web framework](00-story.md). -> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §5. +> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §5, +> re-checked 2026-09-06 against `.dev/reference/fiber` (v3, `3ca9a9d`): +> `DisableHeadAutoRegister`, `Name()`/`GetRouteURL()`, `BodyLimit`, and the +> `requestid` middleware's trust model. > -> Independent of iterations 2–4 and of the streaming seam — startable at any -> time, and a reasonable slice to interleave when the risky work needs a break. -> Nothing here is hard; all of it is felt. +> Independent of iterations 2–4 and of the streaming seam — and, as the +> brainstorm confirmed, with **no upstream dependency at all** (not even +> iteration 2): startable at any time, a reasonable slice to interleave when the +> risky work needs a break. Nothing here is hard; all of it is felt. ## Goals - **The rest of the method helpers.** `App` has `get`/`post`/`put`/`delete_`. A `Route { method: "PATCH" }` literal already works, so this is registration - ergonomics rather than capability — but writing the literal by hand for - `PATCH` while `get` exists is the kind of asymmetry that makes a framework - feel unfinished. Add `patch`, `options`, `head`, and an `all`. + ergonomics rather than capability. Add `patch`, `options`, `head`, and an + `all`. - **Named routes and reverse routing.** Fiber has `Name()` and `GetRouteURL()`. porch has neither, so every link in the site is a hand-written string that no compiler checks — and the site is exactly the app where a renamed path breaks @@ -30,60 +33,101 @@ readiness: refine and the JSON route wants a much smaller one than the upload route can live with. - **Request ids.** `req.ctx` already exists to carry one; there is no generator - and no middleware. With iteration 2's builtin available this is a few lines, - and it is the difference between logs you can correlate and logs you cannot. + and no middleware. It is the difference between logs you can correlate and + logs you cannot. - **The response helpers written by hand today.** `Location`, `Vary`, `Attachment`/`Download`, and a content-negotiated `format` dispatch on top of the existing `accepts()`. Also q-value *ranking*, which the ledger has carried as a known 🔶 since the negotiation slice landed. +## Decisions locked (brainstorm 2026-09-06) + +1. **`head` auto-registers alongside `get`, with an opt-out.** Matches fiber + (`DisableHeadAutoRegister` proves the default is auto and people want the + knob). `serialize()` already produces headers-only with a GET's + `Content-Length`, so this is pure registration — the behaviour exists, this + makes it reachable without a hand-written literal. `patch`/`options`/`all` + are plain additions; `all` registers one handler for every method. +2. **Request ids mirror the limiter's trust conclusion, and use a non-crypto + source.** Default (`trust_inbound` off): always generate a fresh id and + ignore any inbound `X-Request-Id`, because an open port can forge correlation + ids and poison logs — exactly what the limiter's `trust_proxy` guards. + Opt-in (behind the mandated proxy): honor an inbound `X-Request-Id` for + cross-hop tracing, generate when absent. The id is minted from a non-crypto + unique source (`time.ticks` plus a per-process counter), **not** iteration + 2's `random_bytes` — a request id is not a secret, and this keeps the whole + iteration free of upstream dependencies. It is written to `req.ctx` and + echoed in the response header, and the logging middleware includes it. +3. **The per-route body limit is a second check after routing; the global + `BODY_MAX` stays as the pre-routing ceiling.** The body is read in + `parse_request` before the route is known (`parse.wo`), so something must cap + bytes first — the global remains that DoS ceiling. A `body_limit: Int` field + on `Route` (default the global, and never above it) is checked in dispatch + against `len(req.body)`; over-limit is a 413. This corrects the story's + implication that the per-route limit replaces the global — it cannot, because + routing happens after the read. +4. **Adding fields to `Route` is free of the conformance corpus.** Fork 3 warned + the corpus pins `Route`'s ownership shape; it does not — `container-owned-move` + defines its own local `Route`/`App` to pin move-on-push and is decoupled from + porch's. The new fields (`name: Text`, `body_limit: Int`) are scalar/Text, + copy-stored, with no ownership complication for the owned `h: Handler`. +5. **`Vary` accumulates by comma-joining in the existing header map — no + iteration 2 needed.** One `Vary: A, B` header keeps the iteration truly + independent; the story's tie to iteration 2's repeated-header work is not + required for this. + ## Phases ### Phase A — method helpers and route introspection -- The missing registration helpers, including `all`, and decide whether `head` - auto-registers alongside `get` (Fiber has `DisableHeadAutoRegister`, which - tells you the default is auto and that people want it off). -- Route introspection — list the table — because it is nearly free once routes - are already a `multi Route`, and it is what makes a startup banner or a - route-dump flag possible. +- Add `patch`, `options`, `head`, `all` to `App`, each pushing a `Route` literal + as `get`/`post` already do; `all` registers the handler for every method. + `head` auto-registration (decision 1) is wired here with its opt-out. +- Route introspection — list the table — nearly free once routes are a + `multi Route`, and what makes a startup banner or a route-dump flag possible. - Verify: each method dispatches; `405` still carries a correct `Allow` built - from the real table; existing routes unchanged. + from the real table (the `app.wo` dispatch loop already assembles it); + existing routes unchanged. ### Phase B — named routes and URL building -- A name on `Route`, a lookup, and a builder that fills `:param` captures. -- Decide the failure mode for a missing or extra parameter. A silently wrong URL - is worse than a trap, and this is a compile-time-checkable shape only once - language iteration 29's `@derive` exists — so for now it is a runtime check - and should say so. -- Migrate the site's internal links onto it, which is the proof it is usable. +- A `name` field on `Route`, a lookup by name, and a builder that fills `:param` + captures against the route's pattern. +- The failure mode for a missing or extra parameter is a loud runtime refusal, + not a plausible-looking wrong URL. This is compile-time-checkable only once + language iteration 29's `@derive` exists — so it is a runtime check now and + says so. +- Migrate the site's internal links onto the builder, which is the proof it is + usable. - Verify: every site link resolves through the builder; a wrong parameter set is refused loudly. ### Phase C — per-route body limits and request ids -- Move the limit from a module constant to route-level configuration with the - current value as the default, so no existing app changes behaviour. -- A request-id middleware writing into `req.ctx`, and settle whether an inbound - header is trusted (fork 2). +- Add `body_limit: Int` to `Route`, defaulting to the global `BODY_MAX`, checked + in dispatch after the route matches (decision 3); an oversized body is a 413 + at that route while the global ceiling still bounds the pre-routing read. +- A request-id middleware (decision 2): non-crypto id, trust model mirroring the + limiter, written to `req.ctx` and the response header. - Thread the id into the logging middleware's output, since a request id nothing logs is decoration. -- Verify: an oversized body is refused per-route; the id appears in logs and is - stable across a request's lifetime. +- Verify: an oversized body is refused per-route; a request under the global but + over a route's tighter limit is refused only at that route; the id appears in + logs and the response header and is stable across a request's lifetime. ### Phase D — response helpers and negotiation ranking -- `Location`, `Vary`, `Attachment`/`Download`, and a `format`-style dispatch - choosing a builder from `accepts()`. -- Rank q-values properly instead of stripping them, retiring the ledger's 🔶. -- Verify: `Vary` accumulates rather than overwrites (which the iteration-2 - repeated-header work makes possible); negotiation picks the highest-q match, - not the first. +- `Location`, `Vary` (comma-join accumulation, decision 5), `Attachment`/ + `Download`, and a `format`-style dispatch choosing a builder from `accepts()`. +- Rank q-values properly instead of stripping them, retiring the ledger's 🔶: + parse `Accept` into type/q pairs, and pick the highest-q acceptable match. +- Verify: `Vary` accumulates rather than overwrites; negotiation picks the + highest-q match, not the first listed. ### Phase E — the gate and the ledger -- Both serving gates, the ledger rows, the board entry. +- Both serving gates, the ledger rows, the board entry, standup questions + answered including the `.dev/reference` projects used. - Verify: `just web-app`, `just site`, `just linkcheck` green. ## Acceptance Criteria @@ -93,23 +137,26 @@ readiness: refine yields `405` with an `Allow` listing exactly the registered methods. - **Given** `head` auto-registration, **when** a `HEAD` request hits a `GET` route, **then** the response is headers-only with the `Content-Length` a `GET` - would have sent — the behaviour `serialize()` already implements, now - reachable by registration. + would have sent; **and** with auto-registration disabled the `GET` route + answers `GET` only. - **Given** a named route with `:param` captures, **when** a URL is built with the right parameters, **then** it matches that route's pattern exactly; **and** a wrong or missing parameter is refused rather than producing a plausible-looking wrong URL. - **Given** two routes with different body limits, **when** a body exceeding the - smaller arrives at each, **then** it is refused at the small route and + smaller arrives at each, **then** it is refused (413) at the small route and accepted at the large one. - **Given** no per-route limit, **when** a request arrives, **then** the previous global limit applies unchanged. +- **Given** a request-id middleware with inbound trust off, **when** a request + arrives carrying `X-Request-Id`, **then** a fresh id is generated and the + inbound one ignored; **and** with trust on, the inbound id is honored. - **Given** a request-id middleware, **when** a request is handled, **then** the same id appears in every log line for that request and in the response header. - **Given** an `Accept` header with q-values out of order, **when** negotiation runs, **then** the highest-q acceptable type wins — not the first listed. - **Given** two `Vary` contributions from different middleware, **when** the - response leaves, **then** both appear. + response leaves, **then** both appear in one comma-joined `Vary`. ## Out Of Scope @@ -123,21 +170,17 @@ readiness: refine - **Compile-time-checked URL building.** The typed version needs language iteration [29](../language-runtime-database/29-compile-time-metaprogramming.md). Runtime-checked now, upgraded later. +- **Cryptographically random request ids.** Not needed — a request id is not a + secret (decision 2). If iteration 2 is landed, `random_bytes` is an acceptable + alternative source, but this iteration must not depend on it. - **Streaming responses, `SendFile`, byte ranges** — iterations [6](06-streaming-core.md) and [8](08-static-and-lifecycle.md). - **Typed binding of params into a class** — language iteration 29 again. ## Info -Forks the spec must settle: - -1. **Does `head` auto-register?** Fiber's default is yes with an opt-out. Auto is - friendlier; explicit is more predictable and never surprises someone - debugging why a route they did not register is answering. -2. **Is an inbound request-id header trusted?** Behind the mandated proxy, - trusting it is what makes tracing work across hops. On an open port it lets a - client forge correlation ids and poison logs. `client_ip` and `net.peer` - already exist for exactly this trust decision — reuse that conclusion. -3. **Where does a route's body limit live?** A field on `Route` is the obvious - home but widens a record that the conformance corpus pins the ownership shape - of. Check that fixture before choosing. +Forks are settled above. This iteration is entirely pure `.wo` and, unusually +for the track, has **no upstream dependency** — not the streaming seam, not +sessions, not even iteration 2's builtin (decision 2 keeps request ids off the +CSPRNG). It is the safest slice to pick up at any time, which is exactly why the +track lists it as independent.