writeonce/docs/plan/learnings.md
shoney.arickathil d5c76d1dcf docs: six-bucket status board; recover lost vision doc; stop ignoring docs/
- 00-kanban.md rebuilt: ▶ NEXT PLAN pointer (iteration 4 — emitter, corpus,
  `woc build`) then six buckets — stories, in progress, done, pending,
  discarded, learnings. It tracked only the Rust runtime before, so the whole
  OOP track (wovm shipped, woc Tasks 1-8 shipped) was invisible.
- Board now records iteration 3's known gaps instead of silently owing them:
  `?T` plumbed but unenforced; E205/E201/E203/E204 dead, so structural
  interface satisfaction is unchecked.
- New discarded.md — settled rejections with reasons so they are not
  re-proposed: inheritance, `abstract` newtypes, Money/SKU/Float, Dynamic/cast/
  macro/extern, AOT-to-C, Menhir, shared engine state, external deployer.
- RECOVERED docs/plan/exploration/blue-green-vm/00-vision.md — gone from disk,
  never committed (gitignored path), cited by five docs incl. principle 12.
- Root cause was broader: all seven forward-roadmap plans in
  docs/superpowers/plans/ were untracked and ignored, on one disk only. Rules
  were half-fiction — 33 of 34 exploration files were already tracked, so they
  swallowed only *new* files.
- Dropped the docs ignore rules (exploration, oop-vm, superpowers/plans,
  examples/agent-loop, examples/mcp-think) with a do-not-re-add comment; added
  __pycache__/*.pyc. `tests/` stays ignored but warns that the next plan lands
  the corpus there.
2026-08-10 21:52:59 +02:00

6 KiB
Raw Blame History

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