- 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)
15 KiB
porch 1 — store-backed middleware: implementation plan
For agentic workers: REQUIRED SUB-SKILL: use
superpowers:subagent-driven-developmentorsuperpowers:executing-plansto 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 (
calltrappingWO_T_ACTOR) answers 503 withRetry-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) nilis 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 linkcheckall 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: TexttoIdempotencyKey. 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.wolibrary typechecks entry-less. - Step 3 — confirm the annotation is unchanged. Both tables stay
durable: trueand fully resident.resident: keysis 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.shthat compiles a tiny program using the pool and asserts two sequential counts return 1 then 2. It must fail now, becausekeypool.wodoes 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
receiveper message type. Give it areceivethat 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 fromtime.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_proxyfalse, key onnet.peer(req.conn), which cannot be forged. With it true, key onclient_ip(req)— the left-mostX-Forwarded-Forentry — and the app author is asserting a proxy they control overwrites that header. Preferreq.principalwhen it is non-empty. Delete thereq.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-RemainingandX-RateLimit-Reseton both the allowed and the refused path. - Step 4 — the refusal. On a spent window return 429 with
Retry-Afterin seconds. - Step 5 — saturation. Catch the actor trap from
calland return 503 withRetry-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,
SIGTERMthe process, restart it against the sameWO_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
Handlerto the actor, and the actor invokes the handler inside its ownreceive. A duplicate then waits in the mailbox and is served after the owner returns. This is forced:call's reply is the return value ofreceive, so a reply cannot be held for later. - Step 2 — delete the old flow. Remove the
before/afterpair, theidem_missandidem_keycontext 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_bodyis set, and pass it alongside the bare key. Keepuse 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-typewith the digest and the current tick, and returns the response. - Step 5 — the replay allowlist. Store and replay
content-typeonly. NeverSet-Cookie, neverDate. 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.afteris a one-shot timer aimed at an actor, not a recurring sweep. - Step 7 — saturation. Same rule as the limiter: a trapped
callanswers 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: donein 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.