- new handlers/routes: casecheck (key_header: "Idempotency-Key", the
README's documented shape) and patha/pathb (include_body: false,
same key, different routes)
- 18g: capitalised key_header + capitalised wire header must still
dedupe (exec count, not status, is the load-bearing assertion --
pre-fix both calls answer 200 either way, but the handler reruns)
- 18h: same key on two different routes with include_body:false must
answer 200 then 422 (a digest mismatch, same as a body mismatch),
never replay route a's body under route b
- both legs run on a FRESH restart of the same binary, not piled onto
18a-18f's already-loaded server -- doing so measurably raised how
often this run hit the pre-existing, out-of-scope C-runtime
arena-allocator race (confirmed by gdb backtrace: SIGSEGV inside
wo_str_new, unrelated to this file's own .wo logic)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 86e7244dd97fb8ba940f8c0029faa05f70506e59)
- Add a neutral note() helper (prints, touches neither pass nor fail)
- Use it for the saturation leg's unconditional teardown line, which
previously called ok() regardless of branch taken and inflated the
reported count with a line that could never fail
- New count: 78 checks, 0 failures (was 79) -- every number now is a
real assertion
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 2ac1b8b6db360d4423dbb53d5d26b355e7b05e71)
- Add saturation leg (scripts/web-app-accept.sh): one-actor pool,
WO_MAILBOX=2, 15 concurrent requests, exactly 3 served + 12 answer
503; execution count matches the 200 count, retry-after + real
cause verified on the 503s
- Guard make_pool(n<1) by clamping in make_pool itself, not
pool_select's division -- that trap runs inside the middleware's
own try/catch and would be swallowed as ordinary saturation forever
- README: rate limiting + idempotency ledger rows moved to done,
scoped to what the gate proves; documented Handler-decorator
shape, Pool aliasing (WO-E222), call's scalar-only reply (WO-E226),
pool size as a capacity decision
- Story: Progress table filled with real hashes, 7/9 acceptance
criteria marked verified with citations, 2 marked verified by
construction (never gated even in the original plan), status: done
- Status board: standup entry, porch 1 pending row updated
- Recorded a pre-existing runtime hang (main() returns cleanly, OS
process sometimes hangs under concurrent call()-parked callers)
that also reaches the new leg's teardown; contained with kill -9
rather than asserted, so it can't flake the leg's actual subject
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 21934b18910070a3f24b8bd4367fcb9d397dc1fb)
- 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)
- limiter_key: an empty client_ip(req) under trust_proxy no longer keys
on the literal "ip:" -- falls through to net.peer(req.conn) instead,
same as the untrusted-default path
- the bug: every client omitting X-Forwarded-For shared ONE bucket,
so one could exhaust it and deny/hide the rest
- curl availability check added alongside the existing woc/wovm check
(the limiter gate legs drive the server with it)
- new gate leg: LIMIT+1 sequential no-XFF requests must all be 200
(own key per connection, via a fresh ephemeral port each time) --
confirmed it fails against the pre-fix code (6th comes back 429)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 831e9d8e6b1ef39cd938783dbc47c1abe6d53211)
- delete all RateLimitCounter access from limiter.wo: query, increment,
delete-then-insert, and the swallowing catch (e) nil -- the pool is now
the only writer, so its serialization guarantee actually holds
- Limiter gains pool/limit/trust_proxy fields; before() calls pool_count
and acts on the Verdict; make_limiter takes a pool
- key selection: req.principal first, else trust_proxy ? client_ip(req)
: net.peer(req.conn); delete the dead req.ctx["verified_proxy"] branch
- 429 on a spent window (Retry-After, X-RateLimit-*); 503 + Retry-After
on a caught actor trap (saturated pool), request never let through
- add Limiter.after(), registered alongside before() as both Mw and Aw
(Cors's own shape) so the allowed path's X-RateLimit-* headers reach
the response, not just req.ctx
- scripts/web-app-accept.sh: three new gate legs -- threshold (N pass,
N+1th 429), SIGTERM+restart (still limited from the WAL), and N
genuinely-parallel curl clients on one key with an exact-count assertion
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit a653dd0aa64711c43461126d32b4632cc8f64c7a)
- keypool.wo:78,89 passed msg.window (µs) straight into pool_pack's
remaining_ms (ms) parameter on the first-hit and post-prune-reset
paths; the third call site already divided by 1000 and was correct
- fix: pool_pack(1, msg.window / 1000) at both sites — a 60s window
no longer reports reset_at ~16.7h away
- count/allowed were unaffected (computed independently); this only
hit the client-visible reset instant, on the two most common cases
(new key, window rollover)
- extended gate leg 16 to assert reset_at falls within a 5s band of
time.now() + window_ms, not just on count — verified the assertion
itself by reverting the fix, confirming leg 16 failed with the
exact defect shape, then restoring it and confirming green
- woc docs/examples/porch/ exits 0; web-app-accept.sh: 47 checks,
0 failures
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 153fd295d37905dd083823536c6781843699e455)
- add docs/examples/porch/middleware/keypool.wo: one PoolMsg (kind 1 =
count, kind 2 = begin placeholder for task 4), a Verdict class, a
fixed-size actor pool with a byte-sum-mod-N selector
- KeyActor.receive implements kind 1: reads the row, writes the new
count via field assignment (writes through, never delete+insert),
prunes a fully-elapsed window's row instead of resetting it
- window arithmetic on time.ticks(); reset instant sent back is built
from time.now() only
- call's reply must be a copyable scalar (WO-E226), so the count and
remaining window time are packed into one Int by the actor and
unpacked into Verdict by pool_count — the packing stays inside this
file, callers only ever see Verdict
- gate leg in scripts/web-app-accept.sh: a flat copy of porch (manifest
stripped) with a driver dropped beside keypool.wo asserts two
sequential counts return 1 then 2; verified failing (E403, make_pool
undeclared) before this file existed, passing after
- woc docs/examples/porch/ exits 0; full web-app-accept.sh: 47 checks,
0 failures
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 676e651d808ef2c3619f88c3808adc372cd0be6c)
Every gate wrote its server output into a per-run mktemp dir that its own
cleanup trap deletes on exit — nothing to follow during the run, nothing
to read after it.
- chat -> /tmp/chat.log, web-app -> /tmp/web-app.log,
site -> /tmp/site.log, log-watcher -> /tmp/log-watcher.log
- truncated once at gate start, appended for the rest of the run, so one
file holds the whole run in order
- each gate PRINTS the path as its first line, with the tail -F command
- legs are banner-separated and name their port and env
(===== leg 2 - port 18902 - env WO_IO=epoll =====)
Appending breaks readiness detection unless it is leg-scoped:
- serve() used to grep the whole file for `listening`, which after the
switch to append would match an EARLIER leg and return before the new
server was up. It now records the line count first and searches only
tail -n "+$LEGFROM"; the ASan scan is scoped the same way
- log-watcher's checks grep per-invocation files, so those are kept and
the output is teed into both — process substitution adds no pipeline
stage, so $! is still the command's pid the gate kills and waits on
- its one SYNCHRONOUS invocation appends after it finishes rather than
teeing: the grep on the next line would race tee's flush
Verified, all green: chat 11/0, web-app 46/0, site 21/0, log-watcher 7/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- docs/examples/writeonce-serve -> docs/examples/porch (git mv, history kept);
`[deps]` key and import are now `porch` / `porch/http` / `porch/router`
- name history preserved on the library README, not rewritten into dated
records: writeonce-framework -> writeonce-serve (08-25) -> porch (08-26).
Stories, specs, plans and the audit reports keep the older name by the
repo's own convention; only live docs and every path link were rewritten
- left alone deliberately: `internal/serve.wo`, `pub fn serve`, `serve_conn`,
`app.serve(...)` — those are functions, not the module name
- web-app/wo.toml comment corrected: it claimed hyphens are not identifier
characters and named a key this file never used. lexer.ml's `is_ident_cont`
DOES accept `-` (an internal dash is part of the identifier, which is why
binary minus needs spaces), so a hyphenated key would be legal too
- site now teaches the name: package card, the two-deps chapter and the
handlers-are-classes chapter say `porch`; site-accept asserted the old
/packages/serve route and caught the rename, as a gate should
- gates: web-app 46/0, site 21/0, deps-accept 8/0, oop-e2e 116/0,
linkcheck 0 broken / 0 anchors; porch typechecks entry-less as kind=library
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- rename the two libraries: writeonce-framework -> writeonce-serve
(`use serve`), wo-html -> writeonce-view (`use view`). Names say the
ROLE now; every sample, script, gate and live doc follows
- stories/specs/plans keep the old names: they are dated records, and
both library READMEs carry a "renamed 2026-08-25" note
- serve/http/files.wo: StaticFiles { dir, max_bytes } — traversal
refused not normalised, extension content types, attachment
disposition for archives. Lifted out of the shop, which had said in
a comment that it belonged in the framework
- shop drops its private copy and mounts the framework's
- site: /dl/*path over $WO_DIST (default ./dist), 16 MiB ceiling
- /install gains supported systems — Linux x86-64, glibc >= 2.38,
not musl — read off `file` and the binaries' GLIBC_ symbol
versions, not off a wish list; plus GitHub release as primary,
/dl as mirror, and the sha256 verify step
- site-accept: 17 -> 21 checks (supported systems, gzip download with
a binary-safe probe, checksum, /dl traversal 404)
Verified on 192.168.0.165: the real 960,820-byte tarball downloads
as application/gzip and its sha256 matches the published digest.
Gates: oop-accept MET, site 21/0, web-app 46/0, fibers 10/0,
db-actor 8/0; shop rebuilt and its /assets served by the framework.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- parse.wo: ANY Transfer-Encoding header is 400-and-close (RFC 9112
§6.1) — silently treating chunked as body-less was the smuggling
door the dup-CL fix left open
- net.listen/listen_unix backlog 64 -> 1024: the soak's connect bursts
overflowed the kernel accept queue and BLACK-HOLED clients (three-way
handshake done, server never sees the conn — 35-70 stuck per run,
fully reproduced then gone at 1024; kernel clamps via somaxconn)
- web-app gate grows to 46 checks: TE-reject; the 1k soak — 500 real
conns all served + 500 idle conns all evicted, server fds home
(45 -> 45), RSS 24MB, healthy after
- battery green (site restart + fibers-TSan legs flaked under parallel
battery load, both clean serially — the standing flake pair)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- runtime ids 91-95: net.read_dl/accept_dl/write_dl (per-call deadline,
nil/false = the EXPECTED timeout; ms<=0 = old behavior bit for bit),
net.listen_unix (unlink-before-bind, O_NONBLOCK on the listener —
probe-found: accept4's flag covers accepted sockets only), net.peer
- plane: one-op-per-park stays law — deadlines ride one per-shard
TIMEOUT tick (sentinel user_data) + post-CQE expiry sweep +
POLL_REMOVE tombstone; epoll's deadline scan grew the fd-park case;
fibers POOL instead of freeing mid-run (stale-CQE UAF); plain parks
zero park_deadline (no stale sleep deadlines)
- probe: all five seams verified on BOTH WO_IO backends (timeout
timing exact, peer round-trip, unix rebind)
- framework: parse_request grows first_ms/read_ms; serve_conn — the
keep-alive loop with deadlines where parked idle conns are LEGAL
(close-when-idle RETIRED); App.handle_conn exposes it; plain serve()
unchanged for simple apps
- web-app: app-owned accept_dl loop + ConnWorker actor per connection
(each builds its own App; cross-shard placement rides the DB actor);
WA_IDLE_MS knob; gate grows to 41 checks — two slow requests served
in PARALLEL, stalled client evicted at the idle deadline, slow-loris
torn at the read deadline (400)
- docs: story 35 -> done with banner; SQE/CQE design spec LANDED (was
the review doc); ledger rows (timeouts/unix/keep-alive/peer), graph
(NETSEAM cleared, KEEPAL done), builtin-surface rows, runtime
CODE-LOGIC section, board entry
- battery 13/13 fresh-built
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Float full stack: literals (fraction/exponent; `0..10` still a range), f64
opcodes 34-41, @table column, WAL bit-exact replay, json fractions in and
shortest-round-trip out. IEEE-quiet — FDIV never traps where DIV does.
- Bytes: a wo_str with its own class id, so alloc/free/copy are shared but no
Text builtin accepts one; len/at/slice/eq/concat, base64 both ways, json
boundary as base64; TEXT_COPY preserves the kind.
- No implicit Int/Float mixing (WO-E201 in the typechecker, not the emitter,
which picks the opcode from one side and would misread the other).
- One IEEE deviation: float_cmp total order (NaN last, -0.0 == +0.0) for
indexes and order-by, keys canonicalized to match. `?Float` nil is a
reserved quiet NaN — the zero word is +0.0, WO_NIL_SCALAR's bits are -2.0.
- Renderer prefers fixed over exponential in 1e-6..1e21: pure shortest makes
a price of 900.0 read `9e+02`. One renderer for interp/json/float_to_text.
- Fixed en route: lexer double-counted the leading digit; is_scalar_shaped
took Float/Bytes as Int-shaped; Bytes ownership needed a shared heap-scalar
predicate or temps never dropped; order-by bit-compared negatives backwards.
- Iteration 17: `kind = "library"` (absent = program; bad value = WO-E109),
entry-less check mode retiring the `--emit` workaround, Go's `internal/` as
WO-E108 at the consumer's `use`. Driver-only; VM/.wob/GC untouched.
- Framework reorg: internal/{parse,serve}.wo; http/form.wo split out to keep
media_type/form_values public (parse.wo had grown public surface).
- Docs: link audit (97 -> 88 broken, conflict markers resolved, 2 duplicate
stories removed), 00-code-review verified 26/27, iterations re-sequenced.
- Also carries the pre-staged pub(read)/using/#if work from the index.
- Gates: corpus 103/0, test_wal 156/0, web-app 26/0, oop-accept ALL MET.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- http/multipart.wo: RFC 7578 whole-body parsing within BODY_MAX —
boundary from the raw content-type (quoted or bare, key
case-insensitive), parts split on --boundary, each part = headers,
blank line, content; filename + per-part content-type kept (lowercased)
- strict malformed-is-nil: no closing --boundary-- marker, a part
without content-disposition, missing blank line, no boundary param,
wrong media type — all nil, the caller's 400
- part_named(parts, name): first matching field's content, caller-owned
- web-app CreateProduct now accepts multipart/form/JSON (curl -F shape)
into the shared insert path
- probe 13/13 + 3x reuse loop (fields, crlf-in-content, quoted boundary,
file part, zero-part close, five malformed shapes) release + ASan
- gate grows 19 -> 21: multipart create 201, missing closing marker 400
- README: multipart row ✅ (all three body hooks done), limits updated;
story 16 + board record the landing
- gates: web-app 21/0, oop-e2e 89/0, deps-accept 8/0, log-watcher 7/0,
employee 8/0, woc-test green
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- media_type(req): content-type lowercased, "; charset=..." stripped,
"" when absent — the content-negotiation hook
- form_values(req): application/x-www-form-urlencoded body -> decoded
pairs through the existing query decoder ('+' as space, %XX); nil on
any other content-type so a JSON body is never misread as a form key
- web-app CreateProduct accepts form OR JSON; shared create_product
insert path; field/number validation answers 400
- probe 7/7 (plus/pct decode, empty value, case + charset param, json
and missing content-type nil, empty body, media_type strip) + ASan
- gate grows 17 -> 19: form create 201 with decoded name, non-numeric
price 400; hit() gains a content-type argument
- README: checklist row form ✅ (multipart stays candidate), limits
paragraph updated; story 16 + board record the landing
- gates: web-app 19/0, oop-e2e 89/0, deps-accept 8/0, log-watcher 7/0,
employee 8/0, woc-test green
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- App.get/post/put/delete_(pattern, take h: Handler) — the take-interface
shape probe-proven release + ASan before landing; retires plan-16
deviation 1; delete_ because delete is the query keyword
- dispatch matches path-first: wrong method on a known path answers 405
with Allow in registration order; unknown path stays 404
- HEAD routed as GET, body suppressed, Content-Length names the body a
GET would carry (serialize gains head_only)
- Logging middleware (request line to stderr) ships in router/
- set_header(mut r, name, value) — the builder escape hatch
- web-app registers through the helpers (dogfood); README documents all
- gate grows 14 -> 16: 405+Allow, HEAD-vs-GET content-length equality
- all gates green: web-app 16/0, woc-test 540/0, oop-e2e 89/0,
deps-accept 8/0, log-watcher 7/0, employee 8/0
- board/story: iteration 17 parked (spec+plan ready on library-internal),
16 carries the v1-polish landing, order list updated
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>