Commit graph

48 commits

Author SHA1 Message Date
0c7d0530e9 feat(rt2): spawn_pty + resize — a child that believes it owns a terminal
- posix_openpt/grantpt/unlockpt/ptsname_r (plain libc, no -lutil); child
  setsid + opens the slave as its controlling terminal, initial
  TIOCSWINSZ from the call
- Child.stdin == Child.stdout = the master (caller's copy); the slot
  keeps a private dup so resize survives the caller closing theirs
- proc.resize -> TIOCSWINSZ; refuses by name on a pipe child
- legs: test -t proves a real tty; stty size reads "24 80" then "40 120"
  after a mid-sleep resize; refusal asserted; test_proc 193/0 ASan clean

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 9836c9cd5197153517c054b243cab3b453d130a0)
2026-09-15 01:15:30 +02:00
803ff0b790 feat(rt2): proc.spawn/wait_dl/signal — the streaming child
- a child is fds: Child {id, stdin, stdout, stderr}, driven by the
  existing net verbs (echo leg proves cat round-trip through write_dl/
  read_dl); caller owns the fds, the runtime owns pid + pidfd
- wait_dl parks on the pidfd: code on exit, nil at the deadline with the
  child untouched; one waiter per id, a second refuses by name; stale
  ids refused via a generation counter in the handle
- proc.signal through pidfd_send_signal; actor_die kills the streaming
  children the dying actor owns; dead fibers cannot linger as waiters
- ids 97-107 registered wholesale (wob.h, loader arities, dispatch
  bound); Child + Signal predeclared records in types.ml; unimplemented
  ids trap at the default case until their task lands
- test_proc 168/0 (echo, wait trio, one-waiter refusal, 200-round churn
  fd-flat), suite ASan clean, woc-test green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 9be87f159f1bf9cdd509ceed160e7ea518fde46c)
2026-09-15 01:15:30 +02:00
4180ee09d4 feat(lang42): ceiling, churn, unwind and stop legs
- ceiling: 32 fibers hold live sleepers; the 33rd spawn traps WO_T_IO
  naming the ceiling; destroy sweeps all 32 (waitpid -1 = ECHILD after)
- unwind: a fiber parked on a live child is reaped at main's return and
  the child dies with it (nchildren 0 straight after the call)
- churn: one thousand sequential `true` runs through a bytecode loop —
  fd count flat, every slot released
- stop: SIGTERM from a helper 200 ms into a sleep-10 child answers rc 1
  (STOPPED) with no surviving child
- test_proc 128 pass 0 fail in 2.6 s, suite ASan clean

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 22:19:25 +02:00
5d1c82bbd6 feat(lang42): deadline and output caps refuse by name, shard keeps scheduling
- proc.run_dl reachable: dispatch range extended to id 96 (builtin.c) and
  the loader arity table gains [WO_B_PROC_RUN_DL] = 6 — without both, the
  builtin answered "unknown stdlib builtin" (WO_T_EXPLICIT)
- deadline leg: sleep 10 vs 100 ms deadline traps WO_T_IO naming the
  deadline in ~120 ms; the pid is gone (waitpid -1 = ECHILD) and the fd
  count is flat; a worker fiber completes WHILE main is parked — the
  shard was never blocked
- cap legs: stdout and stderr caps trap naming "cap 1000", child dead
- argv multi carries a drop entry at the run pc: a trapping run frees it
  (LeakSanitizer caught the miss)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 22:19:25 +02:00
258222c3a1 feat(lang42): proc.run parks — pidfd + epoll bundle + child registry
- deadlock proven first: chatty child (200 KB stdout, stderr held open)
  hung the old sequential drain 5.0 s into the alarm, code -1, stdout
  truncated at 8192; the leg demands completion under 4 s
- rework: nonblocking pipe read ends + pidfd_open behind one epoll fd the
  fiber parks on (the _dl retry mould); both pipes drain on readiness, so
  the deadlock is gone structurally — leg passes in 15 ms
- wo_child slot table in wo_vm (32/shard) carries cross-park state; caps
  refuse by name (kill + WO_T_IO), deadline armed via dl_active/dl_at,
  defaults 30 s / 1 MiB / 64 KiB
- WO_B_PROC_RUN_DL = 96 shares the case (per-call deadline_ms/out_cap/
  err_cap; compiler row lands in a later task)
- fib_reap kills a reaped fiber's child; wo_vm_destroy sweeps the table
- raw syscalls for pidfd_open/pidfd_send_signal: glibc 2.35 build floor
  has no wrappers
- all 19 suites green under ASan+UBSan

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 22:19:25 +02:00
b8d9f9f594 feat(lang42): pin proc.run's current contract in test_proc
- new suite runtime/test/test_proc.c (auto-globbed by the Makefile)
- three legs against today's behavior: echo exits 0 with exact stdout,
  false exits 1, a missing command answers 127 (the execvp convention)
- record fields copied out before the vm dies; ASan clean

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 22:19:25 +02:00
4ad24d6381 docs(db2-migrate): close out iteration 12
- crash-before-rename test: a COMPLETE valid migrated temp beside the
  untouched original is discarded and the boot re-migrates — the
  sharpest point on the crash timeline, deterministic, no fault
  injection needed
- story: all six tasks done with commit hashes, all eight criteria met
  with the test that proves each, plus the three deviations from the
  plan and why (transcode over replay, lazy head, poison forces
  transcode)
- CODE-LOGIC: migration section; also corrected limitation 3, which
  still claimed unbounded hot-row chains — iteration 11 closed that
- status board row 12; deploy guide's rollback section gets its real
  answer (rolling back across a migration is a migration backwards:
  expect the refusal, restore the .bak)
- test_wal 5966 pass, 0 fail

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 4bb6ece2531e2123eb958c91d9bef4a6528eab3b)
2026-08-31 21:54:27 +02:00
c6e158c6a1 feat(db2-migrate): the transcode — old log to new shape, record by record
- wo_wal_migrate rewrites the log without touching db state: no id
  maps, no indexes, no keys-resident logic — the new log replays
  through the machinery that already exists and is already tested
- cids remap by name, INCLUDING the ones embedded inside stored owned
  values (an owned value carries a cid on the wire); the embed closure
  guarantees every nested class is shape-unchanged, so only numbers
  move
- surviving fields go to their new slot, deleted fields' values are
  freed, added fields take the kind's zero value straight from
  enc_val(0)
- a delta on a deleted field is SPLICED out: an offset map (old record
  start -> new) rewrites every back pointer, and the dropped delta maps
  to its own target so later deltas step over it
- temp + fsync + rename, compaction's own crash discipline; a stale
  temp is discarded at start; a torn tail bounds the intact prefix
  exactly as replay does
- fixed en route: early `goto corrupt` jumped over initializers, so the
  handler freed uninitialized memory — declarations hoisted above the
  first jump
- six end-to-end tests: add, delete (ASan watches the freed Text),
  reorder with owned fixup, delta splice on a keys-resident chain,
  poison-bites-only-with-records, corrupt input
