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:
parent
aee5adddc4
commit
dbee71a9e5
2 changed files with 330 additions and 14 deletions
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue