- loader's resident:keys refusal said "rows are still fully resident" and "until tasks 5c/5d land". Both false since f606fc9. Corrected to name the real blocker: UPDATE needs read-modify-append - databasev2 00-story: the sequence graph drew 2->3->4, which reads as 3 needing 2 and 4 needing 3. Both backwards, and it still drew the 2->5->6 path the 2026-08-27 amendment retired. Redrawn stating only real dependencies, with 4 and 3 shown as composing rather than ordered, and the execution order that actually happened - databasev2 03: the hazard and its Outstanding entry both claimed nothing fails "because iteration 2's storage half is unimplemented". Marked discharged, and recorded that the hazard named only half the danger — the bitmap walk would have dropped keys rows outright - databasev2 06: pending -> hold (largely superseded, revisit only on a measurement); dated its 5c/5d references - porch 01: rewritten to the settled shape. readiness ready, status in-progress, phases B and C marked superseded with why - porch 01 claimed time.after "is still a reserved builtin id". False — builtin 90, implemented. That claim is what made the iteration look cheaper than it is - porch README gains honest ledger rows for both features (partial, being rebuilt), not shipped - skill-catalog README pointed at a story path that moved tracks; linkcheck now 0 broken Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit b3d8c403e1d19ac27ec966de85cb293e0765795c)
14 KiB
| track | iteration | status | readiness |
|---|---|---|---|
| porch | 1 | in-progress | ready |
porch 1 — store-backed middleware: rate limiting and idempotency
Part of Story —
porch, the writeonce web framework. Source: the Fiber parity study §2. Spec:2026-08-29-porch-store-backed-middleware-design.md.Rewritten 2026-08-29 after the brainstorm settled every fork. The iteration's premise changed: it was scoped as the cheapest slice because it needed "only a
@tableandtime.ticks", and it now serializes through an actor pool. That is not new runtime surface —spawn,send,call,monitorandtime.afterall landed with the actor-lifecycle work — but it is more than the original framing, and the reason is in History below.First deliberately because it is the cheapest. Both features need only a
@tableandtime.ticks, both of which already exist — no new builtin, no cookie, no change toResp. It exists to prove the store pattern and the gate shape on low-risk work before iterations 2–4 touch the runtime and the public response type.
Goals
- A rate limiter that survives a restart. Fixed-window counting keyed by
client, answering 429 with the conventional headers when the window is spent.
Fiber's
limiterkeeps counters in memory by default and expects Redis for anything real; porch's live in a@table, so they are WAL-durable and crash-recoverable for free. That is the difference worth demonstrating, and it is why the acceptance criteria include a restart. - Idempotent replay of unsafe requests. A client resending a POST with the same idempotency key gets the stored response and the handler does not run twice. This is the correctness feature the storefront sample has silently needed since it grew a checkout.
- Establish the store convention for iterations 2–4. Sessions and CSRF will want the same shape. Decide it once, here, on the cheap slice.
The design, as settled
One rule, inherited by porch 2 (sessions) and 3 (CSRF):
Serialize through an actor. Persist in a
@table. Never read-modify-write from a handler fiber.
| Concern | Owner |
|---|---|
| per-key ordering, in-flight ownership, waiter lists | a sharded pool of actors, selected by hash of the key |
| counters, stored responses, request digests | @table rows — WAL-durable, replayed at boot |
| deciding whether a request passes | the actor, never the handler fiber |
The read-modify-write is the defect both features shared: a handler reads a count, adds one and writes it back, so two interleaved fibers lose an increment. An actor processes one message at a time, so routing both features through the pool buys per-key serialization with no locks and no polling. A pool rather than one actor because ordering is needed per key, never globally.
Progress
| Phase | State |
|---|---|
| A — store convention | ✅ 519d411 — two purpose-shaped tables. Outstanding: the request-digest column the refusal criterion needs |
| B — rate limiter | ⚠️ superseded — 5b1e82a built the store-after shape; see History |
| C — idempotency | ⚠️ superseded — aee7926 built the same shape; 5c3544d fixed its missing use json, which had made the whole porch library uncompilable |
| D — gate and ledger | ⬜ not started; the restart leg is the one that must not be skipped |
Phases
Phase A — the store convention
- Decide the store shape (see Info fork 1) and write it down before any middleware exists, because three later iterations inherit it.
- Add the
@tableclasses for a counter row and a stored-response row, with the secondary indexes their lookups need — the read path is an equality probe, which is the only index shape the engine has. - Decide and document the expiry discipline: rows are pruned lazily on access, not by a background sweeper, because porch has no timer and iteration 30 owns scheduled work.
- Verify:
woc docs/examples/porch/typechecks entry-less as a library; the new tables appear in the WAL and replay across a restart.
Phase B — the rate limiter
- A
Limitermiddleware class with abeforethat counts and either passes or short-circuits with 429 — the?Respshort-circuit the chain already has. - Key selection: reuse
client_ip(req)and the existing trusted-proxy judgement rather than inventing a second one. A keyed-by-principal variant falls out for free oncereq.principalis set by an auth middleware. - The response headers on both paths, and a
Retry-Afteron the refusal. - Window arithmetic on
time.ticks(µs monotonic), nottime.now— a wall-clock jump must not hand out a free window. - Verify: a burst crosses the threshold at exactly N, the window rolls, the
counters survive
SIGTERM+ restart.
Phase C — idempotency
- An
Idempotentmiddleware pair:beforelooks the key up and replays a hit;afterstores the response for a miss. This is the first real user of theafterchain for something other than headers, which is worth noting. - Decide what is part of the identity: the key header alone, or key plus a
digest of method+path+body (
sha256exists). Replaying a stored response for a different body under a reused key is the failure mode that matters. - In-flight collision handling: a second request arriving while the first is still running. Fiber takes a lock; porch's shard model means the honest answer is probably to refuse with 409 rather than to block.
- Verify: replay returns the stored response, the handler's side effect happens exactly once, a reused key with a different body is refused, concurrent duplicates do not both execute.
Phase D — the gate and the ledger
- Extend
scripts/web-app-accept.shwith the checks above, including the restart leg — a durability claim that no gate exercises is not a claim. - Update porch's README ledger rows for both features, and record in the status board what landed versus what was planned.
- Verify:
just web-appandjust siteboth green;just linkcheckclean.
Acceptance Criteria
- Given a limiter of N requests per window, when a client sends N+1,
then the first N succeed and the last is 429 with
Retry-Afterset. - Given counters at their limit, when the process is SIGTERMed and restarted, then the client is still limited — the counters replayed from the WAL rather than resetting to zero.
- Given a window that has fully elapsed, when the same client returns, then it is served, and the expired row is pruned on that access.
- Given the system clock jumping backwards, when the window is
evaluated, then no extra allowance is granted (
time.ticksis monotonic). - Given a POST with an idempotency key that has been seen, when it is replayed, then the stored response is returned byte-identically and the handler's side effect count is unchanged — proven by a row count, not by a log line.
- Given a reused idempotency key with a different request body, when it arrives, then it is refused rather than answered with the other request's response.
- Given two identical keyed requests in flight at once, when both are
dispatched, then exactly one executes and the other receives that one's
stored response — never a refusal, never a partial write. (Restated
2026-08-29: this used to permit 409. Blocking supersedes it — the duplicate
parks on
calluntil the owner reports.) - Given N concurrent requests for one limiter key, when they are counted, then the total is exactly N and no increment is lost. (Added: unreachable before the pool, and the defect that most undermines a limiter.)
- Given a saturated actor pool, when a request arrives, then it is refused with 503 rather than served uncounted. (Added: fail-closed, because saturating the pool must not become the limiter's bypass.)
None of these are met yet — Phase D owns the gate, and B and C are being rebuilt. The concurrency legs need genuine parallelism: a test that cannot fail before the fix is not a test.
Out Of Scope
- A pluggable
Storageinterface. Fiber abstracts it so one middleware runs on memory or Redis. porch has one store, and interfaces here are structural — an abstraction with exactly one implementor is decoration, which iteration 37 learned the hard way aboutComponent. Concrete@tableuntil a second backend actually exists. - Sliding-window or token-bucket algorithms. Fixed window is what the sample needs; a better algorithm is a later slice with a measurement behind it.
- Distributed limiting across processes. One program owns its database; cross-program state is language databasev2 9.
- A background expiry sweeper. Lazy pruning on access, deliberately —
porch has no scheduler. Corrected 2026-08-29: this used to say "no timer
exists (
time.afteris still a reserved builtin id)". That is false —time.afteris builtin 90 and implemented (runtime/src/builtin.c, viawo_vm_timer_after), along withspawn(68),send(69),call(88) andmonitor(89). The exclusion stands on its own merits:time.afteris a one-shot timer aimed at an actor, not a recurring sweep. But the reason given was wrong, and it is the claim that made this iteration look cheaper than it is. - The TTL cache middleware — language iteration 18 owns it, spec already approved. Do not build a second cache here.
Info
All three settled 2026-08-29 — kept with their outcomes rather than deleted, so the reasoning survives:
- One store or two? A single generic key/value/expiry table serving both
features, or a purpose-shaped table each. Leaning two: the columns genuinely
differ (a counter is an Int, a stored response is status + headers + body),
and a generic table would force everything through
Text, which is how the framework's cache ended up storing JSON strings. - What is the limiter's key when there is no auth?
client_ip(req)readsX-Forwarded-For, which a direct client can forge. Behind the mandated TLS proxy that is fine; on an open port it is not.net.peer(fd)gives the real peer — decide which is authoritative and reuse whatever the trusted-proxy slice concludes rather than deciding twice. - Does idempotency store headers? Fiber has
KeepResponseHeadersbecause replayingSet-Cookieor a freshDateis usually wrong. porch has no cookies yet (iteration 2), so this is cheap to decide now and expensive to retrofit later.
Nothing here needs a new runtime primitive — still true, and now verified
rather than assumed: call is "a send that WAITS", whose park/reply protocol
lives in the VM, and that is exactly the blocking primitive the design needs.
Outcomes:
- Two stores, as leaned. Confirmed by construction in
519d411; a generic table would have forced a counter and a response body through the sameTextcolumn. - The peer address, unless the app declares otherwise. A
trust_proxyflag defaulting to off: off keys onnet.peer(req.conn), which cannot be forged; on keys on the left-mostX-Forwarded-Forentry. Only the deployer knows the topology, so the declaration belongs in their code. The built version branched onreq.ctx["verified_proxy"], which nothing anywhere sets — a dead branch. Verifying the proxy is genuinely story 35's, andclient_ipsays so in its own comment. content-typeonly. Settled as built, and settled correctly: replaying a storedSet-Cookieor a staleDateis wrong, and cookies arrive in iteration 2, so this is cheap now and expensive later.
History
2026-08-29 — Phases B and C superseded before review. Both were built against the original framing and both store the response after the handler returns. Three of the seven original criteria cannot hold in that shape, which is why this is a rebuild rather than a patch:
- In-flight collision was undetectable.
beforefinds nothing and passes; the row appears inafter, once the handler has already run. Concurrent duplicates both miss and both execute — there is no reservation to collide on. - The 10-second in-flight heuristic was inverted. The timestamp is stamped when the response is stored, not when the request starts, so it fired on legitimate fast replays — the common case — answering 409 where the stored response was owed, and could never fire on a genuinely concurrent request.
- "Reused key, different body is refused" was unreachable. The body digest was folded into the lookup key, so a different body was a different key and simply missed. Safe — the wrong response is never served — but nothing looks the bare key up, so nothing can refuse. The digest becomes a column.
Two defects independent of the redesign, both since fixed or scheduled: the
limiter emitted a monotonic tick into X-RateLimit-Reset, where a client
expects a Unix timestamp; and it used delete-then-insert where assigning to a
row field writes through (compiler/src/emit.ml), which doubled WAL traffic
and left a window where a failed insert after a successful delete silently lost
the counter — handing out a free window, the exact inverse of the durability
this iteration exists to demonstrate.