- test_wal 5951 pass, 0 fail

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit b69092a99206e1dcdf6f2dcdb02146939b0564c6)
2026-08-31 21:54:27 +02:00
df09158b7f feat(db2-migrate): the boot diff — name-keyed, poisons instead of errors
- wo_schema_diff matches classes and fields by NAME, so declaration
  reordering is identity apart from the cid map — the silent
  cid-renumbering hole closes as a side effect
- owned-field references (fclass) compare by the NAME the number
  resolves to, never the number: a raw compare would false-poison
  retype on every pure reorder
- refusals are per-class POISONS carried in the plan, not diff errors:
  a poison bites only when a record of the class is met, so a retyped
  class with no stored rows never blocks a boot
- poison set: retype, same-shape delete+add (a disguised rename, one
  reading destroys a column), vanished class, storage-flag change, and
  the embed closure — any class whose old records carry values of a
  class whose shape changed, iterated to a fixpoint
- identity plans skip the rewrite entirely; a NEW class in the binary
  does not break identity (no records; the head refreshes at the next
  compaction)
- ten verdict tests; test_wal 5778 pass, 0 fail

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 63a063b822af381a10c2e599c58cf3455d9c5bf7)
2026-08-31 21:54:27 +02:00
f07295b3c0 feat(db2-migrate): WO_WAL_SCHEMA — the log states the shape that wrote it
- new record kind 5: class and field NAMES, kinds and the two
  encoding-relevant metadata words (field_class, field_elem), CRC-framed
  like every record. Index layout deliberately absent: indexes rebuild
  from rows at boot and never touch record bytes
- names are byte pointers, not constant-pool indices — the database
  layer never sees the module's consts, so the runtime resolves them
  once; a decoded schema owns a private copy of its bytes
- wo_wal_set_schema adopts the compiled schema; wo_wal_ensure_schema
  writes it as a fresh log's first record; compaction writes it at the
  head of every replacement, which is how a legacy log becomes
  self-describing without a migration step of its own
- apply_record skips it BEFORE reading cid/id (its class count would be
  misread as a cid and bounds-refused); replay does not count it
- schema unset = byte-for-byte today's behaviour: all 5700 prior
  assertions pass untouched; four new tests cover roundtrip, fresh-log
  head, legacy adoption via compaction, and absent/empty files
- test_wal 5743 pass, 0 fail

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit ba8519fa5e39c6ed6a3504d39e5a372f8decd045)
2026-08-31 21:54:27 +02:00
68dd3d88b8 test(db2-chain): cover flattening, and drop a ceiling no input could reach
- flattened row image is WO_WAL_UPDATE, not WO_WAL_INSERT: the row's
  original INSERT is already in a live log, so a second one for the same
  id is a duplicate replay refuses as corruption. INSERT is right only
  for compaction, which builds a fresh log
- remove WO_CKPT_MAX_GARBAGE: with the absolute term at 64 MiB, garbage
  large enough to reach a 256 MiB ceiling has already tripped it, so the
  branch was unreachable. Postgres needs both constants because it
  thresholds on tuples with its pair at opposite ends; this thresholds
  on bytes, where one constant does both jobs
- test_delta_chain_flattens_at_k: chain depth stays <= WO_DELTA_MAX_HOPS
  across 2K+2 updates, and a reset is observed
- test_delta_chain_flatten_replays: a flattened chain replays correctly
- test_keys_resident_indexed_across_flatten: a delta on an indexed
  column composes with flattening, checked at every step across the
  bound and after restart. Found no product defect
- test_should_compact_absolute_and_ceiling: pins the absolute term, the
  boundary just under it, and the small-log case the ratio still governs
- test_wal 5700 pass / 0 fail; wovm-test and woc-test green

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit f93b5d9db753305c297e868d977670e6d703684c)
2026-08-30 20:38:03 +02:00
e37a10b70d fix(db2-delta): borrow the pending re-point, not the stale durable offset
- wo_row_borrow's keys arm folded at hget()'s DURABLE offset even
  when an earlier update in the same drain had only a PENDING
  re-point
- idx_remove_row then hashed the pre-first-update value, found no
  matching bucket entry (already moved by the earlier update), and
  idx_add_row added a second one — N same-drain updates leaked N-1
  entries, unbounded, nothing reclaims them but a restart
- now prefers wo_wal_repoint_offset1() over the durable offset, same
  as back_off already does, closing it for every borrow
- new test: 5 updates to one row in one drain, assert exactly one
  index entry — fails (5) before the fix, passes (1) after

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit d4b12d1908e574419c7af2411e52d02623f7e5b7)
2026-08-30 20:38:03 +02:00
e08c26309a feat(db2-delta): lift the resident:keys refusal, prove it end to end
- loader.c: delete the INCOMPLETE-update BAIL; durable:false +
  resident:keys stays refused (nowhere to read from)
- table.c: root-cause fix for the Text-index gap — a keys-resident
  borrow now holds ENGINE values, matching wo_row_ptr's contract
  (table.h's "no VM pointer" doctrine), not a VM-decoded row. Fixes
  idx_hash/idx_cols_equal/wo_idx_probe AND db.c's GET_FIELD/PROBE
  arms with one change; reproduced pre-fix as an ASan
  heap-buffer-overflow
- docs/examples/residency: Product is genuinely resident:keys;
  residency-accept.sh's refusal leg replaced by proving the program
  runs and stock survives a restart (11/0)
- test_wal.c: oracle test drives resident:all and resident:keys
  through the same update sequence and asserts identical rows;
  Text-indexed-update test catches the representation bug; five
  pre-existing tests corrected to the fixed contract (4746/0)
- story, README, status board, CODE-LOGIC.md updated; three known
  limitations documented: mid-drain stale reads, O(N^2) replay in
  chain length, compaction blind to per-row chain length

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit b87c68f950f01aa5e572fbb86a0f374adc83d813)
2026-08-30 20:37:49 +02:00
f138ac0abe feat(db2-delta): replay and compaction fold delta chains
- apply_delta: DELTA replay arm — fold pre-delta state via back_off,
  overlay the field, remove-then-recreate so indexes stay correct
- apply_record/replay loop: dispatch DELTA to apply_delta, drop its
  payload back to the log same as INSERT/UPDATE
- stage_flattened_row: compaction's delta-chain path — fold + re-encode
  as one fresh INSERT instead of copying the chain
- wo_wal_compact: peek the row's current record kind, flatten deltas,
  keep the byte-for-byte copy for chains already at length zero
- test_wal: three new tests — chain-of-three replay incl. secondary
  index, compaction flattens to chain length zero (asserts the record
  is a full row, not a delta), and the commit-before-repoint crash
  window replays the update without ever re-pointing the map

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7e4ae70d7beb2a2e396a002184bc06f41253decb)
2026-08-30 20:37:27 +02:00
1f402ae4bb fix(db2-delta): close the unique-shadow-check's same-drain blind spot
- table.c: unique shadow-check's candidate lookup now checks
  wo_wal_repoint_offset1 before the durable wo_row_offset1, same as
  back_off — a candidate updated earlier in the SAME uncommitted drain
  was folded from its stale pre-update offset, letting a real @unique
  clash through and committing a duplicate
- the offset-only substitution alone was NOT enough (verified): the
  candidate must be FOLDED to compare values, and folding a pending
  offset via pread saw "no record" (bytes still only in the staging
  buffer), so the clash was still missed, just for a different reason
- wal.c: wo_wal_fold_row_at now reads a hop inside the currently-staged
  region from `w->buf` (new scan_record_staged, scan_record's framing
  over memory) instead of pread; every durable hop, and every existing
  caller, is unchanged
- test_wal.c: two updates in one drain where the second collides with
  the first's new unique value; must be refused. Verified failing
  against the prior commit, and still failing with only the offset
  substitution, before the fold fix; passing with both in place

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit c049ab92570cfba4d12a018884a20b25a8916727)
2026-08-30 20:37:27 +02:00
3525435eb5 feat(db2-delta): wire the request path, defer re-point to the barrier
- db.c: guard both WO_B_DB_UPDATE_FIELD arms on keys-resident tables —
  wo_wal_append_update read a NULL wo_row_ptr there; a live crash, fixed
- ruling override on Task 3: row_apply_field_keys no longer commits or
  moves the id map — stages the delta, does the index swap (RAM apply,
  unconditional past the shadow-check; a stage failure past that point
  is now fatal, like insert). Commit/re-point move to the caller,
  mirroring insert. table.c's WAL commit removal is this ruling, not a
  regression
- offset passed back via caller-side wo_wal_next_offset(), insert's
  koff pattern
- inline arm commits then re-points; request arm records
  wo_wal_pend_repoint (own list/name — a drop and a re-point differ),
  flushed by wo_db_flush_drops after the barrier
- back_off checks the pending re-point before the durable offset, else
  a second update in one drain skips the first delta; verified failing
  this way, passing after
- wal.c: fixed a stale comment — keys-resident updates CAN reach
  wo_wal_append_update's caller now, they just never call it
- test_wal.c: 2 tests updated for the new contract; new test drives 2
  same-row updates via wo_row_update_field_slot in one uncommitted
  "drain", checks the value and delta 2's on-disk back-pointer

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 4d13bcebfe51936ba8c608dd9e791c784e5b8983)
2026-08-30 20:37:27 +02:00
d492d1fefb fix(db2-delta): unique shadow-check gets its own buffer, not r's
- row_apply_field_keys's shadow-check borrowed candidates via
  wo_row_borrow, which shares ONE per-table scratch with the row already
  borrowed for the update — every candidate borrow returned NULL, clash
  was always false, `@unique` silently accepted duplicates on update
- idx_add_row's own internal check has the identical defect at the same
  call site; discarding its result is now actually safe, since the fixed
  shadow-check clears uniqueness before it ever runs
- fix: extracted keys_fold_into (fold+decode) out of wo_row_borrow so it
  can target a throwaway per-call buffer instead of t->scratch; the
  shadow-check probes candidates into that buffer — r is never
  released-and-reborrowed (r IS t->scratch; that would overwrite it)
- wo_row_borrow itself is behavior-preserving: same checks, same order,
  same messages, just factored
- test_wal.c: new test — genuine @unique index, update collides with an
  existing row, asserts refusal (DB_ERR_UNIQUE) and both rows untouched;
  verified failing (update wrongly succeeded) pre-fix, passing after

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 409186da51fec0042d8d1e3dcf9f69f704937823)
2026-08-30 20:37:27 +02:00
7cb4bcf0c3 feat(db2-delta): keys-resident updates append, indexes follow
- table.c: wo_row_update_field/_slot no longer refuse `resident: keys`,
  both converge on one new static row_apply_field_keys
- borrows (folds), shadow-checks uniqueness, appends the delta with the
  row's current offset as back-pointer, commits, THEN idx_remove_row +
  idx_add_row + wo_row_set_offset — failure through commit leaves the
  row's offset and index untouched
- nv decoded to a VM value before touching the materialised copy, since
  wo_row_release drops every slot through the runtime, not db_val_free
- borrow released on every exit, including every failure arm
- resident: all path (row_apply_field_slot) byte-for-byte unchanged;
  wal.c untouched — Tasks 1/2 already expose everything needed
- test_wal.c: plain field update read back, and an indexed scalar
  column updated then found via wo_idx_probe by its new value, gone
  from its old — both verified failing pre-implementation, passing after
- concern: idx_hash/idx_cols_equal/wo_idx_probe cast Text slots to
  db_text* unconditionally; a keys-resident borrow decodes Text to a VM
  wo_str* (different layout) — pre-existing, left untouched; tests use
  a scalar index to sidestep it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 89c56a13ed9f4abd082bfcabb46f46bb53d39faa)
2026-08-30 20:37:27 +02:00
b758f2978d fix(db2-delta): fold's cycle guard checks direction, not step count
- wal.c: wo_wal_fold_row_at now refuses any delta back-pointer that
  does not point strictly earlier than the record naming it
  (back_off >= cur), instead of capping total hops at off/13+1
- this is the real invariant, not a proxy for it: a step-count bound
  lets a forward-pointing back-pointer through in one hop whenever it
  happens to land on a genuine record, returning a plausible-but-wrong
  row instead of refusing it
- removes the 13-byte-record magic number entirely; no arithmetic
  tied to record framing remains in the guard
- wal.h: docblock updated to describe the direction invariant
- test_wal.c: two new tests — self-pointing back-pointer (boundary
  case, back_off == cur) and forward-pointing back-pointer to a real
  future record for the same row (the actual gap: verified failing
  against the old step-count guard, passing after the fix)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 173dbf28a42d45a8c7f9fe430355f61f70c81935)
2026-08-30 20:37:27 +02:00
4f3f71e003 feat(db2-delta): fold a delta chain, route reads through it
- wal.h/wal.c: wo_wal_fold_row_at — THE fold. Walks BACKWARD from an
  offset through WO_WAL_DELTA records, remembering the first value
  seen per field index (newest wins, since newest is seen first),
  stops at the first INSERT/UPDATE, decodes it, overlays resolved
  fields. Returns ENGINE-owned values so reads, replay, and
  compaction (Tasks 3/5) can all build on the same output.
- Cycle guard: caps the walk at what the log up to the starting
  offset could possibly hold (13 = scan_record's own record-size
  floor), so a corrupt or malicious back-pointer fails loudly
  instead of spinning.
- table.c: wo_row_borrow's keys arm now calls the fold instead of
  wo_wal_read_row_at directly, then VM-decodes the result — same
  two-stage pattern wo_wal_read_row_at used internally. Per-table
  scratch, scratch_busy nested-borrow refusal, and the cid/id
  identity check all preserved unchanged.
