writeonce/docs/superpowers/specs/2026-08-29-porch-store-backed-middleware-design.md
shoney.arickathil 9f2d19083e docs(porch-store): spec for porch 1 store-backed middleware
- supersedes Phases B and C as built: a store-after-completion
  middleware cannot satisfy three of the story's seven criteria
- in-flight collision is undetectable (the row is written after the
  handler ran, so concurrent duplicates both miss and both execute)
- the 10s in-flight heuristic is inverted: created_at is stamped at
  store time, so it fires on legitimate fast replays and never on a
  genuinely concurrent request
- "reused key, different body is refused" is unreachable while the
  digest is folded into the key — nothing looks the bare key up
- design: sharded actor pool serializes per key, @table persists;
  actors own volatile state, tables own durability. Inherited by
  porch 2 and 3
- limiter joins the pool for exact counting, writes through instead of
  delete+insert, keys on net.peer unless trust_proxy is declared, and
  uses monotonic ticks for arithmetic but wall clock for the header
- idempotency blocks rather than answering 409: call parks the
  duplicate until the owner reports. Digest becomes a column
- saturation fails closed with 503 for both: saturating the pool must
  not become the limiter bypass
- records that the story's "time.after is still reserved" is stale;
  spawn/send/call/monitor/time.after all landed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit fc09e94373db65837ff5eb620fec67bab02c1931)
2026-09-15 01:15:30 +02:00

12 KiB

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

Design settled 2026-08-29. Implements porch 1.

Why this is a redesign, not a first draft

Phases A, B and C already have code on dev — 519d411, 5b1e82a, aee7926. Phase A survives. Phases B and C are superseded, because both are built around a middleware that stores its result after the handler returns, and three of the story's seven acceptance criteria cannot be satisfied by that shape:

  • In-flight collision is undetectable. The before hook finds nothing and passes; the row is written by after, once the handler has already run. Two concurrent duplicates both miss and both execute. There is no reservation for them to collide on.
  • The in-flight heuristic is inverted. It treats a row younger than ten seconds as "in flight", but the timestamp is stamped when the response is stored, not when the request starts. So it fires on a legitimate fast replay — the common case — answering 409 where the stored response was owed, and it can never fire on a genuinely concurrent request.
  • "Reused key, different body is refused" is unreachable by construction. The body digest is folded into the lookup key, so a different body is a different key and simply misses. That is safe — the wrong response is never served — but nothing ever looks the bare key up, so nothing can refuse.

Two further defects, independent of the redesign: the limiter emits a monotonic tick value into X-RateLimit-Reset, where clients expect a Unix timestamp; and its key selection branches on a request-context flag that nothing anywhere sets, so the branch is dead.

The shape

One idea, inherited by porch 2 (sessions) and 3 (CSRF):

Serialize through an actor. Persist in a @table. Never read-modify-write from a handler fiber.

A read-modify-write from a handler fiber is the defect both features share. The limiter reads a count, adds one, and writes it back; two fibers interleaved lose an increment, and a limiter that under-counts fails at precisely the moment it exists for. Idempotency has the same race with worse consequences. An actor processes one message at a time, so routing both features through one gives per-key serialization with no locks and no polling.

Actors own serialization and volatile state. Tables own durability. No middleware touches those tables directly.

The pool

A fixed pool of N identical actors, selected by a hash of the key modulo N. The same key therefore always reaches the same actor, which is the whole mechanism — ordering is only needed per key, never globally, and a pool buys concurrency across keys while a single actor would serialize the entire program's traffic through one mailbox.

N is a declared capacity knob, documented and set at construction. It is the one number in this design that a deployment may need to change.

Why the primitives are already there

The story states that nothing here needs a new runtime primitive, and then that time.after is "still a reserved builtin id". The second claim is stale and the first is still true. All of it landed with the actor-lifecycle work: spawn (68), send (69), call (88 — a send that waits, whose park/reply protocol lives in the VM), monitor (89), and time.after (90). call parking the caller until a reply arrives is exactly the blocking primitive this design needs, and it already exists.

Phase A — the store convention

Two purpose-shaped tables, as already built and as the story's first fork leaned. A counter row is an integer; a stored response is a status, a header set and a body. A single generic table would force both through text, which is how framework caches end up storing JSON strings for want of a column.

The stored-response table gains one new column: the request digest, stored beside the response rather than folded into the key. That single change is what makes refusal possible.

Expiry stays lazy — pruned when a key is next touched, never by a sweeper. The story is right that porch has no scheduler, and time.after is a one-shot timer aimed at an actor, not a recurring sweep.

Phase B — the rate limiter

Every request calls its pool actor and waits for a verdict. The actor reads the counter, decides, writes the new count, and replies with the decision plus the numbers the response headers need.

