writeonce/docs/superpowers/plans/2026-08-29-porch-store-backed-middleware.md
shoney.arickathil dbee71a9e5 docs(porch-store): implementation plan, and a spec correction
- 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) <noreply@anthropic.com>
(cherry picked from commit f079455755a189a86bb12e07cc11549ed7a78b91)
2026-09-15 01:15:30 +02:00

15 KiB

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

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.