- the agent rename database-developer → codd (and its guide) happened inside lang-18's `aab4878` on dev, which stays there; the `docs(agents)` pick brought codd.md in beside the old file — remove the old name and its guide - docs/stories/porch/09-idempotent-replay.md: added on dev by `79e6da4`, whose earlier pick onto master (`refactor(porch-store)`) landed without it — the board, porch 1 and porch 4 link to it - docs/stories/porch/10-memory-features-over-table.md: the refine stub language 18's docs commit created on dev; the board and porch 00-story link to it, the code it waits on is not on master - docs/examples/skill-catalog/README.md: the story link fix from `b3d8c40` that its earlier pick (`8311330`) dropped in conflict resolution — dev's version taken - `just linkcheck` on master: every remaining broken link is a wmux story or spec (track not picked) or a developer-local `.dev/reference` symlink Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4.9 KiB
| track | iteration | status | readiness |
|---|---|---|---|
| porch | 9 | hold | ready |
porch 9 — idempotent replay of unsafe requests
Part of Story —
porch, the writeonce web framework. Spec:2026-08-29-porch-store-backed-middleware-design.md. Code: the tagarchive/porch-idempotency— complete, reviewed, and the reproduction harness for the defect blocking it.Split out of porch 1 on 2026-08-30.
readiness: readybecause nothing about the design is unsettled — it was implemented and passed review.status: holdbecause it is blocked on a C-runtime crash it did not cause but does provoke.
Why this is on hold, precisely
Not a design failure, and worth being exact about that so nobody re-litigates a settled design when they pick this up.
The middleware works. A keyed request calls its pool actor, which runs the
route's Handler inside its own receive; a duplicate for the same key waits
in the mailbox and is dequeued once the owner returns, by which time the
response row exists. That is real blocking with no held reply, no polling and no
409 — and it needed no new runtime primitive.
What blocks it is a C-runtime defect: a SIGSEGV localised by gdb to
wo_arena_alloc / wo_str_new, under concurrent call()-parked callers doing
heavy allocation inside receive. It also manifests as the process hanging
after main() returns, roughly one run in five.
The evidence that this path provokes it, rather than merely coinciding with it:
- Across ten consecutive gate runs, every failure was an idempotency leg.
The rate limiter's 30-parallel leg — same pool, same
call, same park machinery — never failed once. - The begin arm has five times the allocation sites inside
receivethat the count arm has, and it moves a wholeReqplus aHandlerthrough the mailbox where the count arm moves four scalars. - The crash grows more likely with sequential insert+delete volume against one key: N=4 and N=5 crashed 1/3 and 3/3 times, N=1–3 stayed clean over 12+ trials.
What has to happen first
The runtime crash must
be root-caused and fixed. Recover this work with
git checkout -b <name> archive/porch-idempotency, re-run
scripts/web-app-accept.sh sections 18a–18h and 19, and expect them stable
before resuming.
What is already settled — do not re-brainstorm
- The actor runs the handler.
call's reply is the return value ofreceive, so a reply cannot be held for later; an actor that tried would deadlock against the completion it waits for. The mailbox IS the queue. - The response travels through the
@table, not the mailbox. WO-E226: replies must be copyable scalars and everyreceiveprogram-wide must share one return type. The actor stores the response and returns an outcome code. Owner and duplicate then read the same durable row, which makes byte-identical replay structural rather than careful. - The digest is a column, not part of the key. Fold it into the key and "same key, different body" becomes undetectable, because nothing ever looks the bare key up.
Idempotentis aHandlerdecorator, not aMiddleware— the actor needs the route's handler and only the handler slot exposes it.- 4xx/5xx are never durable replay targets. A cached transient 500 would be replayed for the whole TTL, so a retry could never succeed — the exact inverse of why idempotent retry exists.
- Ephemeral rows are per-attempt and nonce-keyed, and the middleware deletes its own after one read. Sharing one row raced; leaving them lingering leaked and, because the nonce wraps every 1000s, eventually replayed a stale failure.
- Saturation fails closed with 503. Saturating the pool must not become the bypass.
Two runtime defects it also has to work around
Both are worked around in the archived code and neither is porch's:
try EXPR catch (e) nilcannot distinguish a literalInt 0reply from a trap — worked around by never packing a zero outcome code.- A
Text/map value read offjson.decode(...) as Tis corrupted once embedded in a struct crossing a function-return boundary — worked around by forcing fresh text with.. ""on every field copied out of a decoded record.
Acceptance criteria
Carried from porch 1, all of them met by the archived code and all of them to be re-proven once the runtime is fixed: byte-identical replay with the handler's side effect counted from a row count; a reused key with a different body refused with 422; two concurrent duplicates yielding exactly one execution; a transient 5xx never replayed, solo or concurrent; ephemeral rows returning to baseline rather than accumulating; and a saturated pool answering 503 rather than executing twice.