- normalise self.key_header via to_lower before the req.headers lookup
- req.headers keys are already lowercased on read (internal/parse.wo);
the documented key_header: "Idempotency-Key" never matched, silently
disabling idempotency (falls through to inner.handle) on every request
- key/digest lookups use the normalised name consistently
- digest now always includes method+path, body appended only when
include_body is set -- a bare "" digest under include_body:false
previously matched any other request reusing the same key
- log a genuine pool_begin trap instead of silently folding it into 503
- update the two doc comments describing the old, unsafe shape
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 91099cfbb2fb9a2d351894e444fed306b25557d5)
- The nonce naming an ephemeral (4xx/5xx) row is handed to exactly one
call() reply and nowhere else -- no other message can ever construct
that key, so idempotent.wo deleting it right after building the Resp
is safe by construction (unlike the earlier shared bare-key row,
which a second message COULD reach and made deleting it racy)
- Closes the leak AND a real correctness edge: the nonce is
time.ticks() % 1_000_000_000, wrapping every ~1000s -- with rows kept
forever, a later failed attempt on the same key could land on the
same nonce and either collide with the unguarded insert or resurface
a stale replay, exactly what rounds 1/2 removed
- Gate leg 18f: N ephemeral attempts against the same key must return
IdempotencyKey's row count to baseline, not grow it by N -- confirmed
failing (baseline+N) against the pre-fix code, passing after
- N picked at 3: the pre-existing runtime hang/segfault (out of scope,
being tracked separately) reproduces more often at higher sequential
insert+delete volume against the same key; 3 stayed clean across
many runs while still proving the property precisely
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9ad594748e01a665ccaf29733a48c2b83a2749da)
- Root cause of the residual: a 4xx/5xx row lived under the bare key,
so a second message could delete-and-replace it before the FIRST
caller's own middleware-side read (necessarily outside receive,
WO-E226) ever ran -- the owner itself could read back a LATER
message's answer, not just a duplicate reading a stale one
- Fix: a 4xx/5xx miss is never stored under the bare key at all. Each
such attempt gets its own row, keyed by a nonce carried back in the
scalar reply's low digits, so no other message for the same bare key
ever touches it -- the decision AND the row's identity are both
fixed inside the one serialized receive call
- Disclosed trade-off: that row is never revisited by a bare-key
lookup, so it is never TTL-pruned either -- permanent per failed
attempt, the same no-sweeper trade-off this codebase already makes
elsewhere, not a new one
- Gate leg 18e: reran 20x in isolation against the fix with zero
500-500 or 200-200 outcomes (was reproducible before)
- §18's SIGTERM-stop check now force-kills on timeout before clearing
$SRV, instead of matching §14/§17b's own gap where a still-running
process escapes the exit trap too -- an orphan no longer survives
past this leg regardless of the assertion's own outcome
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 464147a9ddf3a53cb1946637c3f517ba615ee358)
- Miss path only marks a response a durable replay target (outcome 1)
when status is 2xx/3xx; a 4xx/5xx gets outcome 3 instead
- Outcome 3's row is a one-shot relay: the scalar reply still can't carry
a Resp (WO-E226), so the row exists only to hand the exact response
back once, then idempotent.wo deletes it -- a retry with the same key
is a genuine miss and re-executes, instead of caching a 500 for the
24h default TTL
- Reviewer finding: caching any status meant a transient failure was
replayed verbatim until TTL expiry, worse than no idempotency at all
- Gate leg 18d: FlakyHandler fails once then succeeds; same key twice
must answer 500 then 200 -- confirmed failing (500, 500) before the
fix, passing after
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit e61015f2065a7c6aec6f5c2439e78b53f796bab3)
- Delete before/after flow: it stored on a miss, so two duplicates both
missed and both ran; its 10s "in flight" check fired on fast legit
replays and never on a real collision
- Idempotent now wraps the route's Handler and hands request + handler to
the pool; a duplicate waits in the actor's mailbox, not a held reply
- keypool.wo kind-2 arm: digest match replays, mismatch refuses (422), a
miss runs the handler inside receive and stores status/body/
content-type, all via the same pool_pack(count, remaining_ms) scalar
kind-1 uses (WO-E226 forces one return type)
- Outcome codes start at 1, never 0: idempotent.wo's try/catch cannot
tell a literal 0 reply apart from a trapped call
- fresh_req() copies a borrowed Req's map fields into a new Req before it
crosses the actor boundary (WO-E222: aliased graphs can't cross heaps)
- Reading a stored row back forces fresh Text via `.. ""` on every field
copied out of json.decode's result -- decoded Text does not survive
being handed onward once the decoded record goes out of scope
- insert is unguarded (kind 1's own convention): a swallowed failure
would answer "stored" for a response never written
- web-app-accept.sh: leg 18a/b/c -- byte-identical replay off an ExecMark
row count, digest mismatch is 422, genuinely parallel duplicates run
the handler exactly once
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit eae1b06cdc38e4766d4e27a66b02264f47164e99)
- Add digest field to store sha256(method|path|body) separately from key
- Enables detection of "same key, different body" in future tasks
- Update idempotent.wo insert to compute and store digest value
- Typechecker passes: exit 0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3a9bddcd1e3c11f5b371ce54cafc373685ca08b6)
- docs/examples/porch/ did not typecheck: WO-E403 "cannot resolve the
receiver's type for the call to `encode`" on json.encode
- every other example that calls json.encode/decode imports it; this
file did not, so the whole porch library was uncompilable on dev
- woc docs/examples/porch/ now exits 0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 5c3544d524fa98dbb7a363600cd2eeb6dd1badac)
- replays a stored response for a repeated Idempotency-Key: before()
checks the key, after() stores status/body/content-type on a 2xx/3xx
- key is "idem:<header>:<value>", optionally plus a sha256 digest of
method|path|body when include_body is set
- 409 while a key is in flight (stored within the last 10s), lazy TTL
expiry on access, default 24h
- replay allowlists content-type only — never Set-Cookie or Date
- backed by IdempotencyKey from Phase A (519d411)
Written by a parallel session and committed here as-is because its
branch was consolidated away. NOT verified: it calls json.decode and
json.encode without a `use json` import, which every other example that
uses json has. Left unedited rather than fixed blind.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit aee79264095eea3b2c38c92789c44b31f1c9ef8a)