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

109 lines
6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`](00-kanban.md) · Rejections: [`discarded.md`](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.