writeonce/docs/stories/porch/09-idempotent-replay.md
shoney.arickathil f3215c2f43 docs(status): master-only follow-ups to the 2026-09-15 cherry-pick
- 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>
2026-09-15 01:24:36 +02:00

4.9 KiB
Raw Blame History

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 tag archive/porch-idempotency — complete, reviewed, and the reproduction harness for the defect blocking it.

Split out of porch 1 on 2026-08-30. readiness: ready because nothing about the design is unsettled — it was implemented and passed review. status: hold because 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 receive that the count arm has, and it moves a whole Req plus a Handler through 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

  1. The actor runs the handler. call's reply is the return value of receive, 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.
  2. The response travels through the @table, not the mailbox. WO-E226: replies must be copyable scalars and every receive program-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.
  3. 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.
  4. Idempotent is a Handler decorator, not a Middleware — the actor needs the route's handler and only the handler slot exposes it.
  5. 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.
  6. 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.
  7. 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) nil cannot distinguish a literal Int 0 reply from a trap — worked around by never packing a zero outcome code.
  • A Text/map value read off json.decode(...) as T is 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.