- resident: all path (wo_row_ptr) untouched.
- test_wal.c: two new tests — deltas on two different fields (changed
  fields take the new value, the untouched field keeps its original)
  and two deltas on the SAME field (the newer wins, pinning direction
  — a reversed fold would pass with the older value instead).
  Verified failing pre-implementation (wo_row_borrow returned NULL
  since a delta record isn't INSERT/UPDATE) and passing after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit a60231cde1d49d74743cedbd134d2da11158b70b)
2026-08-30 20:37:27 +02:00
b860823dad fix(db2-delta): make delta test detect a field_idx/back_off transposition
- review finding: field_idx=0 and back_off=0 (fresh WAL, offset 0) meant
  a u32/u64 swap of these two values wrote identical zero bytes either
  way — undetectable by the prior assertions
- test_delta_record: new dedicated 3-scalar-field class (not shared
  KEYS_CLASSES) so field_idx can be a nonzero, fixed-8-byte value without
  a Text field's variable-length encoding complicating the fixed body
  size assertion
- stage+commit a filler row first so the target row's insert record (the
  delta's back-pointer) lands at a nonzero offset, not the WAL's initial 0
- delta now targets field_idx=2 with back_off=base_off, both nonzero and
  distinct from each other and from class_id=0
- class_id stays 0: this fixture registers exactly one class, so there is
  no other value to give it without an unused second class purely to
  shift an index
- verified live: temporarily swapped the field_idx/back_off wput calls in
  wal.c, confirmed test_wal now fails (fidx==49 want 2, back==2 want 49),
  then reverted — wal.c diff is a no-op, only the test changed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 20ba0965e15e200641b8b63893a2f238a0279fba)
2026-08-30 20:37:27 +02:00
f1cf2d2f85 feat(db2-delta): WAL delta record kind and encoder
- enum: add WO_WAL_DELTA = 4, existing 1/2/3 untouched (on-disk logs)
- wal.h: document kind 4's payload shape in the format docblock
- wal.h: declare wo_wal_append_delta(w, db, class_id, id, field_idx,
  back_off, value) — back-pointer taken as a parameter, not looked up,
  keeping the encoder ignorant of table/map state
- wal.c: implement it, modeled on wo_wal_append_insert's shape —
  wput_u8/u32/u64 the header fields, enc_val the one field, stage()
- test_wal.c: new test_delta_record — stages a delta after an insert,
  commits, then preads the raw record and asserts kind/class/id/
  field_idx/back-pointer/value all round-trip; registered in main()
- nothing reads deltas back yet — decode/apply is a later task

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9c6f832c0534a59e644c53b7cd2850581da12159)
2026-08-30 20:37:27 +02:00
7b39da7eb6 fix(db2-keys): a logged delete must replay on a keys-resident table
- wo_row_remove's keys arm borrows the row from the log to find its
  index entries, and a borrow reads through db->rt->wal. At boot that
  pointer is not wired yet: main.c replays first (main.c:226) and
  assigns rt.wal afterwards (main.c:268)
- so the borrow found no log, the remove failed, and replay reported a
  valid tombstone as CORRUPTION. An UPDATE record would have failed the
  same way, since replay applies it as remove-then-recreate
- replay now lends the runtime a read-only view over the fd it already
  has open, for the replay's duration only, and restores what was there
- broken by the delete fix in 76b8fd9 — deletes worked in-process but
  their tombstones broke the next boot. Unreachable in production only
  because the loader still refuses the annotation
- pinned by test_keys_resident_delete_then_replay, verified failing
  against the unfixed code (2 failures) and clean with it
- found by asking whether the read-modify-append plan was ready, not by
  a gate

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit dc25462461b9f79d70c803f7174adc90fa16c90e)
2026-08-30 20:37:27 +02:00
7ad52937b2 fix(db2-keys): delete on a keys-resident table was memory corruption
- wo_row_remove read the id map's value as a slot, but on a keys table
  that value is a LOG OFFSET (hput(t, id, wal_off + 1)). slot_row does
  no bounds check, so a delete indexed t->slabs[] with a byte offset and
  then called db_val_free on whatever it landed on — arbitrary frees,
  not a wrong answer
- keys tables now take their own arm: no slab slot, no bitmap bit, no
  free-list entry to return. The index hook needs the row's values, so
  the row is borrowed from the log for exactly that long
- wo_row_ptr carried the same trap and is public. It cannot refuse keys
  tables outright (insert legitimately calls it while the map still
  holds a slot), so it now detects the offset case — index past the
  slabs, or bitmap bit clear — and returns NULL. Callers all handle NULL
- test_keys_resident_delete pins it; it SEGVs against the old code,
  verified by reverting the fix rather than assumed
- found while auditing every hget() reader before narrowing the loader
  refusal to allow benchmarking. The refusal was justified in the docs
  by "updates are unimplemented" while actually standing in front of
  this too: a guard whose stated reason is narrower than its real one
  gets removed by someone who believes the stated reason

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 76b8fd944af9ed062467bdf9ab93c2e96dd198cf)
2026-08-30 20:37:27 +02:00
533fc5294f feat(db2-keys): rewire remaining readers, survive compaction
- wo_row_read and the @unique shadow probe go through borrow/release;
  release runs before every exit, including wo_row_read's early return
- updates on a keys table refused explicitly in wo_row_update_field and
  the slot variant: no slab slot to mutate, and writing the borrow's
  scratch would discard the write silently. Needs read-modify-append
- compaction walked the bitmap, which a keys row has no bit in — every
  such row would have been dropped from the new log. Now walks
  wo_row_next_id and re-points each row to where it lands
- moves records byte-for-byte (copy_record) rather than decoding: a
  borrowed row holds VM values, enc_val expects engine values, and ASan
  caught that mismatch as a 4294967292-byte memcpy
- wo_row_set_offset updates a value in place and never rehashes, so a
  wo_row_next_id cursor stays valid while compaction re-points
- a compaction that fails after moving rows is fatal: the map would name
  an unlinked temp file, and the intact log replays correctly
- test_keys_resident_survives_compaction pins both failure modes; rows
  rewrite in hash order so offsets really move
- loader still refuses resident: keys — updates are not implemented

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit f606fc9b76d983cac2b348f03f7d4f01433cd905)
2026-08-30 20:36:31 +02:00
e16d4896f8 feat(db2-keys): inserts and boot — payload dropped after the barrier
databasev2 2, task 5c. The write and boot halves. Still not exposed: the
loader refuses `resident: keys` until 5d rewires the readers.

GROUP COMMIT FORCED THE DESIGN. A keys-resident payload can only be dropped
once its record is durable, but databasev2 4 deferred the barrier to the drain
— so at append time the bytes are still in the staging buffer and the recorded
offset would pread ZEROS. Dropping at append would have produced rows that
read as garbage, intermittently, only under multi-shard load.

So the drop is recorded, not performed:

- wo_wal gains a pending-drop list, the same shape as the drain's held replies
  and for the same reason
- both write paths take the offset BEFORE the append (wo_wal_next_offset) and
  record it; the inline path flushes right after its own commit, the request
  path's flush runs in the drain immediately after the barrier
