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

View file

@ -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.