Commit graph

46 commits

Author SHA1 Message Date
1ef4e463ec feat(crypto): AES-GCM via AES-NI + PCLMULQDQ (rv2 8 phase B, ids 113/114)
- aes_gcm_seal/open, AES-128 and AES-256 (variant by key length 16/32),
  nonce 12 bytes, out = ciphertext||tag; open returns nil on auth failure
- hardware path only (phase B): AES-NI key schedule (128/256) + block, GHASH
  via PCLMULQDQ with the fast GF(2^128) reduction, GCM mode (J0, CTR from
  counter 2, GHASH over aad|pad|ct|pad|len, tag = GHASH ^ AES(J0))
- constant-time by hardware; target-attributed functions + __builtin_cpu_supports
  gate so the binary stays portable -- no AES-NI traps with a clear message
  (bitsliced software + ARMv8 paths are phase C)
- wiring: wob.h ids + WO_B_MAX 114; builtin.c crypto range; loader arity 4;
  emit.ml (ids/arity/return/name); types.ml (register + return type)
- VERIFIED: matches NIST SP 800-38D cases 4 (AES-128) and 16 (AES-256) and the
  python cryptography reference byte-for-byte; KAT-gated in test_crypto (36/0);
  ASan/UBSan clean; runtime battery + compiler 557/0 green

(cherry picked from commit f12a745a3c1313847f9d7f65e53bcd8093758af9)
2026-09-15 01:15:31 +02:00
ac52c3fdb5 feat(crypto): ChaCha20-Poly1305 AEAD (rv2 8 phase A, ids 111/112)
- hand-rolled ChaCha20 + poly1305-donna-32 + RFC 8439 §2.8 AEAD in crypto.c;
  constant-time (add/xor/rotate + limb math, no tables, no data-dep branches),
  constant-time tag compare
- two bare-name crypto-family builtins beside sha256/hmac:
  chacha20poly1305_seal(key,nonce,aad,pt) -> Bytes (ct||tag)
  chacha20poly1305_open(key,nonce,aad,ct||tag) -> ?Bytes (nil on auth fail)
  key 32B, nonce 12B (caller-supplied, per TLS's per-record nonce need)
- wiring: wob.h enum + WO_B_MAX 112; builtin.c crypto dispatch range; loader.c
  arity 4; emit.ml (ids, arity_of 4-case, return type, is_builtin_name,
  name->id); types.ml (registration + return type)
- VERIFIED: matches RFC 8439 §2.8.2 byte-for-byte (vs python cryptography +
  the RFC vector); test_crypto 24/0 (Poly1305 §2.5.2 + AEAD seal/open/tamper);
  ASan/UBSan clean; runtime battery + compiler 557/0 green
- first rung of the TLS ladder (rv2 9 phase A)

(cherry picked from commit 961854a8f4e9e632b6fa17f7f2e519e2d08f4936)
2026-09-15 01:15:31 +02:00
02b4b13a52 Merge master into db-residency-doctrine — and close the two half-exposed features
The branch was 17 ahead / 25 behind with 11 conflicting files, and drifting
further: db.c had been rewritten twice on master since (group commit, then
compaction). Resolved rather than rebased so both histories stay legible.

Conflicts, and how each was settled:

- db.c: BOTH semantics kept. Master's fatal path and compaction check now sit
  behind the branch's `table_is_durable` predicate, in all three inline arms —
  a volatile table reaches neither the barrier nor the compaction check
- db-bench sample: every mode from both sides (growth, growth-verify, randread,
  replayseed, wmix) and ONE `boot` mode, which both sides had added
  independently
- db-bench.py: all six legs kept. Both sides had also grown the same
  WAL-size helper under different names; collapsed into one
- perf-targets: the branch's §5 (RAM ceiling) then master's §6/§7 — master's
  numbering had already assumed a §5 it did not have
- story frontmatter: master's `status` (the landing truth) plus the branch's
  `readiness` axis. 03 would have read `done` + `refine`, which is a
  contradiction — it was brainstormed and landed on master, so `ready`
- board: both standup blocks newest-first; master's chain rows (a superset);
  the branch's databasev2 1-2 rows with master's 3-4. Fixed a stray `|` in
  master's row 3
- baseline: master's, then REGENERATED from a full campaign — 143 metrics,
  132 checks, 0 failures with both sides' legs present

TWO HALF-EXPOSED FEATURES FIXED, because the merge rule is that master gets
no feature that is honoured in name only:

- `resident: keys` PARSED, set a .wob flag, and did nothing: rows stayed fully
  resident. A developer could declare a 120 GB table keys-resident, watch it
  compile, and be OOM-killed. The loader now REFUSES it with a message naming
  what to write instead, until tasks 5c/5d land. The compiler still parses it
  and its AST golden still passes, so the grammar work stays tested
- `durable: false` was honoured ONLY on the inline path. wo_db_exec_req had no
  guard at all, so a volatile table written from an actor on a worker shard
  would still be logged — precisely porch's session-table case, and precisely
  what iteration 2 exists to provide. All three request-path arms now carry the
  same predicate. Found by reading the merged code, not by a test: the obvious
  probe runs main() on the primary and therefore only exercises the inline path

Verified on the merged tree: wovm-test 0, woc-test 0, oop-e2e 122/0,
residency-accept 8/0, db-bench 132/0, linkcheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 10:14:25 +02:00
ebc3522c40 Merge branch 'master' into chat-ws-lifecycle 2026-08-27 23:11:49 +02:00
477f8d1b2b feat(wob): v7 — class descriptor carries durability and residency
Task 3 of docs/superpowers/plans/2026-08-26-table-residency.md.

- NO LAYOUT CHANGE. The plan said to add descriptor fields; the descriptor
  already had a `flags` u32 with only bit0 used, so both properties ride
  spare bits (WO_CLASSF_VOLATILE 0x02, WO_CLASSF_RESIDENT_KEYS 0x04). A v7
  class record is byte-identical in shape to a v6 one, which is a much
  smaller and safer change than the plan assumed
- both spelled as the NON-default, so a zero flags word means exactly what
  every pre-v7 image meant: durable, every row resident. A non-@table class
  has both clear by construction
- the loader refuses the meaningless pair (bit1+bit2) independently of woc,
  on the standing principle that what the loader accepts the interpreter
  trusts. Verified by FORGING the flags word in an otherwise valid image,
  since woc will not emit one: flags=6 gives "durable:false with
  resident:keys", flags=8 still gives "unknown flags"
- WOB_VERSION 6 -> 7. Kept because an OLDER runtime reading a v7 image would
  otherwise treat a volatile table as durable and quietly disagree with its
  own source. loader.c's check is exact-match, so a v6 image is refused
  rather than read with the bits clear — verified by patching a v7 header
  back down to 6

GAP FOUND AND CLOSED: woc ACCEPTED `durable: false, resident: keys`. Task 1's
steps covered duplicates and bad values but never the combination, and the
plan had only put that refusal in the loader. The spec wants both, so the
compiler now refuses it too (WO-E102, checked after the argument list is
complete since it is a property of the pair). A compile error is the one a
developer can act on.

VERSION DRIFT: the constant lives in FOUR places, not one. wob.h,
emit.ml:157, disasm.ml:186, and compiler/test/runner.ml:2405 — the last is a
deliberately independent reimplementation of the loader battery, and it
caught the drift as 14 failures rather than silently passing. Its flags mask
and the combination refusal are now in sync too, which is the point of it
being independent rather than shared.

Gates: woc-test 557/0, 18 runtime suites 0 fail, 18 ISO-flavour suites 0
fail, cli_smoke OK, oop-e2e 118/0, employee 8/0, db-actor 8/0, site 21/0.
Zero goldens moved (git diff over golden/ empty).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:22:26 +02:00
23550e7021 feat(lang+wo-html): raw text literals, component layer, MVC samples
- lexer: backtick raw text literal — content verbatim, no escape
  processing, common source margin removed at lex time; `${ }` raw and
  `{{ }}` auto-escaping holes
- `{{ e }}` desugars to `esc(${e})` in parser.ml — a Call on the `esc`
  in scope, so types/owner/emit/.wob/VM are untouched
- WO-E004 unterminated raw literal; WO-E005 newline inside "..." —
  closes a hole where a missing quote silently ate the rest of the file
- wo-html: `Component` interface, `render_all`, `Layout`, README
- framework: `ok_html` joins ok_text/ok_json in http/types.wo
- site + shop restructured to one-feature-one-module MVC (view +
  controller per directory, model at the root, bootstrap-only main)
- removed the filler `pad: Int` convention — verified unnecessary for
  plain classes, interface dispatch, containers and actors
- corrected recorded claims: gap #1 blocks neither the build nor the
  layout; a class crosses module lines, only a free fn is scoped
- docs/guides/language-surface.md — the full grammar inventory
- story 37 landed and moved to done/

Gates: oop-accept MET, oop-e2e 116/0, woc-test 556/0, site 11/0,
web-app 46/0, fibers 10/0, db-actor 8/0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 03:58:41 +02:00
4092074201 feat: monitor + time.after (ids 89/90) — the lifecycle slice completes
- monitor(watched, observer, msg): registration lives on the watched
  actor's home thread (kind-7 envelope cross-shard); actor_die walks
  the list; already-dead fires NOW; the notice msg moves; a full
  observer's notice drops with a stderr line (no fiber to trap)
- time.after(ms, addr, msg): per-shard timer list riding the deadline
  machinery (uring tick min + epoll timeout both include timers;
  fired from the same sweep); ms <= 0 delivers now; NO cancel — the
  generation-counter idiom is pinned by run/timer-generation
- runtime_notify: one runtime-sourced delivery path (notices, timers) —
  reserve-or-drop, cross-shard via kind-0 envelopes
- compiler: monitor typed as a bespoke free fn (notice typed against
  the OBSERVER's mailbox — the three-argument deviation, disclosed);
  time.after as a stdlib row whose msg arg is EXEMPT from the module-
  call fresh-arg drop (it moves — the double-own bug the timer fixture
  caught); owner move slots for both
- corpus: run/monitor-death (trap-death + already-dead notices),
  run/timer-delivery (armed + immediate), run/timer-generation
- teardown drops undelivered notices and unfired timers; battery 13/13

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 08:56:48 +02:00
9a9e4927f6 feat: call/reply — send that waits (id 88, envelope kinds 5/6, WO-E226)
- runtime: mailbox slots grow caller metadata (wo_msg), call parks on
  WO_PARK_INBOX (the DB-RPC protocol) and the resume consumes a SCALAR
  reply; FIBER_DONE ships the receive's return value home (same-shard
  unpark or kind-6 envelope); kind-5 carries cross-shard calls
- actor death is real now: a receive trapping uncaught marks the actor
  dead, error-unparks the in-flight caller AND every queued caller,
  drops queued payloads + state, releases cap slots; send-to-dead
  drops silently, call-to-dead traps — a call never hangs. Fixes the
  pre-existing leak/dangle in TRAPF's fiber-death path (cur_msg leaked,
  a->active dangled, the mailbox rotted)