- if the process dies before the barrier the list dies with it, which is
  correct: nothing was dropped and nothing was lost
- an out-of-memory pend is ignored on purpose — the row simply stays resident,
  which is safe

Boot: replay now leaves a keys-resident table pointing at the LOG. Each record
is applied normally, so indexes and uniqueness are built exactly as for any
other table, and the payload is then dropped with THAT record's offset. For an
update the later record wins, because each apply overwrites the map in order —
the rule replay already follows.

Tests: the round trip (insert, commit, drop, read back with Text intact) and
now BOOT — a fresh wo_db replays the store and every row materialises from the
log, count intact, nothing in a slab.

Verified: just wovm-test — 36 suites 0 fail, test_wal 4301 pass, cli_smoke OK.

REMAINING (5d), and precise: every reader still goes through wo_row_ptr, which
for a keys table would index a freed slot. The scans in db.c walk the BITMAP,
and a keys table's bitmap is empty by construction — so a query over one would
today return no rows at all. That, FK restrict, and the @unique shadow are 5d,
and the loader refusal stays until they land.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 08abd09bf88918f2582e74713dc7903beb8aaeb8)
2026-08-30 20:36:31 +02:00
7cc80405dd feat(db2-keys): storage — drop the payload, read it back from the log
databasev2 2, task 5c step 2. The storage half the accessor was left waiting
for. Not yet wired into insert (that and 5d remain), and the loader still
refuses `resident: keys`, so nothing is exposed to a program yet.

- wo_db gains an `rt` back-pointer, set in main.c beside VM.rt.db. wo_rt
  already carries `db` and `wal` as opaque handles, so this closes the loop
  and a borrow can reach the log WITHOUT threading a wal pointer through
  eleven call sites — which is the whole reason 5c is one accessor
- wo_row_drop_payload: the operation the plan recorded as MISSING. Frees the
  slot and its engine-owned values, then re-points the id map at the record's
  log offset (off + 1, reusing the same 0-is-empty trick as slot + 1). It
  deliberately does NOT touch the secondary indexes (they store row ids, so
  they stay correct), does NOT decrement count (the row is still live, only
  its backing moved), and does NOT remove the id (that is how it is found)
- wo_row_borrow materialises for a keys table: reads the offset from the id
  map, calls 5b's wo_wal_read_row_at into the per-table scratch, and checks
  the record actually holds the expected class and id — a compaction that
  moved records without rebuilding the map lands exactly there, which is the
  obligation recorded at wo_wal_compact
- fully-resident tables keep today's path and pay one predicate

A REAL BUG, exposed the first time the path was used: wo_row_release freed the
materialised values with the ENGINE's allocator. They are VM values —
wo_wal_read_row_at is the out-gate and always copies — so ASan reported a
bad-free immediately. It now drops them through the runtime. That stub was
written in 5c step 1 for a path that did not exist yet.

Recorded while implementing: wo_wal_next_offset's contract says to trust an
offset "only after the matching commit returns 0". Group commit (databasev2 4)
defers that barrier to the drain, so db.c can no longer check inline — but part
A also made a failed commit FATAL, so no execution can record an offset whose
record never became durable. Same guarantee, different mechanism.

Test: a heap-valued row is inserted, committed, has its payload dropped, and is
read back out of the log with its Text intact; count is unchanged (still live);
and a second borrow succeeds, which fails if release did not clear the scratch.

Verified: just wovm-test — 36 suites 0 fail, test_wal 4273 pass, cli_smoke OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 125bd09218d616b2a16b140de770d3f38b45f0ac)
2026-08-30 20:36:31 +02:00
02b4b13a52 Merge master into db-residency-doctrine — and close the two half-exposed features
The branch was 17 ahead / 25 behind with 11 conflicting files, and drifting
further: db.c had been rewritten twice on master since (group commit, then
compaction). Resolved rather than rebased so both histories stay legible.

Conflicts, and how each was settled:

- db.c: BOTH semantics kept. Master's fatal path and compaction check now sit
  behind the branch's `table_is_durable` predicate, in all three inline arms —
  a volatile table reaches neither the barrier nor the compaction check
- db-bench sample: every mode from both sides (growth, growth-verify, randread,
  replayseed, wmix) and ONE `boot` mode, which both sides had added
  independently
- db-bench.py: all six legs kept. Both sides had also grown the same
  WAL-size helper under different names; collapsed into one
- perf-targets: the branch's §5 (RAM ceiling) then master's §6/§7 — master's
  numbering had already assumed a §5 it did not have
- story frontmatter: master's `status` (the landing truth) plus the branch's
  `readiness` axis. 03 would have read `done` + `refine`, which is a
  contradiction — it was brainstormed and landed on master, so `ready`
- board: both standup blocks newest-first; master's chain rows (a superset);
  the branch's databasev2 1-2 rows with master's 3-4. Fixed a stray `|` in
  master's row 3
- baseline: master's, then REGENERATED from a full campaign — 143 metrics,
  132 checks, 0 failures with both sides' legs present

TWO HALF-EXPOSED FEATURES FIXED, because the merge rule is that master gets
no feature that is honoured in name only:

- `resident: keys` PARSED, set a .wob flag, and did nothing: rows stayed fully
  resident. A developer could declare a 120 GB table keys-resident, watch it
  compile, and be OOM-killed. The loader now REFUSES it with a message naming
  what to write instead, until tasks 5c/5d land. The compiler still parses it
  and its AST golden still passes, so the grammar work stays tested
- `durable: false` was honoured ONLY on the inline path. wo_db_exec_req had no
  guard at all, so a volatile table written from an actor on a worker shard
  would still be logged — precisely porch's session-table case, and precisely
  what iteration 2 exists to provide. All three request-path arms now carry the
  same predicate. Found by reading the merged code, not by a test: the obvious
  probe runs main() on the primary and therefore only exercises the inline path

Verified on the merged tree: wovm-test 0, woc-test 0, oop-e2e 122/0,
residency-accept 8/0, db-bench 132/0, linkcheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 10:14:25 +02:00
d432bc5301 test(wal): kill -9 DURING compaction — 40 rounds, mutation-proven — T4
databasev2 3, task 4. The plan called this the riskiest task because a race
can pass by luck, so it is argued with mutants rather than green runs.

The battery: a forked child inserts, acks, deletes the oldest so HISTORY
grows while the live set stays ~17, and compacts every 24 iterations. The
parent SIGKILLs at varied instants so kills land before, inside and after
rewrites, then replays and checks the ACKED LIVE SET. The existing
battery's "records >= acks" oracle cannot be reused: collapsing history is
exactly what compaction is for.

A REAL DEFECT IN MY FIRST VERSION, found by the failures and fixed in the
TEST, not by weakening it:

- the child acked deletes AFTER committing them, so a kill in between left
  the row legitimately gone on disk while the last ack still said
  "inserted" — the parent then demanded a row the engine was right to
  remove. Symptom was an acked insert missing near the end of the stream,
  ~1 run in 3
