From 9f2d19083e2c3f70fc3135f7806619286ad45396 Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Sat, 29 Aug 2026 21:55:25 +0200 Subject: [PATCH] docs(porch-store): spec for porch 1 store-backed middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) (cherry picked from commit fc09e94373db65837ff5eb620fec67bab02c1931) --- ...29-porch-store-backed-middleware-design.md | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-29-porch-store-backed-middleware-design.md diff --git a/docs/superpowers/specs/2026-08-29-porch-store-backed-middleware-design.md b/docs/superpowers/specs/2026-08-29-porch-store-backed-middleware-design.md new file mode 100644 index 0000000..f1df009 --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-porch-store-backed-middleware-design.md @@ -0,0 +1,222 @@ +# porch 1 — store-backed middleware: rate limiting and idempotency + +Design settled 2026-08-29. Implements +[porch 1](../../stories/porch/01-store-backed-middleware.md). + +## 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.