- README: shipped concurrency/HTTP/WebSockets sat in the roadmap as "not yet available"; "no package manager" contradicted [deps]; the deps example would not have compiled (the key IS the module name) - runtime/README: leads with wovm, wo-rt.c demoted to a historical section; dropped 2 nonexistent recipes, crates/rt, @gc refcounting, 13 suites -> 18 - employee + log-watcher READMEs claimed "does not compile"; both are gates - error catalog: +10 emitted codes incl WO-E250, the only diagnostic the shipped query surface raises; recorded why the sweep rotted - language-surface: group-by parses, then the typechecker refuses it - 00-code-review + 00-link-audit re-run; history kept, not rewritten - 48 dead Rust-era exploration links de-linked rather than re-pointed (their prose names the retired plan by number); successor map -> discarded.md - 08-project-structure: compiler/plan/ never existed; corpus has 9 dirs, 5 empty - releasing.md: dropped a --draft step the workflow never had - new docs/00-doc-audit.md: findings + disposition, incl one row where the audit was wrong and the doc it accused was right - status folders removed: 34 stories flat, status only in frontmatter; 252 links recomputed from resolved paths; board/board-views/structure retaught - story 24 -> in-progress, since frontmatter is now the only truth - new iteration 38: fs mutation verbs + net.connect, the two capability families no iteration owned - new iteration 39: gofiber/fiber v3.5.0 parity study. The ledger called CSRF/sessions unblocked by iteration 34's HMAC, but the runtime has no source of randomness at all - linkcheck skips .dev/.superpowers: 0 broken paths, 0 bad anchors Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
20 KiB
Chat + actor lifecycle — implementation plan (iteration 24, absorbing 31 + 34)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans task-by-task. Steps use checkbox syntax.
Style rule (user convention): concept, reason, required behavior in words plus verification commands only — the executor writes the code.
Goal: the chat workload proves the concurrency arc end to end —
call/monitor/timers/bounded mailboxes in the runtime, crypto builtins,
a pure-.wo WebSocket layer in the framework, and a rooms+presence chat
sample gated at 1k clients.
Architecture: everything reuses the arc's machinery — call rides
the DB-RPC envelope+park pair, timers ride the T4 timeout plumbing,
death rides the existing trap-unwind path. The framework never spawns
app classes: the app's handler owns the upgrade and moves the fd (Int)
into its own reader/writer actor pair. See the spec (normative):
../specs/2026-08-23-chat-websocket-actor-lifecycle-design.md.
Tech Stack: C11 libc-only (wovm), OCaml stdlib-only (woc),
pure-.wo framework code, bash + python3-stdlib gates.
Global Constraints
- Branch
chat-ws-lifecycle(this one); commits local only, never push. - ALWAYS
just woc-build && just wovm-buildbefore any gate run — gate scripts require built binaries and never rebuild (stale-binary incident, 2026-08-22). - A stage does not start until the previous stage's full battery is
green:
just woc-test wovm-test oop-e2e deps-accept web-app log-watcher employee fibers db-actor db-bench-quick(run as separate recipes). - New builtin ids: 85
crypto.sha1, 86crypto.sha256, 87crypto.hmac_sha256, 88call, 89monitor, 90time.after.WO_B_MAXfollows. NO.wobversion bump (ticks-84 precedent: pure id additions; old runtimes reject on the id-range check). - New trap kind:
WO_T_ACTOR = 13. New diagnostic: WO-E226. - No new opcodes, no new keywords —
call/monitor/time.afterare builtins resolved likespawn/send/time.sleep. - Plain-HTTP serving stays byte-identical throughout —
just web-appis the canary in every stage.
Spec refinements (disclosed, decided here)
- Reply-type erasure rule.
actor Mdoes not name the class, socall's static type comes from a program-wide agreement check: everyreceive(msg: M) -> Rfor a given M must declare the same R; two classes disagreeing is WO-E226 naming both. Areceivewith no return type makescallon that M a WO-E226 at the call site. - Cross-shard cap check. The mailbox cap is enforced through a
per-actor ATOMIC queue-length counter readable from any shard;
send/call check it before enqueue and trap
WO_T_ACTORwhen at cap. Racing senders can overshoot by at most the number of in-flight sends — bounded, disclosed; RSS stays flat under the soak. - Runtime-sourced deliveries (monitor notices, timer messages) have no fiber to trap: delivery to a full mailbox is dropped with a stderr diagnostic naming both actors (spec's monitor wording, applied to timers too).
Stage 1 — crypto builtins (Part B; independent, smallest risk)
Task 1 — sha1 / sha256 / hmac_sha256
Files:
- Create:
runtime/src/crypto.c(the three digests, hand-rolled, libc-only — one file, shares the block-schedule skeleton) - Modify:
runtime/src/wob.h(ids 85–87,WO_B_MAX 87, doc comments in the builtin roster),runtime/src/builtin.c(forward the id range to the crypto entry point),runtime/Makefile(new object) - Modify:
compiler/src/types.ml(thecrypto.sha1|sha256|hmac_sha256names → ids, arity/typing: Bytes→Bytes and Bytes,Bytes→Bytes — follow exactly howbase64.encode-family names map) - Create:
runtime/test/test_crypto.c(RFC vectors),tests/corpus/run/crypto-digests/fixture pair - Modify:
docs/plan/oop-vm/08-builtin-surface.md(three rows)
Interfaces:
-
Produces: builtins callable from
.woascrypto.sha1(b),crypto.sha256(b),crypto.hmac_sha256(key, msg), each returning fresh Bytes; C entrywo_builtin_crypto(vm, R, ins, msg)consumed bybuiltin.c's dispatch. Task 6 consumescrypto.sha1from.wo. -
Create the board marker
docs/active-slice-2026-08-23-chat-ws-lifecycle.md(slice active, links to spec+plan) and flip the board's In-progress Runtime row to this slice. Commit with the first code commit. -
Digest cores in
crypto.c: SHA-1 and SHA-256 over one buffer (init/update-once/final collapsed — whole-value contract), HMAC as the RFC 2104 two-pass over SHA-256. Wrong-class-id argument traps WO_T_BOUNDS with the same message shape the Bytes builtins use. -
test_crypto.c: RFC 3174 SHA-1 vectors ("abc", the 56-byte chaining case, empty input), FIPS 180-4 SHA-256 vectors (same three), RFC 4231 HMAC cases 1–4, plus a 63/64/65-byte block-boundary sweep asserting against python3 hashlib-precomputed constants (put the generator one-liner in a comment). Wire intomake -C runtime test. -
Verify:
just wovm-build && just wovm-testgreen (ASan+UBSan stage included). -
Compiler surface + corpus fixture: a
.woprogram hashing "abc" through all three and printing base64 of each (exercises 19's encode); expected output = precomputed. Verify:just woc-build && just woc-test && just oop-e2e. -
Full battery. Commit (bullets: ids 85–87, vectors, surface doc).
Stage 2 — actor lifecycle (Part A)
Task 2 — bounded mailboxes + WO_T_ACTOR
Files:
- Modify:
runtime/src/wob.h(trap kind 13 + roster comment),runtime/src/vm.h(per-actor atomic queue length, the cap constant,WO_MAILBOXplumbing),runtime/src/vm.c(check in the same-shard enqueue AND ininbox_push_to's caller path before the envelope is built; trap message names the actor and the cap) - Create:
runtime/test/test_mailbox.c,tests/corpus/run/mailbox-full-trap/(WO_MAILBOX=4 in its runner env, sender catches the trap and prints proof)
Interfaces:
-
Produces:
WO_T_ACTORtrap reachable from.wovia try/catch on send; the atomic length counter Task 3's call path reuses. -
Consumes: arc stage-2 mailbox/inbox structures as they are (mutex list stays — no ring rewrite, spec's out-of-scope).
-
Cap default 1024;
WO_MAILBOXenv override parsed once at engine start (same pattern asWO_SHARDS). Counter increments at enqueue, decrements when receive DEQUEUES (not when it finishes). -
test_mailbox.c: fill to cap, next send returns the trap; dequeue one, send succeeds; two threads racing the last slot never lose a message and never exceed cap + in-flight (assert the bound, not exactness). -
Corpus fixture: catchable trap proven from
.wo; deterministic underWO_SHARDS=1. -
Verify:
just wovm-build && just wovm-test && just oop-e2e, then full battery (unchanged cap = no behavior change anywhere else —just fibersandjust db-actorare the canaries). -
Commit.
Task 3 — call / reply
Files:
- Modify:
runtime/src/wob.h(id 88),runtime/src/vm.c(envelope kinds 5 request / 6 reply generalizing the DB pair: kind-5 carries caller shard+fiber and the moved message; adoption runs the actor's receive for it and ships kind-6 with the moved return value; the caller parksWO_PARK_INBOXand re-executes the builtin to consume the reply — mirrorwo_db_rpc's shape),runtime/src/builtin.c(the call case),runtime/src/vm.h(pending-call bookkeeping on the fiber) - Modify:
compiler/src/types.ml(WO-E226: the program-widereceive(M) -> Ragreement table, call-site typingcall(actor M, M) -> R, call-on-void-receive error),compiler/src/emit.ml(lower to id 88 — same shape as send),compiler/src/diag.ml(E226 text) - Create: corpus
run/call-echo(same-shard round trip, TID-printed park proof),run/call-cross-shard(output-set assertion),run/call-dead-trap(call after callee trap-died → caught WO_T_ACTOR; callee dies mid-call → caught),compile-fail/call-void-receive,compile-fail/call-reply-disagree(two classes, same M, different R)
Interfaces:
-
Consumes: Task 2's atomic length check (call is a send first).
-
Produces:
call(addr, msg) -> Rcallable from.wo; the kind-5/6 envelope pair; death-unparks-caller hook that Task 4's death machinery triggers. Task 8's registry lookups consumecall. -
Runtime first (unit-provable without the compiler): kind-5/6 paths + park/resume + dead-target immediate trap + die-mid-call unpark-to-trap. Extend
runtime/test/test_fiber.cwith a hand-built call round trip and a die-mid-call case (ASan). -
Compiler: the agreement table is built in the same pass that already collects
receivesignatures for spawn/WO-E221; E226 fires on disagreement (both class names in the message) and on call-of-void. Reply values face the same WO-E222 traced-containment check as send arguments — extend that check to receive RETURN types reachable via call, at the receive site. -
Corpus fixtures above; ASan AND TSan on the actor corpus (
just fiberscarries the TSan lane). -
Verify:
just woc-test && just oop-e2e && just fibers && just db-actor, then full battery. Commit.
Task 4 — monitor
Files:
- Modify:
runtime/src/wob.h(id 89),runtime/src/vm.c(per-actor monitor list: observer address + the moved notice message; the fiber-trap unwind path that already isolates actor death walks the list and delivers each notice as an ordinary send from runtime context — full observer = drop + stderr line naming both actors; monitor-of-dead delivers immediately; also unpark any caller parked in a kind-5 call on the dying actor into WO_T_ACTOR — closing Task 3's hook),runtime/src/builtin.c(the monitor case) - Modify:
compiler/src/types.ml(arity/typing:monitor(actor M2, msg: M1)where M1 is the OBSERVER's mailbox type — the msg argument is typed against the observer address's M... the observer is the CALLER: v1 rule, monitor's msg must be the type some actor the caller names receives; concretelymonitor(watched, observer, msg)three-argument form so the target mailbox is explicit and typed),compiler/src/emit.ml - Create: corpus
run/monitor-death(watched actor traps; observer prints the notice; deterministic at WO_SHARDS=1),run/monitor-already-dead
Interfaces:
-
Consumes: Task 3's pending-call bookkeeping (die-mid-call unpark).
-
Produces:
monitor(watched, observer, msg)from.wo; the death walk Task 8's rooms use to drop dead members. -
Note the spec deviation and disclose it in the commit: the spec wrote two-argument monitor with the caller as implicit observer, but the caller of
monitormay be plainmain(no mailbox) — the three-argument form names the observer address explicitly and stays fully typed. Story banner records it at closeout. -
Runtime + compiler + fixtures as above.
-
Verify:
just oop-e2e && just fibers, full battery. Commit.
Task 5 — time.after
Files:
- Modify:
runtime/src/wob.h(id 90),runtime/src/sysio.corruntime/src/park.c(whichever owns the T4 deadline scan — arm a timer entry: deadline + target address + moved message; expiry delivers as an ordinary runtime send: full mailbox = drop + diagnostic, dead target = existing silent-drop),runtime/src/vm.h(the shard's timer list),compiler/src/types.mlemit.ml(time.after(ms, addr, msg))
- Create: corpus
run/timer-delivery(arm 30ms, actor prints on receipt, main outlives it — loose ordering like the fibers demo part 2),run/timer-generation(the documented cancel idiom: arm two, bump the generation, prove the stale one is ignored)
Interfaces:
-
Produces:
time.after(ms, addr, msg); Task 8's presence/ping logic consumes it. -
Timer list lives on the ARMING fiber's shard and rides that shard's existing io_uring/epoll timeout arm — no new wait machinery; delivery crosses shards through the normal envelope path when the target lives elsewhere.
-
Verify:
just oop-e2e && just fiberson BOTHWO_IObackends (the fibers gate already forces both), full battery. Commit. Stage 2 complete: board note.
Stage 3 — framework WebSocket (Part C)
Task 6 — upgrade seam (ws_accept + hijack)
Files:
- Modify:
docs/examples/writeonce-framework/http/types.wo(Req grows the internal conn handle; internal-only — document it as not public surface),docs/examples/writeonce-framework/internal/serve.wo(pass the conn into Req; recognize the hijack sentinel — the Resp status 101 — and neither serialize nor close, just return to accept),docs/examples/writeonce-framework/internal/parse.wo(no behavior change — only whatever plumbing Req's new field needs) - Create:
docs/examples/writeonce-framework/http/ws.wo— upgrade validation (RFC 6455 §4.2.1: method GET,Upgrade: websocket,Connectioncontains upgrade,Sec-WebSocket-Version: 13, the key header present), accept-key =base64.encode(crypto.sha1(key ++ GUID))with the RFC GUID constant,ws_accept(req)writes the 101 with the computed key and returns the fd; malformed upgrade returns nil (handler answers a plain 400 — nothing traps) - Modify: framework probe scripts (whichever pattern auth.wo's 26-case
matrix uses — add handshake cases: the RFC 6455 worked example key
dGhlIHNhbXBsZSBub25jZQ==must produces3pPLMBiTxaQ9kYGzzhZRbK+xOo=, plus each missing-header rejection)
Interfaces:
-
Consumes: Task 1's
crypto.sha1, builtin 80 base64. -
Produces:
ws_accept(req) -> ?Int(nil = not a valid upgrade) and the 101-sentinel contract with serve.wo; Task 8's/wshandler consumes both. -
Verify handshake probe vector; then
just web-app(the byte-identical canary — no HTTP behavior may move) andjust deps-accept. Full battery. Commit.
Task 7 — frame codec (pure .wo)
Files:
- Create:
docs/examples/writeonce-framework/http/wsframe.wo— parse one frame from Bytes (fin/opcode/mask/len; 7-bit and 16-bit lengths; 64-bit length → a close verdict; unmasking via bitwise XOR; fragmented data frames → close verdict; control frames legal between data frames), serialize text/close/ping/pong (server frames unmasked, per RFC), and an incremental feeder shape: a carry buffer so a reader can accumulatenet.readchunks and pull complete frames — mirrors parse.wo's carry convention - Create: codec probes (fixture-style like the multipart probes): masked "Hello" round trip (the RFC 6455 example bytes), 16-bit length boundary at 126, oversize close verdict, ping between fragments of nothing (control-frame interleave), torn-buffer reassembly across three feeds
Interfaces:
-
Produces: frame parse/serialize functions over Bytes + the carry convention; Task 8's reader/writer consume them. No fd, no actor — pure functions.
-
Verify probes + full battery (framework compiles = deps gates). Commit.
Stage 4 — the chat sample (Part D)
Task 8 — docs/examples/chat
Files:
- Create:
docs/examples/chat/wo.toml([deps] on the framework — copy web-app's shape),docs/examples/chat/main.wo(or a small module split if main crowds 200 lines:actors.wofor registry/room/reader/writer classes) - Registry actor: map name → room address; a Lookup request answered
through
call(the first honest consumer — reply is the room address, a scalar). Rooms spawned on demand. - Room actor: members = multi of writer addresses; Join/Leave add and
remove + broadcast presence lines; a text message broadcasts to all
writers; a full writer mailbox trap at broadcast = that member is
dropped (catch, remove, close) — the backpressure policy earning its
keep;
monitoron writers so a died writer leaves the room. - Reader actor: owns the fd read loop with wsframe's carry; text → room; ping → writer sends pong; close/EOF → Leave to room, Close to writer.
- Writer actor: sole fd writer; Text/Presence/Pong/Close messages → serialized frames; Close also closes the fd.
- main: spawn registry, register the
/wshandler (query params name the room and user;ws_accept; spawn reader+writer with fd + addresses; Join via the room),/answers JSON usage; serve; onenv.stopping()the serve loop returns — main sends Shutdown through registry → rooms broadcast close → writers flush close frames — then main returns (the reap).
Interfaces:
-
Consumes: everything Tasks 1–7 produced, by exact name.
-
Produces: the running sample Task 9 gates.
-
Manual smoke: build, connect with the Task 9 python client prototype, two clients two shards, exchange lines. Verify
WO_SHARDS=1byte-order determinism for the single-client script. -
Commit (sample alone — the gate lands next so a reviewer can run the sample by hand first).
Task 9 — the chat gate
Files:
- Create:
scripts/chat-accept.sh+scripts/ws_client.py(python3 stdlib only: socket, base64, hashlib, os, threading — speaks the handshake with an independently computed accept-key check, masks client frames, reads server frames) - Modify:
justfile(chatrecipe),scripts/battery docs if the repo lists gates anywhere beside the justfile
Checks (the spec's five, exactly):
- functional: two clients, one room, cross-shard TID assert, third client in another room silent;
- handshake: the RFC worked-example key verified by the client itself;
- soak: 1k clients, one hot room, every room progresses; RSS bound
asserted; a
WO_MAILBOX=8sub-run proving the drop-slow-member path fires and the room survives; - drain: SIGTERM with connected clients → close frames observed →
exit 0; repeated under
WO_IO=uringandWO_IO=epoll, once under the ASan build (zero leaks); just web-appbyte-identical plus the full battery green.
- Verify:
just chat5/5 at default cores ANDWO_SHARDS=1. - Full battery. Commit. Stage 4 complete.
Stage 5 — closeout
Task 10 — docs, stories, board, graph
- Stories: 24 →
status: donewith the landing banner (what landed, gate numbers, the monitor three-argument deviation, the reply-agreement rule); 31 →status: donewith a banner saying it landed INSIDE 24 (the four forks and their decisions, link to the spec); 34 →status: done(C-builtin resolution, ids, vectors). Frontmatter status + folder move together (house rule). - Board: In-progress row cleared (marker doc deleted), Landed entries standup-shaped (the six questions), chain note: next is 23 (io_uring group-commit) with 22's numbers in hand.
- Graph: PUBSUB2/KEEPAL-adjacent nodes — PUBSUB2 done; CRYPTO gate
done (SHA/ETag row unblocked, not built); framework README ledger:
WebSocket/pub-sub rows ✅, ETag row's gate cleared, cancellation row
unblocked-not-built;
media_type/streaming rows untouched. - CODE-LOGIC files:
runtime/src/CODE-LOGIC.md(lifecycle section: call envelopes, cap counter, monitor walk, timer list; crypto section: one paragraph, vectors pointer),docs/examples/chat/CODE-LOGIC.md(actor topology, the two-actors-per-connection reason, shutdown choreography). - Full battery once more after doc edits. Commit.
Success criteria
The spec's four, verbatim: chat gate 5/5 both shard counts; crypto vectors + independent-client handshake; the four lifecycle proofs pinned; full battery green with zero language growth (builtins only).
Self-review notes
- Spec coverage: Part A → T2–T5, Part B → T1, Part C → T6–T7, Part D → T8–T9, diagnostics WO-E226 (T3) / WO_T_ACTOR (T2–T4), closeout obligations → T10.
- Two spec deviations pre-disclosed: monitor's three-argument form (T4) and the reply-agreement rule + atomic cap counter (header).
- Names used consistently:
crypto.sha1/sha256/hmac_sha256,call,monitor(watched, observer, msg),time.after(ms, addr, msg),WO_T_ACTOR,WO-E226,WO_MAILBOX,ws_accept, ids 85–90. - Riskiest surgery is T3 (typing through erasure) — it sits behind two green stages and its compile-fail fixtures are written with it.