writeonce/docs/stories/porch/01-store-backed-middleware.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.5 KiB
Raw Blame History

track iteration status
porch 1 refine

porch 1 — store-backed middleware: rate limiting and idempotency

Part of Story — porch, the writeonce web framework. Source: the Fiber parity study §2.

First deliberately because it is the cheapest. Both features need only a @table and time.ticks, both of which already exist — no new builtin, no cookie, no change to Resp. It exists to prove the store pattern and the gate shape on low-risk work before iterations 2–4 touch the runtime and the public response type.

Goals

  • A rate limiter that survives a restart. Fixed-window counting keyed by client, answering 429 with the conventional headers when the window is spent. Fiber's limiter keeps counters in memory by default and expects Redis for anything real; porch's live in a @table, so they are WAL-durable and crash-recoverable for free. That is the difference worth demonstrating, and it is why the acceptance criteria include a restart.
  • Idempotent replay of unsafe requests. A client resending a POST with the same idempotency key gets the stored response and the handler does not run twice. This is the correctness feature the storefront sample has silently needed since it grew a checkout.
  • Establish the store convention for iterations 2–4. Sessions and CSRF will want the same shape. Decide it once, here, on the cheap slice.

Phases

Phase A — the store convention

  • Decide the store shape (see Info fork 1) and write it down before any middleware exists, because three later iterations inherit it.
  • Add the @table classes for a counter row and a stored-response row, with the secondary indexes their lookups need — the read path is an equality probe, which is the only index shape the engine has.
  • Decide and document the expiry discipline: rows are pruned lazily on access, not by a background sweeper, because porch has no timer and iteration 30 owns scheduled work.
  • Verify: woc docs/examples/porch/ typechecks entry-less as a library; the new tables appear in the WAL and replay across a restart.

Phase B — the rate limiter

  • A Limiter middleware class with a before that counts and either passes or short-circuits with 429 — the ?Resp short-circuit the chain already has.
  • Key selection: reuse client_ip(req) and the existing trusted-proxy judgement rather than inventing a second one. A keyed-by-principal variant falls out for free once req.principal is set by an auth middleware.
  • The response headers on both paths, and a Retry-After on the refusal.
  • Window arithmetic on time.ticks (µs monotonic), not time.now — a wall-clock jump must not hand out a free window.
  • Verify: a burst crosses the threshold at exactly N, the window rolls, the counters survive SIGTERM + restart.

Phase C — idempotency

  • An Idempotent middleware pair: before looks the key up and replays a hit; after stores the response for a miss. This is the first real user of the after chain for something other than headers, which is worth noting.
  • Decide what is part of the identity: the key header alone, or key plus a digest of method+path+body (sha256 exists). Replaying a stored response for a different body under a reused key is the failure mode that matters.
  • In-flight collision handling: a second request arriving while the first is still running. Fiber takes a lock; porch's shard model means the honest answer is probably to refuse with 409 rather than to block.
  • Verify: replay returns the stored response, the handler's side effect happens exactly once, a reused key with a different body is refused, concurrent duplicates do not both execute.

Phase D — the gate and the ledger

  • Extend scripts/web-app-accept.sh with the checks above, including the restart leg — a durability claim that no gate exercises is not a claim.
  • Update porch's README ledger rows for both features, and record in the status board what landed versus what was planned.
  • Verify: just web-app and just site both green; just linkcheck clean.

Acceptance Criteria

  • Given a limiter of N requests per window, when a client sends N+1, then the first N succeed and the last is 429 with Retry-After set.
  • Given counters at their limit, when the process is SIGTERMed and restarted, then the client is still limited — the counters replayed from the WAL rather than resetting to zero.
  • Given a window that has fully elapsed, when the same client returns, then it is served, and the expired row is pruned on that access.
  • Given the system clock jumping backwards, when the window is evaluated, then no extra allowance is granted (time.ticks is monotonic).
  • Given a POST with an idempotency key that has been seen, when it is replayed, then the stored response is returned byte-identically and the handler's side effect count is unchanged — proven by a row count, not by a log line.
  • Given a reused idempotency key with a different request body, when it arrives, then it is refused rather than answered with the other request's response.
  • Given two identical keyed requests in flight at once, when both are dispatched, then exactly one executes and the other gets the decided answer (replay or 409), never a partial write.

Out Of Scope

  • A pluggable Storage interface. Fiber abstracts it so one middleware runs on memory or Redis. porch has one store, and interfaces here are structural — an abstraction with exactly one implementor is decoration, which iteration 37 learned the hard way about Component. Concrete @table until a second backend actually exists.
  • Sliding-window or token-bucket algorithms. Fixed window is what the sample needs; a better algorithm is a later slice with a measurement behind it.
  • Distributed limiting across processes. One program owns its database; cross-program state is language iteration 20.
  • A background expiry sweeper. No timer exists (time.after is still a reserved builtin id in wob.h). Lazy pruning on access, deliberately.
  • The TTL cache middleware — language iteration 18 owns it, spec already approved. Do not build a second cache here.

Info

Forks the spec must settle:

  1. One store or two? A single generic key/value/expiry table serving both features, or a purpose-shaped table each. Leaning two: the columns genuinely differ (a counter is an Int, a stored response is status + headers + body), and a generic table would force everything through Text, which is how the framework's cache ended up storing JSON strings.
  2. What is the limiter's key when there is no auth? client_ip(req) reads X-Forwarded-For, which a direct client can forge. Behind the mandated TLS proxy that is fine; on an open port it is not. net.peer(fd) gives the real peer — decide which is authoritative and reuse whatever the trusted-proxy slice concludes rather than deciding twice.
  3. Does idempotency store headers? Fiber has KeepResponseHeaders because replaying Set-Cookie or a fresh Date is usually wrong. porch has no cookies yet (iteration 2), so this is cheap to decide now and expensive to retrofit later.

Nothing here needs a new runtime primitive, which is the point of going first.