- WO-E226: call's reply must be a copyable scalar, and every receive program-wide must declare the same return type. Verified by fixture: "call's reply type `Out` is not a copyable scalar" - the spec had the actor return the response object, which cannot cross the mailbox. Corrected: the actor stores the response and returns an outcome code; the middleware reads the row and builds the Resp - owner and duplicate now read the SAME durable row, so byte-identical replay is structural rather than careful copying - blocking, exactly-once execution and the mailbox queue are unchanged Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 77e06c1690b92d456a9bc53503695fdaa2b4b44e)
294 lines
16 KiB
Markdown
294 lines
16 KiB
Markdown
# 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 **as a way to update**: it
|
|
doubles WAL traffic and leaves a window where a failed insert after a
|
|
successful delete loses the row. This does not forbid deleting a row you
|
|
genuinely mean to remove — pruning an expired row is a plain delete and is
|
|
required.
|
|
- 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:
|
|
|
|
- **ONE message class** carrying a `kind: Int` discriminator and the union of
|
|
what both operations need: the key, the limit and the window size for a
|
|
count; the digest, the request and the route's `Handler` for a begin. An
|
|
actor handle is typed to a single message class, so a second `receive`
|
|
compiles but is unreachable through that handle. `docs/examples/chat/main.wo`
|
|
is the repo's precedent — its registry takes `kind: 1` for lookup and
|
|
`kind: 2` for shutdown. Use `kind: 1` for count and `kind: 2` for begin;
|
|
- 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 ONE `receive`,** switching on
|
|
`kind`. Implement the count arm (`kind: 1`) now and leave the begin arm
|
|
(`kind: 2`) for Task 4. Counting reads the row for the key, decides, 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` and
|
|
`docs/examples/porch/middleware/keypool.wo` (step 4 adds the actor's begin
|
|
arm, which lives in the pool file).
|
|
|
|
**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.** `call`'s reply must be
|
|
a copyable scalar (WO-E226 — a response object cannot cross the mailbox),
|
|
so the arm returns an **outcome code** and the response travels through
|
|
the table. Look the bare key up. A hit whose digest matches returns
|
|
"replayed" without running the handler. A hit whose digest differs
|
|
returns "mismatch". A miss runs the handler, stores status, body and
|
|
`content-type` with the digest and the current tick, and returns
|
|
"executed". The middleware then reads the stored row and builds the
|
|
response from it — so owner and duplicate return the same durable row,
|
|
and byte-identical replay is structural rather than careful.
|
|
**Keep the return type identical to the one Task 2's count arm uses**;
|
|
WO-E226 also requires every `receive` program-wide to agree.
|
|
- [ ] **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.
|