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).