Three corrections to what exists:

  • Write through, do not delete and re-insert. Assigning to a field of a table row compiles to an engine update that maintains the row's indexes at the choke point. Delete-then-insert writes two WAL records where one will do, and leaves a window in which the delete has landed and the insert has not — which, with the failure swallowed as it currently is, silently loses the counter entirely and hands the client a free window. That is the exact inverse of the durability this iteration exists to demonstrate.
  • Key on the peer address by default. The limiter takes a trust-proxy flag defaulting to off. Off, it keys on the connection's real peer, which cannot be forged. On, it keys on the left-most forwarded-for entry, and the application author is asserting that a proxy they control overwrites that header. Only the deployer knows the topology, so the declaration belongs in their code — not in a context flag nothing sets. When story 35 lands a peer-address seam that can verify the proxy, it can downgrade a trust-proxy claim that is untrue.
  • Two clocks, deliberately. Window arithmetic uses the monotonic tick source so that a wall-clock jump cannot grant an extra window. The reset header must carry wall-clock time, because it is for a client that has no access to this process's boot time.

Persisting every increment is one WAL record per request. Group commit batches a drain into a single barrier, so the cost is amortised rather than a sync per request — and paying it is what makes the restart criterion true.

Phase C — idempotency

A keyed request calls its pool actor and receives one of three outcomes.

Actor state for that key Reply Caller does
A stored response exists, digest matches the stored response returns it; handler never runs
A stored response exists, digest differs a refusal verdict answers 422
No record, no owner ownership runs the handler, then reports the result back
An owner is already running nothing yet stays parked until the owner reports

The fourth row is the design. The actor does not answer a duplicate while an owner holds the key; it records that someone is waiting and replies to every waiter once the owner reports its result. Because call already parks the caller, waiting costs a parked fiber and no polling. There is no 409, and no timestamp heuristic.

The lookup key is the bare idempotency key. The digest of method, path and body is a column, compared on a hit. Equal means a genuine retry and earns the stored response; different means the key was reused for a different request, and that is refused rather than answered with another request's response.

Replay carries content-type only. The story's third fork is settled as built, and settled correctly: replaying a stored Set-Cookie or a stale Date is wrong, and porch has no cookies yet, so this is cheap now and expensive after iteration 2.

Failure semantics

Saturation fails closed. Mailboxes are bounded, so a burst can trap. Both features answer 503 with a retry hint rather than proceeding. A rate limiter that stops limiting under load is worse than absent, because saturating the pool becomes the bypass — and a burst is the thing it exists to stop. The cost is honest and must be documented: a genuine traffic spike degrades to 503 once the pool is full, which makes the pool size a real capacity decision rather than a default nobody reads.

A crash loses only volatile state. Counters and stored responses are in the WAL and replay. Ownership and waiter lists are in-memory and do not. A crash mid-handler therefore leaves no owner, and the next duplicate re-runs the handler — the behaviour the world had before the feature existed, rather than a key wedged permanently pending. This is the decisive advantage over persisting reservations: a durable pending row outlives the process that owned it and must then be rescued by a lease, whose expiry is another number to get wrong.

Phase D — the gate

The story is right that a durability claim no gate exercises is not a claim, and the restart leg is the one most likely to be quietly skipped. The acceptance script gains: a burst that crosses the threshold exactly; a restart between two bursts proving counters replayed rather than reset; a replayed key proving the handler's side effect count is unchanged, counted from a row count rather than a log line; a reused key with a different body earning a refusal; and two simultaneous duplicates proving exactly one execution.

The concurrency legs need genuine parallelism, not two sequential requests. A test that cannot fail before the fix is not a test.

Acceptance criteria

Carried from the story, with the three unreachable ones restated to match the design that replaces them.

  • Given a limiter of N per window, when a client sends N+1, then the first N succeed and the last is refused with a retry hint.
  • Given counters at their limit, when the process is terminated and restarted, then the client is still limited — replayed from the WAL, not reset.
  • Given a fully elapsed window, when the client returns, then it is served and the expired row is pruned on that access.
  • Given the system clock jumping backwards, when a window is evaluated, then no extra allowance is granted.
  • Given N concurrent requests for one key, when they are counted, then the total is exactly N — no increment is lost. (Newly reachable; the pool is what makes it true.)
  • Given a seen idempotency key, when it is replayed, then the stored response is returned byte-identically and the side-effect count is unchanged, proven by a row count.
  • Given a reused key with a different body, when it arrives, then it is refused. (Newly reachable; the digest column is what makes it true.)
  • Given two identical keyed requests in flight at once, when both are dispatched, then exactly one executes and the other receives that one's stored response — never a refusal, never a partial write. (Restated: the story allowed 409 here; blocking supersedes it.)
  • Given a saturated pool, when a request arrives, then it is refused with 503 rather than served uncounted.

Out of scope

Unchanged from the story: no pluggable storage interface, no sliding-window or token-bucket algorithm, no cross-process limiting, no background sweeper, and no TTL cache — language iteration 18 owns that and its spec is already approved.

Added: verifying that a peer really is the trusted proxy. This design lets an author declare it; story 35 owns proving it.

Risks

  • The pool is now on every request path. Exact counting was chosen over a free hot path deliberately, but it makes pool sizing a first-class operational concern, and the fail-closed rule converts undersizing into 503s rather than into silent overshoot. That trade is the point, and it needs to be measured before it is defended.
  • A parked duplicate waits as long as its owner runs. A slow handler holds its waiters. No deadline is specified here; if one proves necessary it belongs with the measurement, not ahead of it.