- deletes now announce INTENT BEFORE committing, so such a row's fate is
  simply UNKNOWN to the parent, which is the honest thing to assert. Every
  acked insert never marked for deletion must still be present with its
  acked value
- the stale-temp assertion was also wrong: it checked for absence after
  wo_wal_replay, which never opens the WAL. The guarantee is "removed AT
  OPEN", so the test now opens and then asserts. A temp surviving a kill
  is expected debris, not a defect

Proven to have teeth, which matters because assertions were softened:

- against the design's rejected alternative (in-place rewrite instead of
  the atomic rename) it fails EVERY run, reporting log_records=0 — the
  kill landed mid-copy and destroyed the log. That is the corruption
  rename exists to prevent
- on correct code: 10 consecutive runs x 40 rounds clean, plus the suite

Also carried the log's PREALLOCATION to the replacement. The WAL is
preallocated so appends never extend the file, which is what lets
fdatasync alone be the ack barrier; a replacement opened with prealloc 0
silently changes that property and the zero-padded tail the scan relies
on. Stated honestly: this is hygiene making the replacement equivalent to
what open() would have produced — I could NOT prove it was the cause of
the observed loss, and the ack race above explains it.

Verified: just wovm-test — 36 suites 0 fail, test_wal 760 pass, cli_smoke OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 05:55:58 +02:00
aa89fb6c97 feat(db): the checkpoint trigger, and compaction is wired to BOTH write paths — T3
databasev2 3, task 3.

- wo_wal_should_compact is a PURE decision (used bytes, last compaction's
  measured output, floor, ratio) so it is testable without a store —
  which is the only way a policy like this gets tested at all. Denominator
  is the last compaction's real output, not an estimate of the live set:
  estimating would mean estimating Text
- 8 boundary assertions incl. "exactly 3x is not MORE than 3x" and a zero
  ratio disabling the policy rather than dividing by nothing
- MUTATION-TESTED instead of observing RED: implementation and test were
  written together, so removing the floor check was verified to fail
  exactly the two floor assertions. Equivalent evidence, stated plainly
- WO_CHECKPOINT_BYTES / WO_CHECKPOINT_RATIO at boot beside WO_MAILBOX.
  The knobs are what make the policy testable — a gate sets a tiny floor
  and forces compaction in a few writes instead of megabytes
- NO timer, per the spec: Postgres' CheckPointTimeout bounds loss from
  unflushed buffers; our records are durable at commit and an idle log
  does not grow
- the ordering rule is now asserted, not trusted: a test stages a record,
  requests compaction, and requires REFUSAL with the log untouched and
  the staged record still committable afterwards

FOUND AND FIXED a gap in my own wiring. The plan said to call the check
"after the drain's barrier", and I did — but a statement running ON the
owner shard never enters that drain, so WO_SHARDS=1 never compacted and
its log grew forever: measured 536086 bytes where the multi-shard run
held 446024. Now checked after the inline path's commit too (db.c
maybe_compact), where the buffer is equally empty. WO_SHARDS=1 went
536086 -> 260657 bytes. For a checkpoint this mattered more than part A's
equivalent gap: an unbounded log is an operational failure, not just lost
throughput.

Also corrected a measurement of my own: multi-shard logs looked unbounded
(448KB -> 1013KB -> 1647KB across 8k/24k/48k updates). They are not.
Instrumentation showed compaction ran 25 times with zero failures, each
writing MORE than the last, because the live set genuinely grows — wmix's
hist_dump and done-markers are themselves durable inserts. Final log
1631040 against a last compaction of 866432 is a ratio of 1.88, just under
the 2x threshold: the policy holding exactly.

Replies are released BEFORE compaction runs, deliberately: their records
are already durable, and holding them across a stop-the-world rewrite
would add its full duration to their latency for nothing.

Verified: wovm-test 36 suites 0 fail, test_wal 360 pass; db-bench-quick
crash.s1/crash.sN and both restart legs green, and part A still batches
(sN mean 4.16, peak 30).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 18:43:44 +02:00
d7dde018ec feat(wal): a stale compaction temp is removed at open — T2
databasev2 3, task 2.

- wo_wal_open removes `<log>.compact` before reading anything. The only
  way one exists is a crash before the rename, which means its records
  were never authoritative
- deleted rather than ignored, deliberately: a file full of well-formed
  records sitting beside the log is exactly what a future reader mistakes
  for data

Test uses PLAUSIBLE content, not garbage — a byte copy of a real log —
because garbage would be rejected by the CRC anyway and would prove
nothing. It asserts the temp is present before the open, gone after, and
that the live log still replays to exactly what it said.

RED was an assertion failure (`access(tmp, F_OK) != 0` unmet), not a
compile error, so the test was proven to exercise the behaviour before the
behaviour existed.

Verified: just wovm-test — 36 suites 0 fail, test_wal 340 pass, cli_smoke OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 18:01:52 +02:00
0f652dd93f feat(wal): wo_wal_compact — rewrite the log, swap it in with rename — T1
databasev2 3, task 1.

- walks each class's live rows via the bitmap-over-slabs pattern db.c
  already uses in three places, appending one INSERT per live row through
  the EXISTING append path. No second encoder, no new format, and ids are
  preserved exactly because wo_wal_append_insert takes the id and reads
  the row from the store
- FLUSHES EVERY 256 RECORDS rather than staging the whole store: stage()
  grows the staging buffer by doubling and never shrinks it, so a
  one-buffer dump would hold the entire store in RAM on top of the store
  — the unbounded growth databasev2 1 identified as how this engine dies
- the switch, in order: fsync the temp file, rename over the live path,
  fsync the PARENT DIRECTORY (rename's atomicity is in-kernel; the
  directory entry is not durable until the parent is synced — Postgres
  does the same for the same reason), then reopen the descriptor, because
  the old one refers to an unlinked inode
- REFUSES when anything is staged: those records would land in a file
  about to be replaced. The caller-side guard is task 3; this is the
  backstop
- a failure leaves the ORIGINAL log intact and usable and returns -1. A
  failed checkpoint is a missed optimisation, not a durability event, so
  it deliberately does NOT take databasev2 4's fatal path
- records the bytes written, so task 3's trigger can compare against a
  measured denominator instead of estimating the live set (which would
  mean estimating Text)

Recovery is untouched — the result is an ordinary log in the ordinary
grammar, replayed from byte 0. Crash safety comes from rename, not from
code of ours.

Test asserts BOTH halves, on purpose:

- the log shrinks: 43 records (3 inserts + 40 updates of the SAME row, so
  history grows while the live set does not) -> 3 records, fewer bytes
- AND a fresh replay reproduces the store: every id present, and row 0
  carries the 40th update's value rather than its original. "It got
  shorter" is also true of a truncating bug, so the replay comparison is
  what actually proves it
- and the WAL stays usable after the swap: a further append lands after
  the compacted records, giving 4 on the next check

