docs(porch-routing): brainstorm story 5 (routing + response ergonomics) to ready

- five decisions: head auto-registers with opt-out (+ patch/options/all);
  request ids mirror limiter trust model with a NON-crypto source; per-route
  body_limit is a SECOND check after routing (global BODY_MAX stays the
  pre-routing ceiling, over-limit = 413); Route fields are corpus-free;
  Vary accumulates by comma-join
- key finding: story 5 has NO upstream dependency, not even iteration 2 --
  request ids are not secrets, so a non-crypto source (time.ticks+counter)
  keeps it startable today; the one porch slice buildable right now
- three story assumptions corrected: per-route limit cannot replace the
  global (body read before routing); the container-owned-move corpus fixture
  has its OWN Route (adding fields is free); Vary needs no iteration 2
- validated against .dev/reference/fiber; zero language enhancement. Board synced

(cherry picked from commit 0589a13db1f3b7c220d9d9fdc76142af6a63c390)
This commit is contained in:
shoney.arickathil 2026-09-06 17:19:36 +02:00
parent 274f7c5361
commit 6b820fdd5f
2 changed files with 98 additions and 55 deletions

View file

@ -1195,9 +1195,9 @@ the language arc as v1 history.
### ▸ porch — the web framework track ### ▸ porch — the web framework track
New 2026-08-26, from [the Fiber v3.5.0 parity study](../plan/exploration/fiber/00-fiber-parity.md). 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 `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 first slice is deliberately the cheapest so the store pattern and gate shape are
proven before the runtime and `Resp` are touched. 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) | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 |

View file

@ -2,25 +2,28 @@
track: porch track: porch
iteration: "5" iteration: "5"
status: pending status: pending
readiness: refine readiness: ready
--- ---
# porch 5 — routing and response ergonomics: the parity that is merely missing # porch 5 — routing and response ergonomics: the parity that is merely missing
> Part of [Story — `porch`, the writeonce web framework](00-story.md). > 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 > Independent of iterations 2–4 and of the streaming seam — and, as the
> time, and a reasonable slice to interleave when the risky work needs a break. > brainstorm confirmed, with **no upstream dependency at all** (not even
> Nothing here is hard; all of it is felt. > 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 ## Goals
- **The rest of the method helpers.** `App` has `get`/`post`/`put`/`delete_`. - **The rest of the method helpers.** `App` has `get`/`post`/`put`/`delete_`.
A `Route { method: "PATCH" }` literal already works, so this is registration A `Route { method: "PATCH" }` literal already works, so this is registration
ergonomics rather than capability — but writing the literal by hand for ergonomics rather than capability. Add `patch`, `options`, `head`, and an
`PATCH` while `get` exists is the kind of asymmetry that makes a framework `all`.
feel unfinished. Add `patch`, `options`, `head`, and an `all`.
- **Named routes and reverse routing.** Fiber has `Name()` and `GetRouteURL()`. - **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 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 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 and the JSON route wants a much smaller one than the upload route can live
with. with.
- **Request ids.** `req.ctx` already exists to carry one; there is no generator - **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 no middleware. It is the difference between logs you can correlate and
and it is the difference between logs you can correlate and logs you cannot. logs you cannot.
- **The response helpers written by hand today.** `Location`, `Vary`, - **The response helpers written by hand today.** `Location`, `Vary`,
`Attachment`/`Download`, and a content-negotiated `format` dispatch on top of `Attachment`/`Download`, and a content-negotiated `format` dispatch on top of
the existing `accepts()`. Also q-value *ranking*, which the ledger has carried the existing `accepts()`. Also q-value *ranking*, which the ledger has carried
as a known 🔶 since the negotiation slice landed. 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 ## Phases
### Phase A — method helpers and route introspection ### Phase A — method helpers and route introspection
- The missing registration helpers, including `all`, and decide whether `head` - Add `patch`, `options`, `head`, `all` to `App`, each pushing a `Route` literal
auto-registers alongside `get` (Fiber has `DisableHeadAutoRegister`, which as `get`/`post` already do; `all` registers the handler for every method.
tells you the default is auto and that people want it off). `head` auto-registration (decision 1) is wired here with its opt-out.
- Route introspection — list the table — because it is nearly free once routes - Route introspection — list the table — nearly free once routes are a
are already a `multi Route`, and it is what makes a startup banner or a `multi Route`, and what makes a startup banner or a route-dump flag possible.
route-dump flag possible.
- Verify: each method dispatches; `405` still carries a correct `Allow` built - 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 ### Phase B — named routes and URL building
- A name on `Route`, a lookup, and a builder that fills `:param` captures. - A `name` field on `Route`, a lookup by name, and a builder that fills `:param`
- Decide the failure mode for a missing or extra parameter. A silently wrong URL captures against the route's pattern.
is worse than a trap, and this is a compile-time-checkable shape only once - The failure mode for a missing or extra parameter is a loud runtime refusal,
language iteration 29's `@derive` exists — so for now it is a runtime check not a plausible-looking wrong URL. This is compile-time-checkable only once
and should say so. language iteration 29's `@derive` exists — so it is a runtime check now and
- Migrate the site's internal links onto it, which is the proof it is usable. 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 - Verify: every site link resolves through the builder; a wrong parameter set is
refused loudly. refused loudly.
### Phase C — per-route body limits and request ids ### Phase C — per-route body limits and request ids
- Move the limit from a module constant to route-level configuration with the - Add `body_limit: Int` to `Route`, defaulting to the global `BODY_MAX`, checked
current value as the default, so no existing app changes behaviour. in dispatch after the route matches (decision 3); an oversized body is a 413
- A request-id middleware writing into `req.ctx`, and settle whether an inbound at that route while the global ceiling still bounds the pre-routing read.
header is trusted (fork 2). - 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 - Thread the id into the logging middleware's output, since a request id nothing
logs is decoration. logs is decoration.
- Verify: an oversized body is refused per-route; the id appears in logs and is - Verify: an oversized body is refused per-route; a request under the global but
stable across a request's lifetime. 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 ### Phase D — response helpers and negotiation ranking
- `Location`, `Vary`, `Attachment`/`Download`, and a `format`-style dispatch - `Location`, `Vary` (comma-join accumulation, decision 5), `Attachment`/
choosing a builder from `accepts()`. `Download`, and a `format`-style dispatch choosing a builder from `accepts()`.
- Rank q-values properly instead of stripping them, retiring the ledger's 🔶. - Rank q-values properly instead of stripping them, retiring the ledger's 🔶:
- Verify: `Vary` accumulates rather than overwrites (which the iteration-2 parse `Accept` into type/q pairs, and pick the highest-q acceptable match.
repeated-header work makes possible); negotiation picks the highest-q match, - Verify: `Vary` accumulates rather than overwrites; negotiation picks the
not the first. highest-q match, not the first listed.
### Phase E — the gate and the ledger ### 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. - Verify: `just web-app`, `just site`, `just linkcheck` green.
## Acceptance Criteria ## Acceptance Criteria
@ -93,23 +137,26 @@ readiness: refine
yields `405` with an `Allow` listing exactly the registered methods. yields `405` with an `Allow` listing exactly the registered methods.
- **Given** `head` auto-registration, **when** a `HEAD` request hits a `GET` - **Given** `head` auto-registration, **when** a `HEAD` request hits a `GET`
route, **then** the response is headers-only with the `Content-Length` a `GET` route, **then** the response is headers-only with the `Content-Length` a `GET`
would have sent — the behaviour `serialize()` already implements, now would have sent; **and** with auto-registration disabled the `GET` route
reachable by registration. answers `GET` only.
- **Given** a named route with `:param` captures, **when** a URL is built with - **Given** a named route with `:param` captures, **when** a URL is built with
the right parameters, **then** it matches that route's pattern exactly; the right parameters, **then** it matches that route's pattern exactly;
**and** a wrong or missing parameter is refused rather than producing a **and** a wrong or missing parameter is refused rather than producing a
plausible-looking wrong URL. plausible-looking wrong URL.
- **Given** two routes with different body limits, **when** a body exceeding the - **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. accepted at the large one.
- **Given** no per-route limit, **when** a request arrives, **then** the - **Given** no per-route limit, **when** a request arrives, **then** the
previous global limit applies unchanged. 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 - **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. 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 - **Given** an `Accept` header with q-values out of order, **when** negotiation
runs, **then** the highest-q acceptable type wins — not the first listed. runs, **then** the highest-q acceptable type wins — not the first listed.
- **Given** two `Vary` contributions from different middleware, **when** the - **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 ## Out Of Scope
@ -123,21 +170,17 @@ readiness: refine
- **Compile-time-checked URL building.** The typed version needs language - **Compile-time-checked URL building.** The typed version needs language
iteration [29](../language-runtime-database/29-compile-time-metaprogramming.md). iteration [29](../language-runtime-database/29-compile-time-metaprogramming.md).
Runtime-checked now, upgraded later. 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 - **Streaming responses, `SendFile`, byte ranges** — iterations
[6](06-streaming-core.md) and [8](08-static-and-lifecycle.md). [6](06-streaming-core.md) and [8](08-static-and-lifecycle.md).
- **Typed binding of params into a class** — language iteration 29 again. - **Typed binding of params into a class** — language iteration 29 again.
## Info ## Info
Forks the spec must settle: Forks are settled above. This iteration is entirely pure `.wo` and, unusually
for the track, has **no upstream dependency** — not the streaming seam, not
1. **Does `head` auto-register?** Fiber's default is yes with an opt-out. Auto is sessions, not even iteration 2's builtin (decision 2 keeps request ids off the
friendlier; explicit is more predictable and never surprises someone CSPRNG). It is the safest slice to pick up at any time, which is exactly why the
debugging why a route they did not register is answering. track lists it as independent.
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.