- 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)
186 lines
10 KiB
Markdown
186 lines
10 KiB
Markdown
---
|
||
track: porch
|
||
iteration: "5"
|
||
status: pending
|
||
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,
|
||
> 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 — 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. 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
|
||
a page silently.
|
||
- **A per-route body limit.** `BODY_MAX = 1048576` is one compile-time constant
|
||
for the whole server. An upload route and a JSON route want different numbers,
|
||
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. 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
|
||
|
||
- 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 (the `app.wo` dispatch loop already assembles it);
|
||
existing routes unchanged.
|
||
|
||
### Phase B — named routes and URL building
|
||
|
||
- 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
|
||
|
||
- 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; 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` (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, standup questions
|
||
answered including the `.dev/reference` projects used.
|
||
- Verify: `just web-app`, `just site`, `just linkcheck` green.
|
||
|
||
## Acceptance Criteria
|
||
|
||
- **Given** a route registered with each new helper, **when** the matching
|
||
method arrives, **then** it dispatches; **and** an unmatched method still
|
||
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; **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 (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 in one comma-joined `Vary`.
|
||
|
||
## Out Of Scope
|
||
|
||
- **A radix-tree router.** Path matching is a linear scan and the ledger marks
|
||
it 🔶 pending a *measurement*. Language iteration 22 built the benchmark
|
||
harness but pointed it at the database. Until someone benches the router, this
|
||
is an optimisation without evidence.
|
||
- **Case-insensitive or strict-slash routing.** Fiber exposes both as config.
|
||
porch is case-sensitive and lenient; changing that is a behaviour change for
|
||
existing apps and wants its own decision.
|
||
- **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 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.
|