Verified: just wovm-test — 36 suites 0 fail, test_wal 315 pass (was 165),
cli_smoke OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 17:55:43 +02:00
ceea00e0b6 feat(wal): a failed barrier is detected, and fatal — T1
databasev2 4 part A, task 1.

- wo_wal gains `path`: the abort diagnostic is worthless without naming
  the file it could not write. strdup'd in open, freed in close; NULL is
  tolerated so the message degrades rather than crashes
- wo_wal_commit now reports WHICH half failed — WO_WAL_ERR_WRITE for
  pwrite, WO_WAL_ERR_SYNC for fdatasync. A short write and a device
  refusing the flush are different operational problems and the operator
  needs the right one named
- wo_wal_commit_fatal(w, nrec): commits, or prints one diagnostic naming
  the operation, path, errno and record count, then exits
  WO_EXIT_DURABILITY (3 — 1 is a trap, 2 is a refusal, so this takes a
  third of its own)
- retrying is not offered, deliberately: on Linux a failed fsync may have
  already discarded the dirty pages, so a second call can report success
  having written nothing. Replay is the recovery that works

- test_wal: a failed commit is DETECTED, reports the write error
  specifically, keeps the batch staged (a failed commit consumes
  nothing), and the WAL knows its own path. 165 pass (was 156)

DISCLOSED GAP: the exit path itself is not exercised. Forcing a real
fdatasync failure needs a full or read-only filesystem, which the gate
cannot arrange without mount privileges. No fault-injection switch was
added — shipping a binary that can be told to kill itself is the worse
trade, and the spec rejected it.

Verified: just wovm-test — 36 suites (18 x both dispatch flavors) 0 fail,
cli_smoke OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 09:16:35 +02:00
18a56f24ec feat(db): wo_wal_read_row_at — materialise a row from a log offset
Task 5b of docs/superpowers/plans/2026-08-26-table-residency.md, whose Task 5
is now split 5a-5d (plan updated in this commit).

- the offset twin of wo_row_read (table.c:721): same out-gate contract —
  every value handed back is a FRESH VM allocation — but resolved from a file
  position instead of the id hash
- fits entirely in wal.c because everything it needs was already public:
  scan_record and dec_val are local, and wo_val_decode_vm / wo_db_val_free
  are exported at table.h:183-187. Two decode stages, since the record and
  the VM speak different dialects: dec_val -> engine slots -> VM copies, with
  the engine slots freed as scratch on every path
- ZERO storage change. Nothing calls it yet; that is the point of separating
  it from 5c, so the read path can be proven before the slabs are touched
- refuses rather than guessing, each case distinguishable: no intact record
  at the offset, a malformed header, a decode failure, trailing bytes, and a
  REMOVE tombstone. That last one matters most — handing a tombstone back as
  a row would read a deleted row as live

Tested by deep field comparison, not by "it parsed": 24 rows with a nil Text
every third row, each read back BY OFFSET and compared field by field,
including the string bytes. Plus all three refusal paths — tombstone, a
mid-record offset (the silent-wrong-row failure this guards), and past the
intact prefix.

The free-on-every-path claim is VERIFIED, not assumed: removing the free
produced 3 LeakSanitizer reports; restoring it returns to 0. Worth doing
because "ASan is clean" only means something if the harness would have
complained.

PLAN SPLIT: Task 5's storage half was written as if it were plumbing. Measured
instead: wo_row_ptr returns a db_row* into a slab with 11 call sites, table.c
has 37 slab references, db.c:105-181 scans slabs directly, enc_val serialises
FROM the slab, and no operation exists that drops a payload while keeping
index entries. Note this is the OPPOSITE half from the earlier retraction —
the record FORMAT needed nothing, the record STORAGE genuinely is deep. 5c
(id->offset map + drop-payload-keep-index) and 5d (rewiring the call sites,
scans, @unique/FK across the boundary) get their own write-ups.

Gates: test_wal 3654/0 (was 3428), all 18 runtime suites 0 fail under
ASan+UBSan, oop-e2e 119/0, residency 8/0, employee 8/0, db-actor 8/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 10:27:40 +02:00
3290c7d117 feat(db): wo_wal_next_offset — exact record offsets, proven
Task 5a of docs/superpowers/plans/2026-08-26-table-residency.md. The read
path itself is NOT in this commit; see the note below.

- the offset problem is far smaller than the spec feared. `w->off` is the
  durable tail and `w->len` the staged bytes, and wo_wal_commit pwrites the
  whole batch AT off before advancing it — so a record staged now lands at
  exactly off+len, knowable at append time with no deferral to flush
- shipped as an inline accessor rather than new out-params on the three
  append functions, so the 156 existing WAL checks keep their signatures
- correct across both awkward cases, and both are now unit-pinned:
  a failed commit leaves off unadvanced so the record still lands where it
  was promised, and wo_wal_open positions off at the end of the INTACT
  prefix so offsets are always relative to validated data
- test_offset_capture asserts the recovered ID per record, not merely that a
  record parses — a wrong offset reads a NEIGHBOURING record, which passes
  its own CRC and returns the wrong row silently. 400 records across
  repeated buffer growth (stage() doubles from 4096) and uneven commit
  batches, so offsets are exercised mid-buffer and right after a flush

The failed-commit test caught MY OWN misunderstanding: I asserted
next_offset was unchanged after a failed commit. It is not, and should not
be — the record is still staged, so next_offset correctly points PAST it.
The invariant that matters is that the durable tail did not move, which is
what it now asserts.

Gates: test_wal 3428/0 (was 3426), all 18 runtime suites 0 fail under
ASan+UBSan, cli_smoke OK, oop-e2e 119/0, residency 8/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 08:43:10 +02:00
dd7dd42bd1 feat: bounded mailboxes + WO_T_ACTOR (trap 13); try-arm place-copy fix
- cap 1024 (WO_MAILBOX override at boot): sender-side atomic
  reserve/release on every path — same-shard, cross-shard envelope,
  OOM rollbacks; full mailbox traps the SENDER catchably; delivery
  pop releases; overshoot bounded by in-flight sends (disclosed)
- test_mailbox 12/0: exact cap single-threaded, two racing senders win
  exactly cap slots, drain/refill clean
- corpus run/mailbox-full-trap: parked sleeper, send loop catches
  "actor mailbox full" after >= 1024 sends
- pre-existing compiler bug found + fixed: a try ARM yielding a Text
  PLACE (bare e.msg, try box.field) aliased a register the arm's scope
  end freed — ASan use-after-free, SEGV on the next unwind's
  double-walk; emit_try now applies copy_place_text to both arm
  results; pinned by corpus run/catch-msg-place
- db-bench driver: msgrate keeps iteration 22's unbounded-flood
  contract via WO_MAILBOX=MSG_N (the cap is 24's policy, not 22's)
