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>
Reference counting and Bacon-Rajan trial deletion are gone from the runtime.
Traced (inferred-gc) objects now die only by the collector; owned values keep
deterministic drops exactly as before.
- wo_hdr: rc retired; borrow and the freed 4 bytes become a union — non-traced
values keep the borrow word, traced objects use the 8 bytes as the intrusive
sweep-list link. Header stays exactly 16 bytes. WHITE is now the all-zero
color (allocations born white by memset); WO_F_BUF retired.
- gc.c rewritten: snapshot-at-beginning tri-color mark-sweep. Roots (frames'
gc+owned masks) shaded atomically at cycle start; Yuasa deletion barrier
shades the OLD target of every gcref edge deleted while marking (SETF
overwrites + every owned-death path, which all funnel through wo_drop_kind's
GCREF case); allocations mid-cycle born black. Mark AND sweep budgeted
(WO_GC_BUDGET objects/slice), sweep resumes via a cursor; gray-worklist OOM
degrades to a blacken-all cycle (frees nothing, never wrong). Owned interiors
walked eagerly (single-owner trees), pruned by a per-class may-gcref bit
computed at rt_init (fixpoint over kinds + v2 field_class/field_elem;
conservative when metadata is absent).
- vm.c: safepoints at NEW (the heap-goal trigger), CALL, and backward JMP;
root scan follows vm_unwind's governing-pc convention. Unwind's gc-mask
branch just nulls the register. RC_INC/RC_DEC are accepted as no-ops until
the emitter stops producing them (next commit) — which also deletes the old
RC_DEC-on-nil trap that broke `?Node` gcref field stores.
- main.c pump: post-exit, a rootless cycle frees everything unreachable in
budgeted slices; the trace line moved into wo_gc_slice (one format for pump
and in-program slices). rt_destroy frees traced remnants (trap paths, tests).
- WO_GC_GOAL joins WO_GC_BUDGET/WO_GC_TRACE as an rt-owned knob (default 256
KiB; a tiny goal forces mid-program cycles for testing).
- tests: test_cycle.c rewritten (abandoned cycle freed, rooted cycle survives,
slices bounded, cycle-through-multi, repeated-cycle leak-freedom, and the
spec's load-bearing DELETION-BARRIER test: an object hidden behind a black
object mid-mark must survive). test_rc.c re-pinned to owned drops + the
owned/traced boundary; test_obj.c asserts tracked-white-linked instead of
rc=1.
Verified: make test + test-iso (all suites, ASan/UBSan; test_cycle 42/0,
test_rc 14/0) + cli_smoke; oop-e2e 79/0 (gc corpus traces unchanged: the new
slice math reproduces steps=1/freed=2 and steps=2/freed=4); employee 8/0;
log-watcher 7/0. THE RING RUNS: docs/examples/gc-cycle prints
`ring a -> b -> c -> a`, is reclaimed post-exit (freed=3 remaining=0), is ASan
clean, and survives an in-program cycle while rooted (WO_GC_GOAL=64: mid-run
slice frees 0, post-exit frees 3).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Close the 3 gaps between "builds in the repo" and "installs from a tarball
like Go", so writeonce can ship to other developers.
- VERSION file at repo root single-sources the toolchain version (0.1.0).
- `woc version` -> "writeonce 0.1.0 linux/amd64"; `wovm --version` -> "wovm
0.1.0" (stamped by the Makefile from VERSION; --version handled only in the
plain-wovm path so a built app never shadows its own `version` arg).
- wo.toml `[runtime] wo = ">= X.Y"` is now ENFORCED: woc refuses a project
requiring a newer toolchain than itself (>= and bare version parsed;
unknown operators accepted forward-compatibly). Was parsed-and-ignored.
- woc self-locates wovm: --runtime > [build] runtime > $WO_RUNTIME > a `wovm`
beside the woc binary (Sys.executable_name) > runtime/wovm rel CWD. An
installed woc in <prefix>/bin finds its sibling wovm from any cwd.
- `just dist` (scripts/mkdist.sh) packages writeonce-<ver>-linux-amd64.tar.gz,
Go-shaped (archive root writeonce/, bin/{woc,wovm}, README, VERSION), with a
drift guard asserting VERSION == woc == wovm. dist/ gitignored.
- `just install-accept` (scripts/install-accept.sh) is the gate: extract, PATH,
version, build+run a project from an unrelated cwd, constraint refusal — 6/0.
Verified: install-accept 6/0; woc-test 565/0; wovm suites + cli_smoke;
log-watcher 7/0. Linux-amd64 only (a cross matrix is future work).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- 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>
- `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>
- DB_SCAN(64): class -> multi<Int> of every row id, materialized up
front (the 9b cursor-stability rule: the loop body point-reads, so a
row updated mid-loop cannot disturb iteration)
- DB_GET_FIELD(65): class,id,field -> the field decoded to a fresh VM
value (the out-gate copy); a table-class value IS its row id at
runtime, so this is how a compiled query reads a column, and a ref
field decodes to the target id for navigation
- DB_PROBE(66): class,index,key -> multi<Int> of ids whose first
indexed column equals key (backlink + indexed where)
- wo_val_decode_vm wrapper exposed; dispatch range 61..66, loader
arities, runner mirror updated
- 15 runtime suites green, oop-e2e 73/0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- wo_row_update_field: encode new value, unique re-check against a
shadow BEFORE any mutation (violating update leaves the row
untouched, DB_ERR_UNIQUE), index entries moved old-hash -> new-hash,
old engine value freed; proven by test_table (unique refusal keeps
the row, released key becomes insertable)
- WAL UPDATE record: full-row re-log, replay = replace (remove +
re-create same id); prefix/suffix delta recorded as later
optimization; test_wal replays insert+update to the updated state
- builtins 62 DB_UPDATE_FIELD (cid,id,field,value) and 63 DB_DELETE
(cid,id), commit-before-ack like insert, WO_T_UNIQUE/WO_T_DB/WO_T_IO
mapping; dispatch range 61..63; loader arities; runner mirror
- plan Task 5 marked superseded-in-part with the recorded deviation:
the language surface (reads, queries, row views, delete statement)
is 9b's, where the comprehension design put it -- no interim brace-
select grammar to retire later
- gates: test_table 839/0, test_wal 102/0, 15 suites, oop-e2e 73/0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- .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>
- 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>
- database/src/wal.{c,h}: framed records len|crc32|payload|mark
("WOL1" written last -- no mark, no record), typed-row payloads
walking the class-table kinds (nested records, containers, nil
encodings), little-endian like the loader
- commit order verbatim from the shipped phase-D pattern: RAM apply,
stage, ONE pwrite + ONE fdatasync for the batch, ack after -- group
commit is everything staged riding one sync
- replay decodes straight into engine-owned values (no VM at boot)
and re-enters rows through the choke-point row API, so Task 4's
indexes will rebuild for free; next_id advances past replayed ids
this shard owns (wo_row_create_raw)
- torn tail = short/CRC-fail/no-mark/zero-len: intact prefix applies,
tear dropped whole, wo_wal_open positions AT the tear so the next
commit overwrites it; CRC-valid-but-undecodable = corruption, loud
- wo_wal_check: offline oracle, no engine needed -- the crash
battery's verifier
- test_wal 90/0 ASan+UBSan incl. five crash-battery rounds (fork,
insert/commit/ack-over-pipe, SIGKILL mid-stream: zero acked-but-
missing, zero acked-but-wrong); all runtime suites green, oop-e2e
71/0; binding doc WAL section + CODE-LOGIC + plan Task 2 checked
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- database/src/table.{c,h}: per-shard per-class slabs (256 rows,
malloc'd, never moved -- row addresses stable for 9b's row views),
occupancy bitmap, LIFO slot reuse, open-addressing id hash with
tombstones (ids never 0, never reused)
- field encoding walks the same .wob class-table kinds the VM walks:
scalars raw (WO_NIL_SCALAR passes through), Texts copied to db_text,
owned objects flattened recursively to db_rec, containers
element-wise; GCREF refused at encode (the GC bulkhead, defensively)
- two one-way copy gates: insert copies VM values in, read allocates
fresh VM values out -- no VM pointer in a slab, no slab pointer in
the VM, proven by mutating originals after insert
- id discipline: per table per shard, S+1 step N; owner = (id-1) % N;
N-parametric, runs at N=1 until iteration 8, tested at N=3
- choke points: wo_row_insert/wo_row_remove carry the INDEX HOOK
sites Task 4 attaches to; nothing else mutates storage
- runtime/Makefile links database/src into every wovm + test binary
- test_table 827/0 ASan+UBSan; oop-e2e 71/0; log-watcher 7/0;
binding doc docs/plan/oop-vm/04-db-binding.md; CODE-LOGIC.md beside
the code; plan Task 1 checked off
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- 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>
- 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>
- program mode built the entry's `multi Text` of arguments and never
freed it: the entry only BORROWS a parameter (never a `take`, and
the drop tables never drop one), so the runtime that built the
container owns it
- dropped after the entry returns and after a trap alike -- the
container outlives the unwind; `multi_free` recurses, so the
argument strings go with it
- one site covers both invocation shapes: `self_rc` picks the argv
offset, it does not build a second container
- measured: watch, run and the full MCP mix now report ZERO leaks
under ASan -- the clean baseline the soak (Task 6) reads against
- gates: oop-accept ALL CRITERIA MET, oop-e2e 71/0, wovm-test green,
log-watcher 6/0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
- docs/00-status.md: NEXT PLAN is now iteration 5's strictness half (?T forced
handling, pub(read) writes, using, #if) plus an ASan run over the workload;
iterations 6 and 7 marked landed; a "Landed 2026-08-14" section records what
actually shipped, and a new known-gaps block records what did not — lenient
optionals, unenforced pub(read), the borrowed-Text-into-container hazard,
json's Bool/float limits, net fd lifetime, no ASan over the workload, and no
corpus fixtures for the new surface (by direction: the sample is the test)
- runtime/src/CODE-LOGIC.md: file map, the loader-is-the-only-validator and
traps-never-leak invariants, the catch stack, records the VM fills but cannot
name, class metadata + json, program mode, and where to look when it breaks
- compiler/src/CODE-LOGIC.md: the pipeline, why there are two type derivers and
the stay-silent-when-underivable rule, how contextual values get a
destination, the node-id/label contract between owner.ml and emit.ml, the
four kinds of qualified call, predeclared records, the constant-interning
trap, register discipline, and how to verify a change
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
- 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.
- `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.
- 16 tasks complete: arena, object model, borrow word, containers,
RC + budgeted cycle collector, wob_build, validating loader,
interpreter core (dual dispatch), object opcodes, drop-map unwinding,
builtins + DB_STUB + TRAP, ICALL, wovm CLI + just recipes
- 13 test suites × 2 dispatch flavors (ASan+UBSan) + CLI smoke, all green
- .wob v1 format pinned in src/wob.h + docs/plan/oop-vm/00-wob-format.md
- wo-rt.c reference event-loop preserved for sub-project 2
This is Iteration 2 of the OOP milestone; compiler front (Iteration 3)
is in progress on this branch. They meet at Iteration 4 (emitter+e2e).