writeonce/docs/stories/porch/05-routing-response-ergonomics.md
shoney.arickathil 6b820fdd5f 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)
2026-09-15 01:15:31 +02:00

10 KiB
Raw Blame History

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 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. 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 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.