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)
This commit is contained in:
shoney.arickathil 2026-08-29 23:14:28 +02:00
parent aee5adddc4
commit dbee71a9e5
2 changed files with 330 additions and 14 deletions

View file

@ -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.

View file

@ -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.