- docs/stories/porch/ — a TRACK folder, not a status folder: status still
lives only in frontmatter. Adds `track: porch` so a query over
docs/stories/ can tell a porch 3 from a language 3
- 00-story.md carries the sequence, the dependency graph, and a table of
what the track explicitly does NOT own (binding -> 29, cache -> 18,
proxy -> 38, metrics -> 30, TLS/templates -> doctrine)
- eight iterations, each with phases, per-phase tasks, Given/When/Then
criteria, out-of-scope and the forks a spec must settle:
1 store-backed middleware (limiter + idempotency — needs nothing new,
first on purpose so the store pattern is proven cheaply)
2 randomness + cookies (phase A is language-track: a CSPRNG builtin;
`Resp.headers` being a map cannot emit two Set-Cookie lines)
3 sessions 4 CSRF 5 routing/response ergonomics (independent)
6 streaming core (the seam 7 and 8 wait on; chunked-request refusal
must survive) 7 SSE + compression 8 static + lifecycle hooks
- language iteration 39 -> status: hold, retitled superseded, with a row
mapping each of its goals to the porch iteration that took it. Kept, not
deleted: the Fiber study cites it and its randomness argument is what
this track is built on
- board gains a porch section; board-views gains porch and both-track
Dataview queries; porch README and the Fiber study §7 point at the track
- no code blocks in any story (plans carry concept and actions in words);
linkcheck 0 broken / 0 anchors
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7.2 KiB
7.2 KiB
| track | iteration | status |
|---|---|---|
| porch | 5 | refine |
porch 5 — routing and response ergonomics: the parity that is merely missing
Part of Story —
porch, the writeonce web framework. Source: the Fiber parity study §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.
Apphasget/post/put/delete_. ARoute { method: "PATCH" }literal already works, so this is registration ergonomics rather than capability — but writing the literal by hand forPATCHwhilegetexists is the kind of asymmetry that makes a framework feel unfinished. Addpatch,options,head, and anall. - Named routes and reverse routing. Fiber has
Name()andGetRouteURL(). 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 = 1048576is 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.ctxalready 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-negotiatedformatdispatch on top of the existingaccepts(). 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 whetherheadauto-registers alongsideget(Fiber hasDisableHeadAutoRegister, 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;
405still carries a correctAllowbuilt 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:paramcaptures. - 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
@deriveexists — 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 aformat-style dispatch choosing a builder fromaccepts().- Rank q-values properly instead of stripping them, retiring the ledger's 🔶.
- Verify:
Varyaccumulates 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 linkcheckgreen.
Acceptance Criteria
- Given a route registered with each new helper, when the matching
method arrives, then it dispatches; and an unmatched method still
yields
405with anAllowlisting exactly the registered methods. - Given
headauto-registration, when aHEADrequest hits aGETroute, then the response is headers-only with theContent-LengthaGETwould have sent — the behaviourserialize()already implements, now reachable by registration. - Given a named route with
:paramcaptures, 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
Acceptheader with q-values out of order, when negotiation runs, then the highest-q acceptable type wins — not the first listed. - Given two
Varycontributions 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. Runtime-checked now, upgraded later.
- Streaming responses,
SendFile, byte ranges — iterations 6 and 8. - Typed binding of params into a class — language iteration 29 again.
Info
Forks the spec must settle:
- Does
headauto-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. - 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_ipandnet.peeralready exist for exactly this trust decision — reuse that conclusion. - Where does a route's body limit live? A field on
Routeis the obvious home but widens a record that the conformance corpus pins the ownership shape of. Check that fixture before choosing.