- battery 12/12 fresh-built

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:05:13 +02:00
5fc32b4926 feat: iteration 34 — digest builtins sha1/sha256/hmac_sha256 (ids 85-87)
- runtime/src/crypto.c: hand-rolled cores, whole-value over Bytes,
  allocation-free tails; VM half returns fresh Bytes, WO_T_BOUNDS on
  wrong class id (Bytes builtins' message shape)
- test_crypto: RFC 3174 + FIPS 180-4 + RFC 4231 (incl. long-key case 6)
  + 63/64/65 block-boundary sweep + the RFC 6455 handshake input, 18/0
- compiler surface flat per house convention (sha1, not crypto.sha1 —
  matches base64_encode): types.ml signatures + result types, emit.ml
  ids/dispatch/arity/known-list/drop-table (fresh-Bytes entries so
  results get their drops)
- corpus run/crypto-digests pins the .wo path through base64_encode
- no .wob bump (ticks-84 precedent); WO_B_MAX 87; surface doc rows
- slice marker + board row: iteration 24 (absorbing 31+34) executing
- battery 12/12 fresh-built

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:42:43 +02:00
881b90e9c7 feat: O(1) read path — index probe wired end to end
- engine: wo_idx_probe answers single-column equality from the index
  hash buckets (idx_hash_key1 reproduces idx_hash bit for bit; verify
  compares exactly as the slab walk did, so results identical);
  composite indexes keep the walk; both executors wired (local + DB
  actor RPC)
- compiler: probe_key_of_where lowers "var.col == key" on an indexed
  column to DB_PROBE; all where guards still run (guard stays the
  final arbiter); keys = ident/int-literal only; Float/Bytes excluded
  (engine raw-eq narrower than VM float-eq)
- measured: reads 1.3k -> 1.3M ops/s, p50 600us -> 1us (~x850);
  query x830; mixread 1.3k -> 89k s1, 21 -> ~1.9k sN
- gate policy moved into the driver (tolerance_for: refresh-proof);
  latency floors max(4x,100us); quick mode skips poll-bound mix
  floors; both tolerance classes proven to bite
- proof: test_table wo_idx_probe suite (RED first), corpus
  query-index-probe 105/0, full battery green, TSan clean, two
  campaigns pass the refreshed baseline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 16:44:56 +02:00
d24c705860 feat: iterations 19 + 17 — Float/Bytes scalars (.wob v5), library kind + internal/
- Float full stack: literals (fraction/exponent; `0..10` still a range), f64
  opcodes 34-41, @table column, WAL bit-exact replay, json fractions in and
  shortest-round-trip out. IEEE-quiet — FDIV never traps where DIV does.
- Bytes: a wo_str with its own class id, so alloc/free/copy are shared but no
  Text builtin accepts one; len/at/slice/eq/concat, base64 both ways, json
  boundary as base64; TEXT_COPY preserves the kind.
- No implicit Int/Float mixing (WO-E201 in the typechecker, not the emitter,
  which picks the opcode from one side and would misread the other).
- One IEEE deviation: float_cmp total order (NaN last, -0.0 == +0.0) for
  indexes and order-by, keys canonicalized to match. `?Float` nil is a
  reserved quiet NaN — the zero word is +0.0, WO_NIL_SCALAR's bits are -2.0.
- Renderer prefers fixed over exponential in 1e-6..1e21: pure shortest makes
  a price of 900.0 read `9e+02`. One renderer for interp/json/float_to_text.
- Fixed en route: lexer double-counted the leading digit; is_scalar_shaped
  took Float/Bytes as Int-shaped; Bytes ownership needed a shared heap-scalar
  predicate or temps never dropped; order-by bit-compared negatives backwards.
- Iteration 17: `kind = "library"` (absent = program; bad value = WO-E109),
  entry-less check mode retiring the `--emit` workaround, Go's `internal/` as
  WO-E108 at the consumer's `use`. Driver-only; VM/.wob/GC untouched.
- Framework reorg: internal/{parse,serve}.wo; http/form.wo split out to keep
  media_type/form_values public (parse.wo had grown public surface).
- Docs: link audit (97 -> 88 broken, conflict markers resolved, 2 duplicate
  stories removed), 00-code-review verified 26/27, iterations re-sequenced.
- Also carries the pre-staged pub(read)/using/#if work from the index.
- Gates: corpus 103/0, test_wal 156/0, web-app 26/0, oop-accept ALL MET.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:24:15 +02:00
f88aa11cef feat(runtime): fiber run queue + reduction budget (arc T2)
- fiber states (RUNNABLE/PARKED/DONE), intrusive FIFO run queue,
  wo_vm_spawn_fiber (calloc'd context, frame 0 set up like wo_vm_call)
- reduction budget: WO_REDUCTIONS (default 4000), checked at loop
  BACK-EDGES AFTER the jump lands so the saved pc is the loop head —
  a pre-instruction save at budget 1 re-executes the jump into the
  same decrement and livelocks (found by reasoning, pinned by the
  budget-1 test; deviation from the spec's three-site wording,
  recorded in the yield macro's comment)
- FIBER_DONE: main returning ends the program and reaps every
  remaining fiber through vm_unwind (drop maps run); a spawned fiber
  ending frees silently; its return value is discarded by contract
- TRAPF: an uncaught trap in a spawned fiber kills that fiber ALONE
  (stderr report, program lives); in main it stays the program's death
- WO_SYS_STOPPED reaps all fibers wherever it lands (main unlinked
  from the queue and unwound if a spawned fiber caught the stop)
- vm_gc_roots walks the live fiber plus every queued one
- test_fiber (45 checks, ASan): EXACT round-robin interleave at budget
  1 across three fibers pushing tags into one shared multi;
  main-return reaps a spinning fiber holding an owned Big (ASan proves
  the free); a DIV0 fiber dies alone, main answers 0
- full battery green: wovm-test, oop-e2e 89/0, woc-test, log-watcher
  7/0, employee 8/0, web-app 21/0, deps-accept 8/0 (scheduler dormant
  = one branch per back-edge)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 07:03:40 +02:00
092b528081 feat: retire RC from the emitter and the format — .wob v4 (7b Phase 3b)
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>
2026-08-19 17:07:43 +02:00
841cb41c6b feat(runtime): incremental tri-color mark-sweep replaces RC (7b Phase 3a)
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>
2026-08-19 16:52:48 +02:00
44c77ff5bf feat: update-point + delete engine half (iteration 9, Task 5 engine)
- 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>
2026-08-15 13:01:05 +02:00
6f2f9b6f0a feat: secondary indexes + @unique trap (iteration 9, Task 4; wob v3)
- .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>
2026-08-15 12:57:32 +02:00
c3741262cb feat(database): typed WAL + boot replay (iteration 9, Task 2)
- 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>
2026-08-15 11:01:41 +02:00
936bd14bff feat(database): class-shaped row storage (iteration 9, Task 1)
- 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>
2026-08-15 10:54:24 +02:00
065ac99224 feat: json encode/decode + as, .wob v2 class metadata — log-watcher compiles
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>
2026-08-14 17:08:46 +02:00
bab88a53b9 feat(runtime): wovm VM core (Iteration 2)
- 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).
2026-08-10 09:35:55 +02:00