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

98 lines
4.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
track: porch
iteration: "9"
status: hold
readiness: ready
---
# porch 9 — idempotent replay of unsafe requests
> Part of [Story — `porch`, the writeonce web framework](00-story.md).
> Spec: [`2026-08-29-porch-store-backed-middleware-design.md`](../../superpowers/specs/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](01-store-backed-middleware.md) 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](../language-runtime-database/41-actor-arena-crash.md) 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.