- `readiness: ready | refine` is a SECOND axis, orthogonal to status.
`ready` = the brainstorm is complete and the decisions are LOCKED (a spec
approved, or the forks explicitly confirmed). `refine` = open forks remain
and it cannot be planned yet
- `status: refine` RETIRED because it carried both meanings at once, so a held
iteration with an approved spec (language 18, 26) was indistinguishable from
one nobody had thought about. status is now purely where the WORK is:
done | in-progress | pending | hold — `pending` was already the board's own
rendering word, so nothing new was invented
- all 47 iterations classified from EVIDENCE in their own text, not by guess:
"the four forks are SETTLED" / "spec + plan approved" / "Approved spec:" for
ready; "Forks the spec must settle" / "no spec exists yet" for refine. Every
shipped iteration is ready by definition. 19 done, 5 in-progress, 15
pending, 8 hold; 27 ready, 20 refine
- two iterations moved refine -> in-progress rather than -> pending: language
31 and 34 are absorbed into 24 and work on them is literally happening, which
the board already showed as 🔄 while their frontmatter said otherwise. That
disagreement is now gone
- board legend, board-views' frontmatter contract, and two new Dataview
queries updated — the useful one being `readiness: ready AND status:
pending`, the startable set
WHAT THE NEW AXIS IMMEDIATELY SURFACED: of 15 pending iterations, exactly ONE
is startable — databasev2 4, io_uring group-commit, whose forks were confirmed
settled 2026-08-20. Everything else pending needs a brainstorm first. That was
invisible while one key carried both meanings, and it is now on the board.
Also caught by the sweep, unrelated to readiness but found by cross-checking
frontmatter against the board: SIX duplicate rows. Every iteration moved into
databasev2 was still listed in the LANGUAGE pending table under its retired id
(23, 32, 33, 20, 21, 27) as well as its new one. Stale copies removed. And two
databasev2 rows made claims the sweep contradicts — iteration 1 was billed
"startable today" while its forks are open, and 6 still called itself the
ceiling-raiser after 2 took that role.
Docs only. linkcheck 0 broken / 0 anchors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
143 lines
7.2 KiB
Markdown
143 lines
7.2 KiB
Markdown
---
|
||
track: porch
|
||
iteration: "5"
|
||
status: pending
|
||
readiness: refine
|
||
---
|
||
|
||
# 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.
|
||
>
|
||
> 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.
|
||
|
||
## 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`.
|
||
- **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. 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.
|
||
- **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.
|
||
|
||
## 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.
|
||
- Verify: each method dispatches; `405` still carries a correct `Allow` built
|
||
from the real table; 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.
|
||
- 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).
|
||
- 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.
|
||
|
||
### 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.
|
||
|
||
### Phase E — the gate and the ledger
|
||
|
||
- Both serving gates, the ledger rows, the board entry.
|
||
- 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 — the behaviour `serialize()` already implements, now
|
||
reachable by registration.
|
||
- **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
|
||
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, **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.
|
||
|
||
## 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.
|
||
- **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.
|