- compiler: reply typing through actor-M erasure — every receive(M)
  program-wide must agree on one return type and it must be a copyable
  scalar (v1); WO-E226 names disagreeing classes / void receives /
  non-scalar replies; call's message moves exactly like send's (owner)
- corpus: run/call-echo (park + ordered replies), run/call-dead-trap
  (mid-call + to-dead, both catchable), compile-fail/call-void-receive,
  compile-fail/call-reply-disagree; cross-shard call proof rides the
  chat gate next
- battery 12/12 fresh-built (ASan+TSan lanes in fibers/db-actor green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 06:22:24 +02:00
dd7dd42bd1 feat: bounded mailboxes + WO_T_ACTOR (trap 13); try-arm place-copy fix
- cap 1024 (WO_MAILBOX override at boot): sender-side atomic
  reserve/release on every path — same-shard, cross-shard envelope,
  OOM rollbacks; full mailbox traps the SENDER catchably; delivery
  pop releases; overshoot bounded by in-flight sends (disclosed)
- test_mailbox 12/0: exact cap single-threaded, two racing senders win
  exactly cap slots, drain/refill clean
- corpus run/mailbox-full-trap: parked sleeper, send loop catches
  "actor mailbox full" after >= 1024 sends
- pre-existing compiler bug found + fixed: a try ARM yielding a Text
  PLACE (bare e.msg, try box.field) aliased a register the arm's scope
  end freed — ASan use-after-free, SEGV on the next unwind's
  double-walk; emit_try now applies copy_place_text to both arm
  results; pinned by corpus run/catch-msg-place
- db-bench driver: msgrate keeps iteration 22's unbounded-flood
  contract via WO_MAILBOX=MSG_N (the cap is 24's policy, not 22's)
- battery 12/12 fresh-built

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:05:13 +02:00
5fc32b4926 feat: iteration 34 — digest builtins sha1/sha256/hmac_sha256 (ids 85-87)
- runtime/src/crypto.c: hand-rolled cores, whole-value over Bytes,
  allocation-free tails; VM half returns fresh Bytes, WO_T_BOUNDS on
  wrong class id (Bytes builtins' message shape)
- test_crypto: RFC 3174 + FIPS 180-4 + RFC 4231 (incl. long-key case 6)
  + 63/64/65 block-boundary sweep + the RFC 6455 handshake input, 18/0
- compiler surface flat per house convention (sha1, not crypto.sha1 —
  matches base64_encode): types.ml signatures + result types, emit.ml
  ids/dispatch/arity/known-list/drop-table (fresh-Bytes entries so
  results get their drops)
- corpus run/crypto-digests pins the .wo path through base64_encode
- no .wob bump (ticks-84 precedent); WO_B_MAX 87; surface doc rows
- slice marker + board row: iteration 24 (absorbing 31+34) executing
- battery 12/12 fresh-built

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:42:43 +02:00
ee018f06e2 Merge branch 'read-path-index' 2026-08-22 23:26:01 +02:00
c2c9b240f1 feat: iteration 36 task 4 — runtime bitwise opcodes, .wob v6
- WOP_BAND..WOP_SHR = 42..46 (wob.h), WOP_MAX 46, WOB_VERSION 6
- trap kind WO_T_SHIFT=12: shift count outside 0..63 traps (DIV0
  precedent, never x86's silent count%64); SHR arithmetic
- vm.c: one shared case-body serves both dispatch flavors; SHL shifts
  the unsigned word (wrapping), SHR casts int64_t (sign extends)
- loader.c + test runner battery + disasm: v6 accepted, new opcodes
  validated three-register, rendered BAND/BOR/BXOR/SHL/SHR
- woc-test 543/0, wovm-test ASan both flavors green, cli_smoke OK

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:38:22 +02:00
3d75196121 feat: iteration 36 tasks 2-3 — grammar, checker, emit lowering
- ast: unop Not, binop BAnd/BOr/BXor/Shl/Shr
- parser: | ^ join additive, & << >> join multiplicative (Go rungs);
  not joins unary (Lua placement); ladder doc updated
- compound assigns claim the dead +=/-= tokens plus *= /= %= —
  parse-time sugar via rewind-and-reparse, fresh ids by construction
- types: bitwise Int-only both sides, not Bool-only (WO-E201 family);
  literal shift count outside 0..63 rejected as new WO-E223;
  confident-typ knows bitwise=Int, not=Bool
- emit: op_band..op_shr 42..46; not lowers on existing EQ vs zero
- woc-test 543/0 unchanged; scratch smoke parses/typechecks clean

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:29:47 +02:00
881b90e9c7 feat: O(1) read path — index probe wired end to end
- engine: wo_idx_probe answers single-column equality from the index
  hash buckets (idx_hash_key1 reproduces idx_hash bit for bit; verify
  compares exactly as the slab walk did, so results identical);
  composite indexes keep the walk; both executors wired (local + DB
  actor RPC)
- compiler: probe_key_of_where lowers "var.col == key" on an indexed
  column to DB_PROBE; all where guards still run (guard stays the
  final arbiter); keys = ident/int-literal only; Float/Bytes excluded
  (engine raw-eq narrower than VM float-eq)
- measured: reads 1.3k -> 1.3M ops/s, p50 600us -> 1us (~x850);
  query x830; mixread 1.3k -> 89k s1, 21 -> ~1.9k sN
- gate policy moved into the driver (tolerance_for: refresh-proof);
  latency floors max(4x,100us); quick mode skips poll-bound mix
  floors; both tolerance classes proven to bite
- proof: test_table wo_idx_probe suite (RED first), corpus
  query-index-probe 105/0, full battery green, TSan clean, two
  campaigns pass the refreshed baseline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 16:44:56 +02:00
d24c705860 feat: iterations 19 + 17 — Float/Bytes scalars (.wob v5), library kind + internal/
- 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>
2026-08-20 19:24:15 +02:00
d2d1721e05 feat: spawn / send / actor M — the unified actor surface (arc T3)
- language: `spawn Cls { fields }` expression (ctor semantics — fields
  MOVE; result is the address); `actor M` parametric field type
  (contextual like multi/map — actor stays a legal identifier); `send`
  is a builtin free-fn name, not a keyword (shadowing rule applies)
- typing: M inferred from Cls's receive(msg: M); WO-E221 when receive
  is missing, mis-armed, or M is not a class/record/union; send checks
  addr is `actor M` and the message IS an M (silent when underivable);
  ctor half of spawn delegates to the Ctor arm (completeness, ?T, E207)
- ownership: send's message TRANSFERS (sender's later use = WO-E301,
  corpus-pinned); spawn's fields move via the ctor machinery; an
  address is Copy
- emit: spawn lowers to ctor + LOADK receive's method index + BUILTIN
  68; send is BUILTIN 69 with the message excluded from fresh-arg drops
  (the runtime owns it now)
- runtime: wo_actor (moved-in instance, receive idx, growable FIFO
  mailbox, one delivery fiber at a time); delivery reuses the fiber
  context across messages and re-queues per message (fairness — an
  actor never monopolizes); the runtime drops each message after its
  receive returns; actor state/queued/in-flight messages are GC roots;
  teardown drops everything (main-return reap included); loader knows
  the two arities
- corpus: run/actor-echo (typed spawn/send, one-at-a-time delivery
  interleaved with main by budget — output exact, ASan-clean),
  compile-fail/spawn-no-receive (WO-E221), send-after-move (WO-E301)
- battery green: oop-e2e 92/0, woc-test, wovm-test, log-watcher 7/0,
  employee 8/0, web-app 21/0, deps-accept 8/0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 07:21:24 +02:00
ed29ccadbc fix(compiler): return of a Text interp of a place handed out the borrow
- emit_return's place test matched only bare Ident/Field/Index, so
  `return "${p.content}"` (p a loop borrow) returned the part's own
  string; the caller's eventual drop freed it under the container —
  arena corruption surfacing two requests later (multipart slice)
- the return test now sees through Interp exactly as copy_place_text
  does (is_borrowed_value_t, container reads excluded); bare
  Ident/Field/Index behavior at return unchanged
- interp-borrowed-field fixture grows the return flavor (fn first),
  50 iterations exact
- gates: oop-e2e 89/0 (ASan stage), woc-test green

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 03:32:31 +02:00
81a9f882b5 fix(compiler): Text interp of a place crossed let/assign uncopied
- copy_place_text matched only bare Ident/Field/Index, so a Text-typed
  single-segment interpolation ("${r.method}", r a loop borrow) passed
  the place's own register through a binding/assignment boundary — the
  local aliased the row's field and its overwrite freed it
- release-build crash; invisible to ASan (in-arena free, no redzones)
- now asks is_borrowed_value_t && not is_container_read — exactly
  drop_fresh_text's place test; Int segments (fresh int_to_text) and
  container reads (already copies) stay uncopied as before
- pinned by tests/corpus/run/interp-borrowed-field (crashed both
  runtimes before the fix, 50 iterations now exact)
- gates: woc-test 540/0, oop-e2e 89/0 (ASan stage included)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 03:04:43 +02:00
970ae2566c feat(woc): dependency resolver + multi-root builds (iter 15 Tasks 2+3)
[deps] entries now resolve to fetched, locked checkouts and compile as
module roots.

- resolve_deps (driver): .wo-deps/<name>/ cache beside wo.toml, wo.lock
  pinning name -> commit SHA. Cold+unlocked: clone at the manifest rev,
  record HEAD. Cold+locked: clone and checkout the LOCKED sha — a moved tag
  cannot change the build. Warm+locked: HEAD==lock -> zero network.
  Divergence is WO-E106 "lock drift" naming both SHAs and pointing at
  --update-deps; every git failure (missing binary, bad URL, bad rev,
  missing locked commit) is WO-E106 naming the dep and step. Guard rails:
  fetched dep must be a writeonce project; a dep with its own [deps] is
  refused (flat-only); dep name colliding with a local module dir is
  WO-E107. All git via the git binary (Sys.command; output reads through a
  temp file) — no network code in the compiler. Full clone, not --depth 1
  (a locked SHA must be reachable regardless of tag movement) — recorded
  deviation from the plan's clone sketch.
- woc --update-deps <dir>: re-fetch at manifest revs, rewrite the lock.
- Multi-root compile: compile_image gains ~deps; app files first then deps
  sorted by name; module_of_multi maps a dep file to `<name>` /
  `<name>/<sub>`, so existing use/pub/collision machinery works across the
  boundary unchanged. The app root's walk skips .wo-deps via the existing
  dot-rule.
- Entry restriction: Emit.emit gains ?entry_ok (default true — test helpers
  untouched); the driver excludes dep files, so a dependency's fn main is
  never the entry.

Verified end to end against local file:// remotes: cold fetch + lock; `use
niceframework` + `use niceframework/strutil` build and run; offline rebuild
with the remote deleted; moved tag -> cold rebuild stays at the locked SHA;
--update-deps follows the tag and rewrites the lock; cache/lock drift,
transitive [deps], and name collision each produce their named diagnostic;
the dep's fn main (returning 99) never becomes the entry. woc-test 540/0;
oop-e2e 87/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 19:19:57 +02:00
f282451867 feat(json): Bool encodes true/false; fraction/exponent decode fails honestly
Closes json's two documented fidelity limits (iteration 5 strictness):

- field_class gains WOB_FIELD_BOOL (a plain `Bool` field) and
  WOB_FIELD_NIL_BOOL (a `?Bool`: WO_NIL_SCALAR nil + bool encoding) — the
  kind byte alone cannot tell a Bool slot from an Int slot, so the metadata
  carries it. Emitter writes them (field_class_meta); loader whitelists
  them; json.c encodes `true`/`false` (and `null` for a ?Bool nil), decode's
  null/omitted-key pre-write covers NIL_BOOL.
- A JSON number with a fraction or exponent is MALFORMED for an Int field:
  the checked decode (`json.decode(t) as T`) yields nil for the whole
  document instead of silently truncating 3.7 to 3 — the language has no
  float, and corrupting data quietly was the one thing a "checked decode"
  must never do. Floats stay representable through a raw `json.Value` field.
- corpus: run/json-bool-fidelity pins the round-trip (true/false both ways,
  ?Bool null both ways, fraction AND exponent rejected).
- Board's two known-gap entries struck; format doc's field_class marker list
  extended.

Verified: oop-e2e 87/0; runtime test + test-iso OK; woc-test 540/0;
log-watcher 7/0; employee 8/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 18:04:15 +02:00
092b528081 feat: retire RC from the emitter and the format — .wob v4 (7b Phase 3b)
The compiler no longer emits reference-counting ops anywhere, and the format
reserves them. With Phase 3a's collector this completes the runtime half of
iteration 7b: spec success criteria 3 (no RC ops in any image, opcodes
reserved) and 6 (corpus ASan-clean) are met — `just oop-accept` is fully green.

- owner.ml: the rc machinery is deleted outright — rc_site/rc_op types, the
  rcs table, fn_rcs/rc_groups/rc_escaped, record_rc, release_gc, gc_escape,
  resolve_rc, and the clobber rule (its only consumer was elision). The
  `push`-of-a-gc-value RC_INC special case is gone (the bug class cannot recur
  without RC). Drop tables (owned + LGc kinds) are untouched — the gc mask is
  what feeds the collector's root maps.
- emit.ml: emit_rc, the v_rc view, the escape-acquire anchor, and every
  caller deleted; assignment displacing a traced value emits nothing (the VM's
  store barrier owns it); scope-ended LGc handles clear their gc-mask bit so
  root maps stay precise.
- .wob v4: WOB_VERSION 3 -> 4 in wob.h + emit.ml + disasm.ml + the runner's
  loader battery; opcodes 27-28 removed from the enum/jump table/interpreter
  and REJECTED by the loader like any unknown opcode.
- dump.ml: the == RC == owner-dump section is gone; 6 goldens re-blessed
  (owner dumps lose the section, elision.wo's bc dump loses its RC ops).
- runner.ml: rc-table/ELIDED assertions deleted; the elision test now asserts
  the WHOLE image contains no RC op; the table-contract sweep asserts rc ops
  never appear.
- test_unwind.c: the rc-opcodes test becomes two — the loader rejects reserved
  opcode 27, and an abandoned traced instance is freed by rt_destroy
  (ASan-proven).

Verified: woc-test 540/0 + test_diag 14/0; runtime test + test-iso all suites
ASan/UBSan (test_unwind 12/0); cli_smoke; oop-e2e 79/0 (v4 images end to end);
employee 8/0; log-watcher 7/0; ring runs + reclaimed (freed=3) with zero RC
ops in its image; `just oop-accept` ALL CRITERIA MET.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 17:07:43 +02:00
484a3259b5 feat(compiler): is_gc_class is inference-first (7b Phase 2a)
GC-ness now comes from the inference pass, not only the annotation. A class is
traced if the structural SCC put it in `syms.traced`, OR (temporary bridge
until demand-promotion lands) it still carries `@gc`.

- Types.symbols gains a `traced : StringSet.t`; is_gc_class reads it (union'd
  with the surviving @gc annotation). All symbols literals + both merges carry
  the field.
- typecheck_all injects the classification once (Gcinfer.classify -> traced)
  into the merged table AND every module table, before typecheck/owner/emit.
- emit.ml routes the class gc-flag and the union/drop decision through
  is_gc_class instead of the raw `.is_gc`, so structurally-inferred gc classes
  get the runtime flag. Field-kind derivation already routed through is_gc_class.
- gcinfer.traced_names exposes the traced set for injection.

Effect: docs/examples/gc-cycle now COMPILES with no annotation (the WO-E301
use-after-move at the ring-closing store is gone) — traced classes alias
freely. Bytecode is byte-identical to writing `@gc class Node`.

Verified: woc-test 566/0 (goldens unchanged — every current @gc class stays gc
via the annotation branch, and no golden has a structural-gc-non-annotated
class); oop-e2e 79/0 (gc corpus green).

Not in this slice: demand-promotion (the acyclic-aliased PriceCache case still
needs the @gc bridge) and @gc-in-source-as-error (Phase 2b); the ring RUNNING
(the RC runtime doesn't implement nullable-gcref `?Node` fields — Phase 3).
WO-W201 still fires on gc-cycle (retired in Phase 4).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:51:06 +02:00
2677c1457e feat: FK restrict on delete + employee sample runs; group-by parked (9b)
- FK restrict: deleting a row a non-nullable `ref` still points at traps
  WO_T_FK (11), catchable. The compiler now records a `ref` field's
  target class in the class-table field_class metadata; the engine
  (wo_row_has_referrers) scans referencing scalar columns before a
  delete. Correctness-first full scan; the backlink-index optimization
  is recorded for later
- docs/examples/employee now COMPILES AND RUNS all six modes against a
  WAL-durable database: seed (+@unique trap across restart), report
  (per-dept aggregates + payroll), staff (unique probe + backlink +
  ref nav), raise (update-through-row), drop (FK restrict), and
  persistence via replay
- group-by SYNTAX parked to a future iteration (user decision): the
  report mode is hand-rolled from the shipped primitives meanwhile
  (same numbers). "table relations and FK" is complete
- scripts/employee-accept.sh (8 checks) + a `just employee` module;
  manifest parser tolerates iteration 9c's [share]/[[share.clients]]
  sections so `woc .` builds the sample on this branch
- fixtures trap/db-fk-restrict (code 11) + run/db-fk-restrict-catch;
  oop-e2e 79/0, woc-test 566/0, 15 runtime suites, log-watcher 7/0,
  employee-accept 8/0
- 9b story + status board updated

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 18:37:23 +02:00
c8c34c118c feat(compiler): update-through-row + delete statement (9b cont.)
- `e.field = v` where e is a table row lowers to DB_UPDATE_FIELD
  (class, id, field, value) — the row's indexes maintained at the choke
  point; heap-object field assignment still emits SETF unchanged
- `delete <row>` expression: DB_DELETE(class, id), yields the id so it
  composes in `try delete x catch (e) nil` (restrict/trap surfaces
  catchably); Delete AST node threaded through type/owner/dump/emit
- disassembly-caught bug fixed: in tail position dst == the builtin
  window's first reg, so moving the id into dst clobbered the class id
  — reserve dst past the window (the emit_ctor guard)
- run/db-update-delete fixture; oop-e2e up, woc-test 566/0,
  log-watcher 7/0
- employee seed/list/staff/raise/drop now compile+run; only `report`
  (group-by aggregation + projection record) remains

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 05:24:58 +02:00
75b35fb456 feat(compiler): query order-by + take (9b cont.)
- `order by <field> [desc]` on whole-row queries: a selection sort over
  the result multi, re-reading the key per element via the range var
  (DB_GET_FIELD); O(n^2), KISS, no cost planner — the result sets are
  small by design
- Text order keys use a new WO_B_STR_LT builtin (content compare, reusing
  the WO_B_SORT elem_cmp); scalar keys use the LT opcode. The bug this
  fixes: op_lt on two Text pointers compares ADDRESSES
- `take N`: clamp to count, slice [0,N). `take` is the KwTake keyword,
  not an Ident — matched as the token
- two bugs found + fixed while testing: multi-line query clauses (skip
  the separating newlines) and the key-kind read (must bind the range
  var BEFORE ty_of_expr of the order key, or a Text key silently uses
  op_lt); Index typechecks to the container's element type (`ds[0]`)
- fixture run/db-query-order; oop-e2e 75/0, woc-test 566/0, 15 runtime
  suites, log-watcher 7/0
- employee `seed`/`list`/`staff` modes now compile and run; report
  (group-by+projection), raise (update), drop (delete) remain

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 05:20:17 +02:00
8817fdec2a feat(compiler): ref + backlink navigation in queries (9b cont.)
- `backlink C.f` field type: parsed, typed as `multi C`, and VIRTUAL —
  filtered out of the stored row layout (no column, omittable in
  ctor/insert), collected in clsrec.cr_backlinks
- reading a backlink (`d.staff`) lowers to DB_PROBE on the source
  class's index for the backing column (backlink_target resolves the
  (source class, index number); a backlink with no backing index has
  no efficient read)
- `ref C` navigation (`e.dept.name`) chains: a ref value is the target
  row's id, so a `Ref C` base navigates into C's fields exactly like a
  table-class value, routing to DB_GET_FIELD both in typecheck and emit
- query navigation source `from s in d.staff`: emit_query evaluates the
  nav expr to get its id-list instead of DB_SCAN; QNav typechecks with
  the range var bound to the navigation's element class
- fixture run/db-query-relations proves both directions; oop-e2e 75/0,
  woc-test 566/0, log-watcher 7/0
- still ahead for employee: order/take, group-by aggregates, projection
  records, delete + update-through-row

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 05:10:13 +02:00
d4bfee9745 feat(compiler): language-integrated query — scan/where/select (9b slice)
- Ast.Query node + parser: `from <v> in <src> where* [group..into] [order
  by] [take] select <e>`, positional `from` trigger so it stays a usable
  identifier; select stays grammar-owned (no DbStub conflict)
- typecheck: range var bound to the source table class; a table-class
  value is its row id at runtime but TYPES as the class, so `e.field`
  checks against the class fields; result is `multi <select-type>`;
  group/order/take/navigation diagnosed WO-E250 not-yet (honest edge)
- emit: `from/where/select` lowers to a bytecode LOOP over DB_SCAN's
  materialized id list — DB_GET_FIELD per column read, where-guards skip
  the push, select projects, result is a fresh multi; no plan tree, no
  SQL text (disassembly-provable)
- field access on a @table-class value routes to DB_GET_FIELD instead of
  GETF (clsrec.cr_is_table + is_table_class); non-table classes unchanged
  so log-watcher is unaffected
- the ASan-caught bug kept in a comment: a table-class query element is a
  SCALAR id, not an OWNED pointer — tagging the result multi OWNED dropped
  an id as a pointer (SEGV in wo_drop_obj)
- fixture run/db-query-scan (where-filter + select-whole + select-field);
  oop-e2e 74/0, woc-test 566/0, log-watcher 7/0
- SLICE scope: group-by aggregation, order/take, and ref/backlink
  navigation are the next chunk (employee report/staff/raise need them)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:28:09 +02:00
6f2f9b6f0a feat: secondary indexes + @unique trap (iteration 9, Task 4; wob v3)
- .wob v3: class records carry an index tail (flags bit0 = unique,
  col_cnt, columns) -- @table(index:[a,b]) entries plus one unique
  single-column entry per @unique field; loader validates columns in
  range and scalar/Text-kinded; emitter validates the declarations
  (unknown column, un-indexable kind => diagnostic)
- engine: db_index hash multimap per table, built from the class
  table at first touch, maintained ONLY inside wo_row_insert/
  wo_row_remove; unique checks re-compare actual column values (a
  hash is a hint); replay re-indexes via wo_row_raw_commit AFTER
  slots are filled, so recovered tables carry their indexes
- WO_T_UNIQUE = 10; a violating insert is un-applied whole (bitmap,
  hash, count, and the never-observable id reclaimed) and traps
  catchably -- the employee SEED-DUP pattern
- wo_row_insert gains err_kind so db.c maps UNIQUE/OOM/other to the
  right trap; test images and the runner's loader mirror speak v3
- fixtures: trap/db-unique-violation (code 10 exact) and
  run/db-unique-catch (catchable dup, composite index accepts
  duplicates, next id dense after a refusal)
- gates: oop-e2e 73/0, all 15 runtime suites, woc-test green,
  log-watcher 7/0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 12:57:32 +02:00
30ef8d8533 feat: insert executes (iteration 9, Task 3) -- DB_STUB retires for insert
- compiler: `insert Class { ... }` is a typed Ast.Insert in statement
  AND expression position, sharing the ctor literal's field grammar;
  typechecked with the ctor's omittable rule; result = the row id (Int)
- owner pass: the engine copies at the row API, so an insert BORROWS
  its field values -- no transfer, no E304; node is trap-capable and
  carries a live-mask drop entry like DbStub did
- emit: builtin 61 window = class-id const + one slot per DECLARED
  field in declaration order; omitted defaults emitted, omitted ?scalar
  gets WO_NIL_SCALAR, other omitted optionals the zero word; fresh
  argument values reaped after (the push/set copy semantics)
- runtime: database/src/db.c executes via the choke-point row API;
  rt.db/rt.wal opaque handles on wo_rt; WO_DATA=<dir> = replay
  <dir>/shard-0.wal at boot + commit-before-ack per statement (the
  builtin's return IS the ack until iteration 8 ticks); failed commit
  un-applies the row and traps WO_T_IO; loader validates the class-id
  slot (variable window documented in wob.h + format doc)
- the promised diff: trap/pricing-set-price-db-stub is now
  run/pricing-set-price-insert printing engine-allocated ids;
  durability smoke prints 1,2 then 3,4 across two WO_DATA runs
- old "bare insert is an Ident" unit test rewritten to the new
  contract; runner's loader mirror accepts id 61; goldens re-blessed
- gates: oop-accept ALL CRITERIA MET, oop-e2e 71/0, woc-test 566/0,
  wovm-test green, log-watcher 7/0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 11:15:06 +02:00
3c53d27347 fix: close per request, soak the daemon, kill five soak-found leaks (Tasks 5+6)
- Task 5: net.close on every path out of a serve iteration (400
  included) and the listener on stop; measured 4 -> 54 fds over 50
  requests before, 4 -> 4 over 200 after. The loop's comment claimed
  the iteration-end drop IS the close -- wrong twice (net.Conn is a
  scalar, and a drop would not close an fd); it now says what is true
- Task 6: LW_SOAK=<seconds> in the acceptance script -- each mode under
  load, resident+descriptor deltas against a WARMED baseline (warm-up
  includes load: cold-to-high-water is not growth), 256 KiB / zero
  tolerance; LW_ACCEPT_WOVM soaks another build
- the soak caught ~1.6 MiB/min of in-arena leaks ASan cannot see (the
  arena is one allocation to LeakSanitizer); an arena size-class
  census + pointer trace attributed five bugs:
  - jparse_string sized every decoded string at "rest of the input"
    and relabeled len after -- blocks filed on free lists their next
    allocation never reads (fs.read_all's mis-size, again); copy out
    exact, free at the taken size
  - `!=` never dropped fresh operands (headers["authorization"] !=
    "Bearer ${key}" leaked both sides per request); Ne now reaps as Eq
  - an Int interpolation segment is a fresh int_to_text, not a borrow;
    is_borrowed_value_t asks the segment's type
  - json.encode(Ctor{...}) had no owner -- record + both field copies
    leaked per tool call; its bespoke lowering now drops the argument
  - a discarded expression statement owns its result: `pop(lines);`
    leaked the popped element; reader builtins excluded
- after: arena live bytes flat per request on every handler; release
  soak 30 s per mode watch 0 / run 0 / mcp +20 KiB, descriptors flat;
  ASan build flat at 14600 KiB across 601686 requests in 90 s past its
  ~1200-request quarantine warm-up
- gates: oop-accept ALL CRITERIA MET, oop-e2e 71/0, woc-test 565/0,
  wovm-test green, log-watcher 7/0 (soak opt-in, fast path <1 min)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 00:32:49 +02:00
22910e3974 fix: a stopping program stops (executable plan, Task 4)
- blocking stdlib calls that PARK (net.accept, socket read/write,
  time.sleep, a child wait) no longer restart the syscall when the
  stop flag is set on an interruption: a server sitting in accept
  ignored SIGTERM and only `kill -9` ended it
- a stop is NOT a trap -- builtin.h's WO_SYS_STOPPED carries no error
  record and no catch handler sees it (`try` must not swallow
  SIGTERM); the VM unwinds the whole stack through the same drop
  machinery an uncaught trap uses, so nothing leaks on the way out
- wo_vm_call gained a third outcome (1 = stopped); the CLI maps it to
  the status the program's own `return 0` would have given, and a
  regular-file read keeps its plain EINTR retry -- it does not park
- an ASSIGNMENT was not an ownership boundary: `api_key =
  j.mcp.apiKey` moved the field pointer into the local, so the local
  aliased the record and the first unwind freed the same string twice
  (SIGSEGV in class_free). `let` copied a Text place, assignment now
  does too -- the same double free was latent on the normal exit path,
  hidden by the order the compiler happens to emit drops in
- log-watcher-accept is 7 checks: the seventh is the stop itself, with
  the hard kill demoted to a fallback whose use is the failure
- measured under ASan: mcp parked, mcp after traffic, watch and run
  all exit rc 0 with zero leaks; SIGINT behaves as SIGTERM
- gates: oop-accept ALL CRITERIA MET, oop-e2e 71/0, woc-test 565/0,
  wovm-test green, log-watcher 7/0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:58:21 +02:00
ef74d157b6 fix: drop the values nobody names (executable plan, Task 2)
- the drop tables track bindings only, so six shapes had no owner: a
  comparison operand (`if parse_expr(s) == nil` abandoned a schedule
  record and its five containers per cron line), a borrowed call
  argument (a 1 KB string per MCP request), a container read's copy,
  a loop's iterable, a projected record, and any of those escaped by
  a `return` from inside the statement that built them
- `c[i]` is the one place expression whose register holds a COPY:
  no second copy at a boundary (`let u = tokens[0]` copied twice and
  abandoned the first), and a drop where every other place is left be
- never drop an argument register after a CALL — the callee's frame
  overlaps it; the reap moved into call_window's pre-call stash
- a statement-owned temporary is parked in a LOCAL slot: a loop
  reclaims every temp for its body, and the end-of-statement DROP was
  releasing the loop counter instead of the record
- reader builtins (get/latest/key_at/val_at) keep arg0 alive — their
  result points into it — but their key argument is ordinary
- measured: run 2 112 B -> 64 B, flat 8 s to 20 s; MCP mix 21 312 B /
  63 -> 64 B / 1; every handler flat from 2 to 6 requests; the 64 B
  left is Task 3's argv container
- gates: oop-e2e 71/0, woc-test 565/0, wovm-test green, log-watcher 6/0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:23:21 +02:00
eb0095428d fix: Text is an owned value copied at every boundary (executable plan, Task 1)
Measured on the workload's supervisor mode, eight seconds, clean SIGTERM exit:
run 1 051 040 B in 24 allocations -> 2 112 B in 19; watch 128 B in 2 -> 64 B in
1. corpus 71/0, woc runtest 565/0, wovm unit gates green, just log-watcher 6/0.

- owner.ml: `oclass_of` called `Text` a builtin scalar, so it was Copy and NO
  Text local was ever dropped — that, not the missing stdlib table, was the
  leak. Text is now Owned, which forces an answer for what it does at an
  ownership boundary, and the answer is uniform: it is COPIED. Into a
  container (push/set/`m[i] = v`, already true), into a field (SETF), out of a
  function (return), into a binding (`let s = other`), and into a loop cursor.
  The source keeps its value; a freshly built Text stays the caller's and is
  dropped at the site
- owner.ml: resolve_callee answers for three shapes it never knew — reserved
  stdlib members, builtins, and a class's `static` members — so their results
  get a type, an owner and a drop
- vm/builtin: WO_B_TEXT_COPY, the one new builtin the rule needs; SETF copies a
  TEXT field in; emit copies a Text read out of a container, bound from a
  place, returned from a place, or loaded into a cursor, and drops a freshly
  built one after a copying store
- sysio.c: fs.read_all/net.read allocated their cap then relabelled the buffer
  with the short length — but wo_str_free sizes a block by its len (no size
  headers, obj.h), so a 1 MiB buffer wearing a 30-byte length went onto a
  32-byte free list and never came back. They copy out at the true size now
- two regressions the corpus caught, fixed in the same pass: a @gc value read
  out of a container is a plain borrow, not an rc-counted alias; and push's @gc
  escape is keyed on "push is not a user-declared fn" rather than "the callee
  did not resolve", which stopped being true once builtins resolved
- docs: Task 1 closed in the executable plan with its before/after numbers, and
  the status board's item 1 records the deeper root cause

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 22:45:03 +02:00
4cf548d6a0 fix: copy-on-push closes the container double-free; line-buffer program output
The unsoundness is closed. All four MCP tools now answer correctly over HTTP
(get_running_crons, list_logs, tail_log -> ["info two","error three"],
search_log -> its match) where `tail_log` used to return
{"isError":true,"text":"tool failed: not a text value"}. corpus 71/0,
woc 565/0, wovm gates green, ASan clean on the container fixtures.

- builtin.c: multi_push, map_set (key AND value) and multi_set COPY a TEXT
  element into the container. The container's declared kinds already make it
  the owner of what it holds, so storing a caller-owned pointer gave one
  string two owners — `push(res, e.log_path)` freed a record's field out from
  under it. OWNED/GCREF elements still move (not copyable; the @gc escape
  keeps their counting), so `set`'s @gc gap is untouched and still recorded
- emit.ml: `drop_fresh_text` — after push/set and the `m[k] = v` / `m[i] = v`
  sugar, a value that was freshly BUILT (call result, `..` chain,
  interpolation) is dropped here, while a value read out of a place is left to
  its owner. That asymmetry is the point: before the copy the borrowed case
  double freed and the fresh case leaked
- obj.c: the runtime's output stream is line-buffered. A long-running program
  writing progress with `print` was invisible when stdout was a file or a pipe
  (full buffering), and a killed one lost its log entirely; byte-exact
  fixtures are unaffected
- scripts/log-watcher-accept.sh + `just log-watcher`: the acceptance test for
  the sample — compile, watch (alert), run (schedule), and three MCP checks.
  Hardened after it lied to me: a per-run port (a stale server on a fixed port
  answered for it), a connect-probe that fails loudly when OUR server did not
  come up, replies read by Content-Length rather than to EOF (the sample never
  closes), and kill -9 on teardown
- docs: the copy rule is in the builtin surface; the status board records the
  gap as closed and adds the new one — a blocking accept/read swallows SIGTERM,
  which belongs to the shard-actor runtime's event loop, not to a patch here

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:50:18 +02:00
0acb6be559 fix: net.Conn is a scalar, \r escape, map[k] is optional, interp node ids
Found by driving the compiled log-watcher's third path — the MCP server. It now
answers real JSON-RPC over HTTP: initialize returns protocolVersion/serverInfo,
tools/list returns the full tool list (1049 bytes of generated JSON), and an
unauthorized request gets 401 {"error":"unauthorized"}. corpus 71/0, woc 565/0,
wovm gates green.

- lexer: `\r` and `\0` escapes. Without `\r` a program cannot write CRLF at
  all — the server's `index_of(buf, "\r\n\r\n")` was searching for a literal
  backslash-r, so it never found a header terminator and hung on every request
- parser: an interpolated sub-expression now mints node ids from the OUTER id
  space. A fresh sub-parser started at 1, so `${...}` nodes collided with the
  file's own nodes — and every side table (drops, moves, rc, masks, f_decl) is
  keyed by node id. Surfaced as WO-E404 "ownership table names `headers`,
  which has no register"; silent misattribution otherwise
- types.ml: `net.Conn` is a reserved SCALAR type (a file descriptor). It was
  falling through as "some user class", i.e. WO_K_OWNED, so the frame would
  DROP an integer at scope end
- types.ml: confident_typ knows `..` yields Text. Interpolation desugars to a
  Concat chain, so without it every interpolated value looked underivable —
  which is why the `+`-on-Text check missed two live sites in the workload
- `m[k]` on a map is now the OPTIONAL read (nil for a missing key), while
  `get(m, k)` stays the asserting one that traps KEY. That is what makes
  `let v = m[k]; if v != nil` — the workload's header lookup — work.
  trap/missing-map-key now pins `get(...)`, and the surface doc records the
  split
- json.encode of a `json.Value` emits it verbatim (kind 255): an echoed id was
  coming back as "1" instead of 1
- disasm: TRY/ENDTRY render instead of ?OP32/?OP33
- status board: the push-of-a-borrowed-Text gap is now recorded with the
  concrete failure it produces (tools/call tail_log), plus the leaked
  temporary-record shell found in the same disassembly

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:47:45 +02:00
993a540d7a fix: nullable scalars need their own nil word; EQS accepts nil
Found by running the compiled log-watcher, not by reading code: the supervisor
rejected every cron line ("malformed schedule: * * * * *") because a `*` field
expands to 0 and `?Int`'s nil was also 0, so `a == nil` was true for a real
value. Both log-watcher subcommands now behave: `watch` alerts on a live file,
`run` reports SCHEDULE /var/log/backup.log: * * * * *. corpus 71/0, woc 565/0,
wovm gates green.

- a nullable SCALAR (?Int/?Bool/?Timestamp/?Id) spells nil as WO_NIL_SCALAR
  (-2^62), not the zero word. Heap-shaped optionals keep 0 — a null pointer is
  unambiguous. The value is -2^62 and NOT INT64_MIN on purpose: the compiler's
  integers are OCaml's 63-bit natives, so INT64_MIN is not expressible there
  (and `min_int * 2` silently wraps to 0 — the first attempt did exactly that)
- the class table marks such fields (WOB_FIELD_NIL_SCALAR in field_class), so
  the runtime writes the right absence where it produces absence itself:
  json.decode leaving a key absent or seeing `null`, and parse_int on
  unparseable input (so parse_int("0") is now distinguishable from a failure).
  json.encode renders a nil scalar as JSON null
- emit.ml: `nil` takes its word from its destination (annotation, field,
  return type); a comparison against `nil` emits the literal with the other
  operand's type, so ?scalar compares against the sentinel and ?heap against 0
- vm.c: EQS accepts a nil operand — two `?Text` values compare with it, and the
  answer is "both absent is equal, one absent is not". Trapping there made
  `a != b` on optionals unusable (it was trapping BOUNDS "null text" in the
  supervisor's rescan). A non-nil operand must still be a real Text
- docs: both normative docs now state the heap-vs-scalar nil split and the EQS
  rule; the stale duplicate vm_unwind comment is gone

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:28:50 +02:00
b973ea107e feat: program mode (argv + exit code); reject + on Text; nil compares by word
docs/examples/log-watcher now COMPILES AND RUNS: `wovm lw.wob watch app.log 2 1`
tails a live file, classifies levels and fires its alert
("last entry is error, quiet for 2s"). corpus 71/0, woc 565/0, wovm gates green.

- program mode: the entry is `fn main` taking nothing or one `multi Text`;
  runtime/src/main.c builds that list from the program's own arguments (not
  the program name, not the image path) and the entry's return value is the
  process exit code (low byte); the loader accepts a 0- or 1-arg free-fn entry
- `+` on Text is now WO-E201 pointing at `..`. This was a memory-safety hole,
  not a style nit: the emitter lowered it to ADD on two heap pointers, and the
  workload's own `out = out + char_of(c)` produced a wild pointer that
  segfaulted the VM inside starts_with. Reported off confident types only
- `x == nil` / `x != nil` lower to EQ (a word compare), never EQS: nil is the
  zero word and EQS dereferences its operands, so a nil guard would trap
  instead of answering
- docs/examples/log-watcher: seven `+`-on-Text sites corrected to `..`
  (logtail sanitize, mcp header/body/carry assembly, supervisor detections
  line) — the sample was carrying the Haxe habit, and the language reserves
  `+` for arithmetic by doctrine
- wovm CLI takes arguments after the image path (`wovm <file.wob> [args...]`)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:18:01 +02:00
065ac99224 feat: json encode/decode + as, .wob v2 class metadata — log-watcher compiles
docs/examples/log-watcher (1285 lines, 7 files) now compiles clean: 0
diagnostics, a 35KB .wob written. corpus 71/0, woc runtest 565/0, every wovm
unit gate green (both dispatch flavors).

- .wob v2: each class row gains three u32 per-field arrays — the field's NAME
  constant, the CLASS it refers to (or the json-raw marker), and a container
  field's ELEMENT kinds. json is then a runtime service driven by metadata
  instead of per-type generated code. loader/emitter/disassembler/test
  assembler all read and write v2; field-name constants are interned with the
  rest of the pool (interning during serialization silently loses them)
- runtime/src/json.c (new): encode by static kind + object headers + class
  table (nested records need no static knowledge); decode parses and BINDS
  straight into the target class — keys matched to field names, nested objects
  built as the field's class, arrays as a multi of the field's element kind,
  unknown keys skipped, absent keys nil. Malformed input is nil, never a trap
- `as`: `json.decode(text) as T` is the one cast this language has (WO-E403
  for any other `as`, and for a bare json.decode with no target type). Its
  result is `?T`, which is why the decode and the target are one instruction
- json.Value: a reserved type name for a value the source does not inspect —
  the raw JSON slice, kind TEXT, re-emitted verbatim by encode
- docs: 00-wob-format.md is now the v2 reference (class metadata, TRY/ENDTRY,
  the whole builtin surface, WO_T_IO); 08-builtin-surface.md documents the
  text/container builtins, the OS modules with their predeclared records, and
  json's two documented limits (Bool encodes 0/1, floats truncate)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:08:46 +02:00
76dacf6985 feat(compiler): richer field defaults + cross-file consts
log-watcher diagnostics 55 -> 48 (all json-rooted now). corpus 71/0, woc 565/0.

- emit.ml: a field default may now be `nil`, `{}` (a fresh empty container of
  the field's declared type) or `Rec {}` (a record built from its own field
  defaults) — the workload's `st: TailState = TailState {}` and
  `scheduled: map<Text, CronWatch> = {}` shapes
- parser.ml/main.ml: a top-level `const` is visible to every file of its
  module, not just its own file — files in a directory are one module by the
  discovery contract, and the workload reads logtail.wo's `const CHUNK` from
  mcp.wo. A file's own const still wins on a name collision

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:56:55 +02:00
fb91085bdb feat: systems stdlib OS half — fs, time, env, net, proc
log-watcher diagnostics 129 -> 55 (json is what is left: 13 encode sites,
3 `as` parses and their cascade). corpus 71/0, woc 565/0, wovm gates green.

- runtime/src/sysio.c (new): 17 builtins behind the reserved module names —
  fs.exists/list/stat/read_all/read_at/append, time.sleep/local/iso,
  env.get/stopping, net.listen/accept/read/write/close, proc.run. Thin
  blocking libc calls; a failed syscall traps the new WO_T_IO with errno's
  own message, which `try ... catch` is how a program handles
  - record-returning members (fs.stat, time.local, proc.run) take their
    result record's CLASS ID as the last argument, so the VM allocates what
    it fills without knowing any source type name (the err_fill pattern)
  - absence is the zero word: a missing path from fs.stat and an unset
    env.get are nil, not traps
  - env.stopping installs SIGTERM/SIGINT handlers on first use only
- types.ml: predeclared Stat/TimeParts/Proc records (field order is the
  contract with sysio.c) + the stdlib member table (arity, builtin id,
  return type, result record) + stdlib return types in confident_typ
- emit.ml: stdlib member calls lower to their builtin with the record class
  id appended; WO-E406 now means "no such member", not "not linked";
  predeclared records enter the class table only when a program needs them
- emit.ml: fstate carries the method's declared return type, so a tail
  `return []` / `return {}` gets its element kinds; a non-empty list literal
  falls back to its own element type when there is no declared destination
- types.ml: confident_typ chases a container read (`c[i]`), which is what
  makes a switch over a value pulled out of a map resolve; a void `try` arm
  no longer demands its catch arm agree
- corpus: lang-use-stdlib-not-linked now pins WO-E406 for an unknown MEMBER
  (fs.slurp) — the "not linked" premise is gone now that fs is linked

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:54:31 +02:00
ea77fc9d5c feat: map iteration (for k, v in m) + multi element writes
log-watcher parse errors 7 -> 3 (only `as` left); corpus 71/0, woc 565/0.

- wob.h/builtin.c/loader.c: WO_B_MULTI_SET — `m[i] = v` for a multi, dropping
  the element it replaces (the mirror of map_set, which the format doc's sugar
  rule already had; the "no element write" gap is closed)
- ast/parser: `for k, v in m` — the second name binds the value for that key
- types.ml/owner.ml: the two cursors take the map's key and value types; both
  are borrows of what the map owns, so neither is dropped per iteration
- emit.ml: map form lowers to len + key_at/val_at over slot indexes (insertion
  order, cont.h's parallel arrays), same loop skeleton as the multi form

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:46:53 +02:00
e5c5952cdc feat: nil literal + systems-stdlib text/container builtins
log-watcher diagnostics 272 -> 129 (parse errors 7, stdlib-module calls 49,
lowering gaps 72). corpus 71/0, woc runtest 565/0, wovm unit gates green.

- nil (haxe-parity Task 6's literal half): `nil` keyword, Ast.NilLit, lowered
  to the zero word for every `?T` — the representation the format doc already
  fixes ("a nullable field stores exactly what T stores and spells nil as 0"),
  so no boxing, no unbox on read, and every drop plan already skips it.
  Contextual on its destination in both type derivers, like `[]`/`{}`
- 23 new builtins (wob.h ids 16..38, loader arities, builtin.c): len, byte_at,
  print_err, starts_with, ends_with, index_of, last_index_of, substr, trim,
  to_lower, char_of, parse_int, split, split_ws, join, slice, pop, shift,
  sort, reverse, remove, key_at, val_at
  - fresh-Text/fresh-multi results allocate in the VM; `split`/`split_ws` fix
    their element kind (Text), `slice` copies its source's — and COPIES Text
    elements so a slice and its source never both own one value
  - pop/shift hand the element's ownership to the caller; remove drops the
    map's own key and value; key_at/val_at expose slot-ordered enumeration
    (what `for k, v in m` will lower onto)
  - parse_int is optional-shaped: unparseable is 0, `?Int`'s own nil
- obj.c/obj.h: wo_str_alloc (uninitialized Text of known length) so `join`
  builds its result in one allocation instead of one per element
- types.ml/emit.ml: builtin signatures, argument-shape requirements and
  return types for all 23 — the return table is also what classifies a `let`
  holding a fresh Text or multi as owned, so an omission there is a leak

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:43:38 +02:00
2bb39d6b6b feat: try/catch over the trap system (haxe-parity plan 8, Task 5)
VM catch frames + expression-form try/catch in the compiler. Uncaught traps
keep byte-for-byte today's surface. log-watcher parse errors 18 -> 7;
corpus 71/0, woc runtest 565/0, wovm unit gates green (both dispatch flavors).

- wob.h: WOP_TRY (A sBx: push catch frame, handler at pc+sBx) / WOP_ENDTRY;
  WO_B_ERR_FILL builtin (fills the catch record: 0 code, 1 line, 2 method,
  3 msg — the field-order contract with the compiler)
- vm.h/vm.c: catch stack (depth, handler pc, error reg) + the caught error;
  vm_unwind takes a stop depth, so a caught trap kills every frame above the
  catching one exactly as an uncaught trap would, then releases only what the
  try region owned in the catching frame (drop-entry diff against the handler
  pc) and resumes at the handler; RET/RET0 drop the catch frames of the frame
  they leave; TRAPF resumes instead of returning when the trap was caught
- builtin.c: err_fill allocates the method/msg Texts into the record the
  compiler owns, so the pending error never has to outlive the landing
- loader.c: TRY's handler target validated like a jump, error register like
  any register operand; err_fill arity
- lexer/token/ast/parser: `try`/`catch` keywords; `try expr catch (e) expr`
  and `catch (e) { block }`, newline allowed before `catch`; try binds looser
  than every operator, so `try a / b catch (e) 0` catches the division
- types.ml: predeclared `Error` record (merged table only), catch binding,
  arm-type agreement reported only when both arms are confidently typed
- owner.ml: analyze_try — the catch arm is an alternate flow join off the
  entry state, the error record is an owned handler-scope local
- emit.ml: TRY/body/ENDTRY/JMP + handler prologue (NEW Error, err_fill),
  join drops on both arms, `Error` class entry only for programs that catch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:35:36 +02:00
2a98186259 feat(compiler): let-type annotations, container literals, statics, pub(read)
Driving goal: compile docs/examples/log-watcher (1285 lines of .wo).
Diagnostics on that program: 481 -> 379; parse errors 85 -> 18.
tests/corpus via scripts/oop-e2e.sh stays 71 checks / 0 failures.

- ast.ml: `Let.ty` is a full `field_ty` (was a bare name), so `multi Text`,
  `map<K, V>` and `?T` annotate a local; new `ListLit`/`MapLit` expressions;
  `method_decl.is_static`; `field.pub_read`
- parser.ml: let annotations go through parse_field_ty; `[]`/`[a, b]` and
  `{}` literals in expression position; `static const`/`static fn` in class
  bodies (one token of lookahead — `static` stays an identifier);
  `pub(read) field: T`; a `;` ends a statement on its own, so one-line
  guard bodies (`{ skip(...); return; }`) parse
- emit.ml: list/map literals lower to multi_new/map_new + one multi_push per
  element, element kinds from the destination's declared type (WO-E403 with
  no typed destination, same rule multi_new already had); `static fn` gets a
  receiverless method record (arg_cnt = params) and lowers `Cls.fn(args)`
  through emit_direct with no `self`; a static can satisfy no interface
- types.ml: a written `let` annotation is now the authority for the binding's
  type (the only thing that types `[]`/`{}`/`nil`); `static_method_of` +
  static-call return types; method_info.is_static is wired from the AST
- owner.ml: container literals are fresh owned values, elements read in place
  (inherits push()'s open borrowed-element gap, noted in the code)
- plan doc: `Spec:` header line so planboard's lint accepts the plan

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:22:47 +02:00
1ba035397d feat(compiler): iteration 5 Tasks 1-4 — modules, language surface, switch, typedef records + enum variants
- Modules: `use`/`pub`, directory-as-module, per-module symbol resolution (a
  flat first-wins merge silently ran the wrong `pub fn` body), six reserved
  stdlib namespaces typed UNKNOWN-BUT-RESERVED.
- Surface: `and`/`or` (own precedence tier, short-circuit, Bool-only), `${}`
  interpolation desugared at parse time, `const`, break/continue with
  drop-correct exits, do-while, inline-fn rejection.
- switch expr/stmt: required `default` over scalars/Text, arm unification,
  EQ/EQS+JZ lowering, per-arm drop scopes with N-way JOIN-DROP; `default`
  sorted last by a shared lowering order (textual order made arms dead).
- typedef records: structural, same shape = one class entry; `?name: T`
  nullable-by-shape; emit_ctor fills omitted defaults; `type` as field name.
- Enum variants: all-bare unions = int ordinals; any-payload = one class
  entry per variant, tag IS the header class_id (no header field, no format
  bump); exhaustive switch without `default`; arity checked both directions.
- Payload escape modeled as move-out (pointer-kind fields only — a scalar
  escape is a copy); caller reaps owned heap temps passed by borrow: two
  unbounded LSan-blind leaks, 10.5 MB -> 1.5 MB flat over 300k iterations.
- Fixed en route, each with a RED repro: dead E209 builtin-arg check and
  `int_to_text` missing from both types.ml builtin tables (both segfaulted
  wovm), multi-file phantom double-report, emit_ctor's field temp clobbering
  dst in tail position (pre-existing), warnings swallowed without an error.
- Two fenced VM builtins: `int_to_text` (13), `variant_tag` (14).
- 14+565 unit (was 14+401), corpus 71 (was 32) plain and under wovm_asan,
  wovm-test + cli_smoke green. Log-watcher 307 -> 93 diagnostics (85 E101 /
  4 E207 / 1 E208 / 3 W202); the 5 non-E101 residuals await Task 7 grammar.
2026-08-12 14:40:07 +02:00
79605cbb4f feat: milestone 1 complete — .wob emitter, conformance corpus, single binary; GC redesign specced
- `woc` now emits `.wob` that `wovm` runs: emit.ml lowers the typed,
  owner-annotated AST (scope-stack registers with a >64 WO-E401 diagnostic,
  Lua-style call windows, ICALL by slot, dedup const pool, drop maps, line
  tables, implicit terminators); disasm.ml backs `--dump-bc` goldens.
- Ownership lowering consumes the four owner tables verbatim; RESIDUAL is the
  only source of borrow ops, coalesced per operand. Review caught the emitter
  consuming only 2 of owner.ml's 4 residual producers — an assignment-anchored
  aliasing violation ran to exit 0 instead of trapping; fixed, plus a backstop
  raising WO-E404 for any residual region left unconsumed.
- Conformance harness `scripts/oop-e2e.sh` (`just oop-e2e`): four fixture
  kinds with exact outcomes — byte-exact stdout, one WO-E### anchored on
  `error CODE:`, numeric trap code, gc trace. 25 fixtures incl. pricing-demo
  logic, the ownership suite, and DB_STUB's parse-but-trap. `tests/` un-ignored
  so the corpus is actually tracked.
- `woc build` produces a self-contained binary: wovm copy + appended image +
  20-byte trailer, self-exec via /proc/self/exe. Verified relocated outside
  the repo, argless, and against adversarial trailer corruption.
- Milestone 1's five spec criteria all MET (`just oop-accept`). Criterion 3
  closed by WO-E405 — the entry must return `Int`, since program mode already
  says its return value is the exit code — which deletes the leak class
  without adding return-type metadata to the format. `gc/held-cycle` retired:
  an externally-held cycle is not expressible in a post-exit pump.
- New spec: inferred GC + incremental per-shard tri-color mark-sweep, retiring
  `@gc` and reference counting. Story gains iterations 7b (that work) and 9b
  (`@table`, relations, compiler-checked query); `.dev/reference` gains a
  sparse System.Linq checkout. Priority: 5→6→7 (log-watcher) then 7b, 8, 9, 9b.
2026-08-11 19:31:26 +02:00