- 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)
10 KiB
| track | iteration | status | readiness |
|---|---|---|---|
| porch | 5 | pending | ready |
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, re-checked 2026-09-06 against.dev/reference/fiber(v3,3ca9a9d):DisableHeadAutoRegister,Name()/GetRouteURL(),BodyLimit, and therequestidmiddleware'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.
Apphasget/post/put/delete_. ARoute { method: "PATCH" }literal already works, so this is registration ergonomics rather than capability. 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. 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.
Decisions locked (brainstorm 2026-09-06)
headauto-registers alongsideget, with an opt-out. Matches fiber (DisableHeadAutoRegisterproves the default is auto and people want the knob).serialize()already produces headers-only with a GET'sContent-Length, so this is pure registration — the behaviour exists, this makes it reachable without a hand-written literal.patch/options/allare plain additions;allregisters one handler for every method.- Request ids mirror the limiter's trust conclusion, and use a non-crypto
source. Default (
trust_inboundoff): always generate a fresh id and ignore any inboundX-Request-Id, because an open port can forge correlation ids and poison logs — exactly what the limiter'strust_proxyguards. Opt-in (behind the mandated proxy): honor an inboundX-Request-Idfor cross-hop tracing, generate when absent. The id is minted from a non-crypto unique source (time.ticksplus a per-process counter), not iteration 2'srandom_bytes— a request id is not a secret, and this keeps the whole iteration free of upstream dependencies. It is written toreq.ctxand echoed in the response header, and the logging middleware includes it. - The per-route body limit is a second check after routing; the global
BODY_MAXstays as the pre-routing ceiling. The body is read inparse_requestbefore the route is known (parse.wo), so something must cap bytes first — the global remains that DoS ceiling. Abody_limit: Intfield onRoute(default the global, and never above it) is checked in dispatch againstlen(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. - Adding fields to
Routeis free of the conformance corpus. Fork 3 warned the corpus pinsRoute's ownership shape; it does not —container-owned-movedefines its own localRoute/Appto 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 ownedh: Handler. Varyaccumulates by comma-joining in the existing header map — no iteration 2 needed. OneVary: A, Bheader 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,alltoApp, each pushing aRouteliteral asget/postalready do;allregisters the handler for every method.headauto-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;
405still carries a correctAllowbuilt from the real table (theapp.wodispatch loop already assembles it); existing routes unchanged.
Phase B — named routes and URL building
- A
namefield onRoute, a lookup by name, and a builder that fills:paramcaptures 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
@deriveexists — 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: InttoRoute, defaulting to the globalBODY_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.ctxand 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 aformat-style dispatch choosing a builder fromaccepts().- Rank q-values properly instead of stripping them, retiring the ledger's 🔶:
parse
Acceptinto type/q pairs, and pick the highest-q acceptable match. - Verify:
Varyaccumulates 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/referenceprojects used. - 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; and with auto-registration disabled theGETroute answersGETonly. - 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 (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
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 in one comma-joinedVary.
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.
- Cryptographically random request ids. Not needed — a request id is not a
secret (decision 2). If iteration 2 is landed,
random_bytesis an acceptable alternative source, but this iteration must not depend on it. - Streaming responses,
SendFile, byte ranges — iterations 6 and 8. - 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.