writeonce/docs/stories/porch/05-routing-response-ergonomics.md
shoney.arickathil 01df75245f docs(porch): give the framework its own story track, iterations 1-8
- 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>
2026-08-26 20:17:33 +02:00

7.2 KiB
Raw Blame History

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

  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.