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:
parent
274f7c5361
commit
6b820fdd5f
2 changed files with 98 additions and 55 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue