From dbee71a9e57144c27ff6910ed12184941c2e70ae Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Sat, 29 Aug 2026 23:14:28 +0200 Subject: [PATCH] docs(porch-store): implementation plan, and a spec correction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the spec's blocking design was unimplementable: call's reply IS the return value of receive, so an actor cannot hold a waiter. Holding means never returning, and an actor that never returns cannot process the completion it waits for — deadlock - corrected shape: the actor RUNS the handler inside its own receive, so a duplicate waits in the mailbox and is served after the owner. The queue blocking needs is the mailbox; nothing is held - verified before adopting it, not after: an actor can receive a message carrying an interface-typed value and invoke it, so the route's Handler passes through the mailbox - spec History records the reasoning error — "the primitives landed" was taken as "blocking needs no new surface", which does not follow - plan: 5 tasks. Counting and replay live in one new keypool.wo; both middlewares become thin key-choosers, so porch 2 and 3 inherit one serialization convention instead of re-implementing it - self-review added two legs it was missing: exact counting under real concurrency (the criterion the pool exists for), and pruning an elapsed limiter row rather than resetting it, which otherwise leaks a row per IP ever seen - plan is code-free per house convention; the writing-plans skill wants code blocks and the project rule overrides it Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit f079455755a189a86bb12e07cc11549ed7a78b91) --- ...026-08-29-porch-store-backed-middleware.md | 276 ++++++++++++++++++ ...29-porch-store-backed-middleware-design.md | 68 ++++- 2 files changed, 330 insertions(+), 14 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-29-porch-store-backed-middleware.md diff --git a/docs/superpowers/plans/2026-08-29-porch-store-backed-middleware.md b/docs/superpowers/plans/2026-08-29-porch-store-backed-middleware.md new file mode 100644 index 0000000..a6ea083 --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-porch-store-backed-middleware.md @@ -0,0 +1,276 @@ +# porch 1 — store-backed middleware: implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: use +> `superpowers:subagent-driven-development` or `superpowers:executing-plans` to +> work this task-by-task. Steps are checkboxes. +> +> **House convention: this plan carries concept, reason and actions in words — +> no implementation or test code blocks.** The engineer writes the code; the +> plan says what must be true and why, and names every file and symbol +> involved. Exact strings that must match (error text, header names) are quoted +> inline. + +**Goal:** rate limiting and idempotent replay, both durable across a restart, +both serialized per key through an actor pool so neither loses a write to a +concurrent duplicate. + +**Architecture:** a fixed pool of identical actors selected by hash of the key. +Actors own serialization and volatile state; `@table` rows own durability. For +idempotency the actor **runs the handler itself**, so a duplicate waits in the +mailbox rather than needing a reply to be held — `call`'s reply is the return +value of `receive`, and there is no deferred-reply primitive. + +**Tech stack:** writeonce `.wo` under `docs/examples/porch/`, the embedded +`@table` store, and the actor builtins `spawn` (68), `send` (69), `call` (88), +`monitor` (89), `time.after` (90) — all landed. + +**Spec:** +[`2026-08-29-porch-store-backed-middleware-design.md`](../specs/2026-08-29-porch-store-backed-middleware-design.md) + +## Global constraints + +- Window arithmetic uses `time.ticks()` — µs, monotonic. A wall-clock jump must + never grant an extra window. +- Any header carrying a timestamp to a client uses `time.now()` — wall clock. + Monotonic ticks are seconds-since-boot and meaningless to a client. +- Mutating a stored row is a field assignment on the row, which writes through + and maintains indexes. Never delete-then-insert: it doubles WAL traffic and + leaves a window where a failed insert after a successful delete loses the row. +- A saturated mailbox (`call` trapping `WO_T_ACTOR`) answers **503** with + `Retry-After`, for both features. Saturation must never become the limiter's + bypass. +- Never swallow a store failure with an empty catch. The current limiter's + `catch (e) nil` is how a lost counter becomes silent. +- `woc docs/examples/porch/` must exit 0 after every task. It is a library with + no entry point; a compile error there breaks every downstream example. +- Gates: `just web-app`, `just site`, `just linkcheck` all green at the end. + +## File structure + +| File | Responsibility | +| --- | --- | +| `docs/examples/porch/middleware/store.wo` | the two `@table` classes and nothing else. Gains a digest column on the idempotency row. | +| `docs/examples/porch/middleware/keypool.wo` | **new.** The actor, its message classes, and the hash-to-shard selection. The only file that knows a pool exists. | +| `docs/examples/porch/middleware/limiter.wo` | the `Limiter` middleware: key selection, and a `call` into the pool. Holds no counting logic. | +| `docs/examples/porch/middleware/idempotent.wo` | the `Idempotent` middleware: digest computation, and a `call` into the pool carrying the route's `Handler`. | +| `scripts/web-app-accept.sh` | the gate legs, including the restart and concurrency legs. | +| `docs/examples/porch/README.md` | ledger rows moved from 🔶 to ✅ only when their gate leg exists. | + +Counting and replay logic lives in `keypool.wo` alone. The two middlewares +become thin: they decide a key and delegate. That is what stops sessions and +CSRF from each re-implementing serialization in iterations 2 and 3. + +--- + +### Task 1 — the store gains a digest column + +**Files:** modify `docs/examples/porch/middleware/store.wo`. + +**Produces:** an `IdempotencyKey` row carrying `digest: Text` alongside `key`, +`response` and `created_at`. + +- [ ] **Step 1 — add the column.** Add `digest: Text` to `IdempotencyKey`. The + lookup key becomes the **bare** idempotency key; the digest of + method+path+body is data, not part of the identity. Fold the digest into + the key and "reused key, different body" becomes undetectable — nothing + ever looks the bare key up, so nothing can refuse. +- [ ] **Step 2 — check it compiles.** Run `woc docs/examples/porch/`. Expect + exit 0. A `.wo` library typechecks entry-less. +- [ ] **Step 3 — confirm the annotation is unchanged.** Both tables stay + `durable: true` and fully resident. `resident: keys` is refused at load + today and is not wanted here anyway: these tables are small and hot. +- [ ] **Step 4 — commit.** Prefix `feat(porch-store)`. + +--- + +### Task 2 — the key pool: the actor and its protocol + +**Files:** create `docs/examples/porch/middleware/keypool.wo`. + +**Consumes:** the tables from Task 1. + +**Produces:** the message classes and the pool accessor every later task uses. +Name them once here and do not rename them later: + +- a **count** message carrying the key, the limit and the window size; +- a **begin** message carrying the key, the digest, the request, and the + route's `Handler`; +- a pool type holding a list of actor addresses, and a selector that maps a key + to one of them by hash; +- a verdict class the limiter reads: whether the request is allowed, the count, + the limit, and the wall-clock reset instant. + +- [ ] **Step 1 — write the failing test as a gate leg stub.** Add a leg to + `scripts/web-app-accept.sh` that compiles a tiny program using the pool + and asserts two sequential counts return 1 then 2. It must fail now, + because `keypool.wo` does not exist. A test that cannot fail before the + code exists is not a test. +- [ ] **Step 2 — run it and watch it fail.** `just web-app`. Expect the new leg + to report a compile failure naming the missing class. +- [ ] **Step 3 — define the message classes and the verdict class.** Fields + only; no behaviour yet. +- [ ] **Step 4 — define the actor class with a `receive` per message type.** + Give it a `receive` that handles the count message and returns a verdict. + Counting reads the row for the key, decides, and writes the new count by + **assigning to the row's field** so it writes through. +- [ ] **Step 5 — window arithmetic.** If the elapsed monotonic time since the + stored window start exceeds the configured window, reset the count to + zero and restart the window. Use `time.ticks()`. Compute the reset + instant for the header from `time.now()` — the two clocks are not + interchangeable and mixing them is the defect being fixed. +- [ ] **Step 6 — prune, do not merely reset.** When a window has fully elapsed + and the client returns, delete the stale row rather than resetting its + count in place. Resetting keeps a row forever for every key ever seen, + which for IP-keyed limiting is an unbounded leak — one row per address + that ever touched the service. Deleting on access is the lazy expiry the + story specifies, and there is no sweeper to do it later. +- [ ] **Step 7 — the pool and its selector.** A construction function that + spawns N actors and returns the pool; a selector that hashes a key to an + index. Same key must always select the same actor — that is the entire + serialization mechanism. +- [ ] **Step 8 — run the leg.** Expect 1 then 2. +- [ ] **Step 9 — commit.** Prefix `feat(porch-store)`. + +--- + +### Task 3 — the limiter delegates + +**Files:** modify `docs/examples/porch/middleware/limiter.wo`. + +**Consumes:** the count message, the pool selector and the verdict from Task 2. + +**Produces:** a `Limiter` middleware with fields for the limit, the window, and +a `trust_proxy` flag defaulting to false. + +- [ ] **Step 1 — delete the counting logic.** All of it: the query, the + increment, the delete-then-insert, and the `catch (e) nil`. The limiter + must not touch the table at all. If any store call remains in this file + the serialization guarantee is void. +- [ ] **Step 2 — key selection.** With `trust_proxy` false, key on + `net.peer(req.conn)`, which cannot be forged. With it true, key on + `client_ip(req)` — the left-most `X-Forwarded-For` entry — and the app + author is asserting a proxy they control overwrites that header. Prefer + `req.principal` when it is non-empty. **Delete the + `req.ctx["verified_proxy"]` branch**: nothing anywhere sets that key, so + it is dead code that reads as a security control. +- [ ] **Step 3 — call the pool.** Send the count message and act on the + verdict. Set `X-RateLimit-Limit`, `X-RateLimit-Remaining` and + `X-RateLimit-Reset` on both the allowed and the refused path. +- [ ] **Step 4 — the refusal.** On a spent window return 429 with + `Retry-After` in seconds. +- [ ] **Step 5 — saturation.** Catch the actor trap from `call` and return 503 + with `Retry-After`. Do not allow the request through. A limiter that + stops limiting under load is worse than absent, because saturating the + pool is then the bypass. +- [ ] **Step 6 — gate leg: the threshold.** Add a leg asserting that of N+1 + requests the first N pass and the last is 429 carrying `Retry-After`. +- [ ] **Step 7 — gate leg: the restart.** Drive the limiter to its limit, + `SIGTERM` the process, restart it against the same `WO_DATA`, and assert + the client is **still** limited. This is the leg that proves the + differentiator over an in-memory limiter, and it is the one most likely + to be skipped. A durability claim no gate exercises is not a claim. +- [ ] **Step 8 — gate leg: exact counting under concurrency.** Fire N requests + for one key **genuinely in parallel** and assert the recorded count is + exactly N. This is the criterion the whole actor pool exists for: the old + read-modify-write lost increments when two fibers interleaved, so a + limiter under load stopped limiting at precisely the moment it mattered. + A sequential version of this leg passes against the broken code and + proves nothing. +- [ ] **Step 9 — run all three legs.** Expect green. +- [ ] **Step 10 — commit.** Prefix `feat(porch-store)`. + +--- + +### Task 4 — idempotency, with the actor running the handler + +**Files:** modify `docs/examples/porch/middleware/idempotent.wo`. + +**Consumes:** the begin message and the pool from Task 2; the digest column +from Task 1. + +**Produces:** an `Idempotent` middleware taking the key header name, an +`include_body` flag and a TTL. + +- [ ] **Step 1 — read the spec's reason before writing code.** The middleware + does **not** run the handler and store afterwards. It hands the request + and the route's `Handler` to the actor, and the actor invokes the handler + inside its own `receive`. A duplicate then waits in the **mailbox** and is + served after the owner returns. This is forced: `call`'s reply is the + return value of `receive`, so a reply cannot be held for later. +- [ ] **Step 2 — delete the old flow.** Remove the `before`/`after` pair, the + `idem_miss` and `idem_key` context keys, and the ten-second in-flight + heuristic. That heuristic was inverted — its timestamp is stamped when + the response is stored, not when the request starts, so it fired on + legitimate fast replays and never on a real collision. +- [ ] **Step 3 — the digest.** Compute a digest of method, path and body when + `include_body` is set, and pass it alongside the bare key. Keep `use + json` — the file did not typecheck without it. +- [ ] **Step 4 — the actor arm for the begin message.** Look the bare key up. + A hit whose digest matches returns the stored response. A hit whose + digest differs returns a refusal, answered as **422**. A miss runs the + handler, stores status, body and `content-type` with the digest and the + current tick, and returns the response. +- [ ] **Step 5 — the replay allowlist.** Store and replay `content-type` only. + Never `Set-Cookie`, never `Date`. Cookies arrive in porch 2; this is + cheap now and expensive to retrofit after. +- [ ] **Step 6 — lazy expiry.** On access, if the stored row is older than the + TTL, delete it and treat the request as a miss. There is no sweeper and + no scheduler; `time.after` is a one-shot timer aimed at an actor, not a + recurring sweep. +- [ ] **Step 7 — saturation.** Same rule as the limiter: a trapped `call` + answers 503, not a silent second execution. +- [ ] **Step 8 — gate leg: replay is exact.** Assert the replayed response is + byte-identical **and** that the handler's side effect happened once — + counted from a row count in the store, never from a log line. +- [ ] **Step 9 — gate leg: digest mismatch.** Reuse a key with a different body + and assert 422, not the other request's response. +- [ ] **Step 10 — gate leg: concurrent duplicates.** Dispatch two identical + keyed requests **genuinely in parallel** — backgrounded clients, not two + sequential calls — against a handler slow enough to overlap. Assert + exactly one execution and that both clients receive the same response + body. Sequential requests cannot fail this leg, so a sequential version + of it proves nothing. +- [ ] **Step 11 — run the legs.** Expect green. +- [ ] **Step 12 — commit.** Prefix `feat(porch-store)`. + +--- + +### Task 5 — pool saturation, and closing out + +**Files:** modify `scripts/web-app-accept.sh`, +`docs/examples/porch/README.md`, `docs/stories/porch/01-store-backed-middleware.md`, +`docs/stories/00-status.md`. + +- [ ] **Step 1 — gate leg: saturation fails closed.** Configure a pool of one + actor and a handler slow enough to fill its mailbox, then assert the + overflow answers 503 and that no request slips through uncounted. This + is the leg that proves saturation is not a bypass. +- [ ] **Step 2 — document the pool size knob.** Record in the README what the + pool size bounds and what happens when it is too small: 503s, not silent + overshoot. It is a capacity decision, not a default to ignore. +- [ ] **Step 3 — the ledger.** Move the two rows in the README from 🔶 to ✅, + and only now — a ✅ whose gate leg does not exist is the thing this + project keeps catching. +- [ ] **Step 4 — the story.** Fill the Progress table with real commit hashes, + move the criteria from Outstanding to Met **recording how each was + verified**, and set `status: done` in the frontmatter. +- [ ] **Step 5 — the board.** Add the standup entry: what landed, what did not, + dependencies unblocked, next steps. +- [ ] **Step 6 — run everything.** `just web-app`, `just site`, + `just linkcheck`. All green. +- [ ] **Step 7 — commit.** Prefix `docs(porch-store)`. + +--- + +## What this plan deliberately does not do + +- **No pluggable storage interface.** One store exists; an abstraction with one + implementor is decoration. +- **No sliding window or token bucket.** Fixed window is what the sample needs; + a better algorithm is a later slice with a measurement behind it. +- **No deferred-reply runtime primitive.** It would make the original design + implementable and is useful beyond this feature, but porch 1 was chosen as + the slice needing no runtime work. It belongs in its own iteration. +- **No blocking deadline.** A duplicate waits as long as its owner's handler + runs. If a deadline proves necessary it should arrive with the measurement + that justifies its value, not ahead of it. 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 index f1df009..32bc7a7 100644 --- 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 @@ -118,18 +118,35 @@ request — and paying it is what makes the restart criterion true. 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 actor runs the handler.** This is the correction of 2026-08-29 — see +History. A keyed request does not run its own handler; it calls its pool actor, +passing the request and the route's `Handler`, and the actor invokes the +handler inside its own `receive`. -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. +| Actor state for that key | What `receive` returns | +| --- | --- | +| A stored response exists, digest matches | the stored response; the handler never runs | +| A stored response exists, digest differs | a refusal verdict, answered as 422 | +| No record | run the handler here, store the response, return it | + +There is no fourth row, and that is the point. **A duplicate arriving while the +owner's handler runs waits in the mailbox**, because an actor processes one +message at a time. It is dequeued after the owner's `receive` returns, finds +the stored response, and is answered with it. The queue that blocking needs +already exists and is the mailbox; nothing has to hold a reply. + +Why it must be this way: `call`'s reply **is** the return value of `receive`. +There is no handle to stash and answer later. An actor that tried to hold a +waiter would have to not return from `receive`, and while it has not returned +it processes nothing else — including the owner's completion message. That +deadlocks. Verified before committing to it: an actor can receive a message +carrying an interface-typed value and invoke it, so passing the route's +`Handler` through the mailbox works. + +The cost is real and must be stated: the actor is occupied for the whole +duration of the handler it runs, so a slow keyed handler blocks other keys that +hash to the same actor. Pool size is what bounds that, and it is the same knob +that bounds saturation. 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 @@ -210,6 +227,27 @@ 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. +## History — one correction, made before any code + +**`call` cannot defer a reply, and the first version of this spec assumed it +could.** The design said the actor would hold a duplicate's reply and answer it +once the owner reported. That is not expressible: `call`'s reply is the return +value of `receive` (`tests/corpus/run/call-echo/fixture.wo`; `msg_caller` in +`runtime/src/vm.c` is answered on handler completion), so holding a waiter +means never returning, and an actor that never returns processes nothing else — +including the completion it is waiting for. + +The error behind it is worth keeping: the brainstorm established that `spawn`, +`send`, `call`, `monitor` and `time.after` had all landed, and concluded from +that that blocking needed no new runtime surface. **The primitives existing is +not the same as one of them supporting deferred reply.** The conclusion was +right by luck — blocking is achievable — but the reasoning did not support it, +and the shape it produced was unimplementable. + +Inverting so the actor runs the handler gets the same semantics from the +mailbox itself, and was verified with a throwaway fixture before adoption +rather than after. + ## Risks - **The pool is now on every request path.** Exact counting was chosen over a @@ -217,6 +255,8 @@ author declare it; story 35 owns proving it. 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. +- **A duplicate waits as long as its owner's handler runs**, and so does every + other key that hashes to the same actor, because the actor is occupied while + it runs a handler. This is the sharpest cost of the inversion. No deadline is + specified here; if one proves necessary it belongs with the measurement, not + ahead of it.