- developer move; all inbound links repointed (root docs, plan/, plan/compiler/, exploration, superpowers plans+specs, in-progress marker), board's own links re-based one level deeper - prose mentions inside landed plans left as historical records Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 KiB
Learnings — what attempts taught
What the work actually taught, independent of whether it shipped. Recorded so the same wall is not hit twice. Newest first within each section.
Status board: 00-status.md · Rejections: discarded.md
Testing and verification
Plumbed is not enforced — and a status table will happily claim otherwise.
?T passed every lexer, parser, and dump test while its semantics did not
exist: WO-E211/E212/E213 were declared and never emitted, so
fn take_it(b: Box) -> Int { return b.v; } with v: ?Int exited 0. The
plan doc had marked the typechecker ✅. Assert on behavior — did the
diagnostic fire, what was the exit code — never on the presence of plumbing.
(2026-08-10, nullable-types audit.)
A declared error-code constant is not a feature. Ten WO-E2xx constants
existed in types.ml with no emission site anywhere, including
unsatisfied_interface — so structural interface satisfaction was unenforced
while the module's own doc comment claimed the satisfaction set was produced.
The error catalog now lists emitted and reserved codes separately.
A golden test can pass vacuously. The first diagnostic-ordering fixture put both errors in the same pipeline stage, so the collector's insertion order already equalled the required output order — the fixture would have passed with the sort deleted. A fixture must fail when its mechanism is removed; prove that by removing it once. The replacement splits the errors across stages so insertion order is the reverse of output order.
Exit-0-with-wrong-output is the worst failure mode, and only absence-testing
catches it. Two instances in one plan: a class field named on, service, or
policy was silently absorbed by the skip-on-block dispatch (field gone, exit
0, empty stderr), and a dangling backslash at EOF inside a string was swallowed
with no diagnostic. Both were found by review, not by the suite, because no test
asserted that something should appear.
Make memory correctness machine-checked. Test classes given ~130 fields exceed the arena's 1024-byte size-class ceiling and take the malloc path, so any missed free becomes a hard ASan report instead of an invisible slop. Used throughout the VM's drop, RC, cycle-collector, and trap-unwinding tests.
A blind bless absorbs regressions. WOC_BLESS=1 rewrites every
.expected in every stage, not just the fixture you were thinking about. Prefer
hand-editing a predictable golden — then a passing test proves the prediction
— and if you do bless, diff the changed-file list against what you intended.
Analysis and design
"The runtime check will catch it" is false if nothing reaches the runtime.
A double-mut reached through let-bound aliases (let r = bag.items[i]; let s = bag.items[k]; swap(r, s)) escaped the static check and produced no residual
site — so the VM's borrow word, which only guards sites the table names, was
never engaged. Fixed by canonicalizing places through borrow bindings before
asking the aliasing question. Lesson: when a hybrid design defers a check to
runtime, verify the deferral actually lands in the table that drives it.
Removals that look load-bearing may not be. owner.ml's
is_abstract_type call sat in a branch returning Copy — and its else branch
already returned Copy for unknown names, so deleting it was provably
behavior-neutral. Read the fallthrough before assuming a call site matters.
Conservative joins leak. Marking a conditionally-moved value Moved at an
if-join is safe against double-free but drops it from every later drop set, so
the not-moved path leaks — which an ASan gate would have caught only in plan 3.
Normalizing instead (drop at the non-moving branch's end) keeps the table shape
and costs only an earlier death on that path.
Contract notes must live where the consumer will read them. Two obligations
for the bytecode emitter — coalesce borrow guards per operand, and the
conditional-move drop rule — were first disclosed only in agent report files
that plan 3 will never open. They now live in dump.ml's format-contract
comments beside the tables they constrain.
Process and tooling
Check that a new document is actually tracked. docs/plan/oop-vm/ was
swallowed by a blanket docs/plan/ ignore rule, so the error catalog — a
normative compiler↔VM contract both plan tracks cite — existed only on local
disk and appeared in no diff. .gitignore now carves that directory back out.
A status board that covers one track hides the other. The kanban tracked only the four Rust-runtime tracks while the entire OOP track (VM core shipped, compiler front shipped) was invisible on it — so "what is next?" required reading code and ledgers. Hence the six-bucket format and the next-plan pointer at the top of the board.
Prose reports are not a handoff. Agent-written reports and SDD ledgers hold the reasoning, but only files a developer opens by habit — the board, the plan, the format contract — actually transfer it.
Runtime, from the C proving ground
Reference-implement first in C, then port. The C proving ground (phases A–F) hit 859k reads/s and 618k durable commits/s, and found the ack-ordering and fd-ABA bugs the Rust port then avoided entirely. Building the risky thing twice, cheaply first, was faster than building it once carefully.
Budget the collector, don't stop the world. Per-shard heaps plus per-shard cycle-candidate buffers mean no global pause can even be expressed — the worst case is a bounded slice of one shard's tick. Deferring all frees until after the trial-deletion phases removed every dangling-candidate hazard that incremental freeing had introduced.
Validate once at the trust boundary, then trust it. The .wob loader
bounds-checks every index and copies everything out into aligned structures, so
the interpreter's hot loop carries no static checks at all. Only the checks that
cannot be static — the borrow word, runtime-indexed bounds, map keys — remain,
and those always trap rather than corrupt.