- sk_vm_idle_point(may_drain_self): with no frame live and mail pending, Hera drains one message and returns to the poll loop. Readline passes n == 0 (the mid-edit gate), headless 1, the key read 0. - Removed sk_repl_idle()'s timer-paced drain of Hera's queue and the two flags that guarded it (g_mama_interpreting, g_idle_pump_active) -- the idle point's frames check already covers that re-entry hazard. Probe (reverted), all three ISAs: admission -> drain for mail to an idle Hera 0-1 beats (was up to 100); a message arriving mid-edit is held until the line is entered (2080-3587 beats), never printed into typed text; an event queued during a busy line drains when it ends; BLK-ATTACH at boot 1 beat. Acceptance passes; parity, dict_hash set and relay ledger unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
54 KiB
FABRIC-3.9.8.md — Observers and participants: execution plan and log
Status: IN PROGRESS. Done: O-A, the stack-convention fix, S-1a, S-1b, S-1c, M-0, M-A, the dead-VM departure fix, M-B and O-B (§2). The design and
its rulings (O-1 to O-4, the discard, and §7/§8's M- and S- rulings) are in FABRIC-3.9.0.md.
Each stage:
- needs Captain Bob's explicit go;
- gets its own commit;
- gets its own three-architecture acceptance (
clean qemu; amd64, then aarch64, then riscv64; foreground; visible framebuffer; one at a time; coupled images reset before each).
Evidence is named before each stage runs.
The standing acceptance sequence for every stage, unchanged from FABRIC-3.8.md:
- a boot to
[zuse@Hera] ok>with zeroUNKNOWN WORD, faults and frame corruption; - the
dict_hashset identical across ISAs; - the Stage G interleave (two workers,
A/B), byte-identical across ISAs; - mint → WIREBIND →
USE→1 2 + .→3; - 0 bytes of idle log growth over 20 s.
1. Staged plan
| Stage | Change | Evidence (named in advance) |
|---|---|---|
| O-A | Observer role (FABRIC-3.9.0.md §2.1). The absolute-heat-share charge is discarded (ruling 5). Observers are left out of the decision's candidates, the work-heat charge and the floor, and (O-2) the absolute charge. Observers with events go ahead at clean boundaries, among themselves by wait stamp (§2.4, per O-1). |
(Corrected before starting.) The standard sequence, with the G interleave unchanged. Console responsive under load: 1 2 + . typed while wa/wb run prints 3 in the middle of the A/B stream. Observer precedence: a message to Hestia sent in the same line as the two worker jobs runs before any A/B. Not run: the busy-wait DoE driver. Under O-1, Hera running DOE is an observer acting on an event, and nothing displaces her, so MU-WAIT-ALL would wait forever. The originally named evidence ("Hera's share falls on the unchanged busy-wait driver") contradicts the design. DOE stays unrun until O-C replaces the driver. |
| M-0 (first; a defect against the standing no-fixed-bounds rule) | Kernel-Hermes arena, per-VM queues and channel member lists grow on demand; Q_SLOT decoupled from arena size (FABRIC-3.9.0.md §7.0). |
The standard sequence. The existing kernel-Hermes boot self-tests (allocator, publish, drain, chunk, negotiation) still PASS, with expected_n now set by the reservoir, not the arena. A probe (reverted) admits more than 32 messages system-wide, spread over senders with reservoir to pay, and more than 32 to one queue, with conservation and the ledger holding throughout. A channel takes more than 64 members. Nothing moves or corrupts across a grow: messages queued before a grow drain intact after it. |
| M-A (ahead of O-B, per Captain Bob: "close the gap first") | Manual messaging words over the existing C API (FABRIC-3.9.0.md §7.2, names per M-1). |
The standard sequence. At the console: CHANNEL, then JOIN-VM of wa and wb, then PUBLISH of a marker payload delivers to both (n = 2, both print). LEAVE-VM then PUBLISH delivers to one. CHANNEL-CLOSE succeeds, and refuses channel 0. INJECT lands in a queue that MAIL?/.MAIL show before the recipient runs. .CHANNELS lists what was made. The dict_hash set changes by the new words and is identical across ISAs. |
| M-B | Autonomous events (§7.3): event channels created at boot; EV-DONE/EV-BORN/EV-DIED fired from the existing kernel points, for participants, funded per M-5, dropped and counted when full (M-6). |
The standard sequence. A VM that JOINs EV-BORN/EV-DONE/EV-DIED and defines the ON-… words sees one event for each WORKER-BIRTH, each job finished, and each KILL, with no SEND anywhere in the job text. A VM that didn't join sees nothing. The dropped-event counter reads 0 in normal use; a probe that floods the arena shows events dropped and counted, never blocking. |
| O-B | Hera's own mail drained at sk_vm_idle_point(), with the readline n > 0 gate; the timer-paced pump drain removed (§2.5). |
The standard sequence, plus: a message sent to Hera while she sits idle at the prompt is acted on without waiting for the idle beat. A probe records the delay in beats from admission to drain; it must be well under SK_IDLE_BEAT_INTERVAL. The relay and BLK-ATTACH flows are unchanged. |
| O-C | An event-driven DoE driver (§3, as revised by §7.4 once M-B lands): DOE starts trial 1 and returns to the prompt; Hera's ON-DONE (fed by EV-DONE) counts, ends trials and starts the next; MU-WAIT-ALL and the job text's own completion SEND are deleted. Capsule only. |
A 1-rep smoke campaign on amd64 runs all 6 cells to MU-CAMPAIGN: complete, with every trial fail=0 and N/N KILL … dead. Hera's rows during trials are only event handling. The prompt answers typed input while trials run. Then the standard sequence on all three ISAs. |
| O-D | The DoE relaunch: 12345 3 S" <isa>" DOE, amd64, then aarch64, then riscv64, with the ring extracted between ISAs and artemis.img not reset in between. |
A fresh time estimate from O-C's measured row rate, before launch. Then per ISA: 18 MU-TRIAL markers, fail=0, MU-CAMPAIGN: complete. |
| Merge | This branch to master, once all three ISAs have completed O-D. |
Per this project's own merge practice, at Captain Bob's word. |
Stop conditions (re-plan rather than push on):
dict_hashdiverges across ISAs;- conservation (
vm_physics_conserved(),stadium_conserved()) goes false; - an observer is ever returned as a heat candidate;
- a switch storm: log growth at an idle prompt, with QEMU pinned;
- a trial that never produces its marker.
2. Execution log
-
Stage O-A — the observer role (
FABRIC-3.9.0.md§2). 2026-09-24. Captain Bob: "all as proposed, go ahead with O-A" (O-1 to O-4 and the discard, all as proposed). - Discarded: the uncommitted absolute-heat-share charge (git checkoutofcapsule_vm_physics.c/.h), per ruling 5. -capsule_vm_switch_signal.c/.h: - Each slot carries anobserverflag, withsk_vm_switch_signal_set_observer()andis_observer(). -sk_vm_switch_decide()now works in tiers: 1. An observer that is itself in progress (a live non-IDLE frame) or holding an event keeps the CPU. 2. Otherwise, an observer with an event, meaning mail (or console input, for Hera), goes ahead of every participant. Among observers, the oldest wait stamp wins. 3. Otherwise, participants are compared by work heat exactly as in FABRIC-3.8. Observers are never heat candidates. -note_work_gained()never floors an observer and leaves observers out of the floor set. The running VM counts only if it is a participant. -capsule_birth.c:is_fleet_foundationis hoisted to function scope. Registration marks the Tripod as observers from that same birth-time fact. -kernel_main.c: Hera is marked an observer where she registers. -vm_runtime.c: observers are not charged at all (O-2). - Nothing a VM executes can set or clear the role.**Evidence, as corrected before starting (§1).** Ordinary words only, typed at `[zuse@Hera] ok>`, byte-identical on all three ISAs (`logs/20260924-084904/amd64/`, `-090107/aarch64/`, `-091424/riscv64/`): - **G interleave unchanged:** A = 40, B = 40, 48 runs. - **Observer precedence:** `S" LA" S" wa" SEND . S" LB" S" wb" SEND . S" 72 EMIT 72 EMIT 72 EMIT" S" Hestia" SEND .` gives `HHHABBAABBAA…`. Hestia's mail ran before any participant output once Hera went idle. - **Console responsive under load:** with `wa`/`wb` on 200 markers × 20,000-iteration loops, `1 2 + .` typed mid-run appears as `…ABABAB1 2 + .` / `3 ok` / `ok> ABABBA…`. Hera, an observer with console input, got the CPU at the next worker boundary, ran the line, and the workers resumed. This is the C-4 (α) starvation recorded at C-b, fixed. *(The first amd64 attempt at this check used loops that finished before the line was typed, so it proved nothing. It was re-run with the heavy loops, and the script was fixed for the other two ISAs.)* **Acceptance** (same boots): - All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD`, faults and frame corruption. - The `dict_hash` set is unchanged from Stage I (word_count 531, M7.1a `0x572daac83b797332`), since no words were added or removed. - Mint → WIREBIND → `USE` → `1 2 + .` → `3`. - Idle log growth: 0 bytes. - Clean build on all three plus hosted, no new warnings. **Not run, by design:** the busy-wait DoE driver, which would deadlock under O-1 (§1). Don't run `DOE` until O-C. -
Stack-convention fix: out-of-bounds stack writes in four vendored word files (found during S-1a, 2026-09-24). Captain Bob: "Fix now, own commit."
**The defect.** The VM's `dsp` is the index of the top item (−1 = empty): `vm_pop` reads `data_stack[dsp--]`. Four vendored files instead treated `dsp` as a count, pushing with `data_stack[vm->dsp++] = x` (11 sites) and popping with `data_stack[--vm->dsp]` (17 sites): - `physics_freeze_words.c`: 7 pushes, 12 pops; - `dictionary_heat_diagnostic_words.c`: 4 pushes, 2 pops; - `physics_diagnostic_words.c`: 1 pop; - `physics_benchmark_words.c`: 1 pop. **The consequences:** - On an empty stack a push wrote `data_stack[-1]`, **8 bytes before the VM struct**. POST's own `DECAY-RATE@.basic` test does exactly that on every boot. - Otherwise a push overwrote the top item and left `dsp` one slot off. - The pops read the item *below* the top, and on the last pop, `data_stack[-1]`. - Four one-item words also guarded with `dsp < 1`, which demands two items, so `LOOKUP-STRATEGY!`, `COMPARE-LOOKUPS`, `PHYSICS-BURN` and `BENCH-DICT-LOOKUP` always errored on valid input. **How it was found.** The S-1a parity probe's incomplete counter read one too many on aarch64 only. - A per-test hook in the test runner named `DECAY-RATE@.basic` as the test after which the counter moved (`logs/20260924-104424/aarch64/`). - Writing a marker into `mama->data_stack[-1]` changed the counter to the marker (`-105120`). - Printing runtime addresses (`-105729`) showed why aarch64 only: the aarch64 kernel runs inside the `lld-link` PE, whose `.bss` puts `parity.c`'s counter directly before `sk_mama_vm`. - On amd64 and riscv64 the same write lands in the last cell of the POST block-RAM buffer, silently. - An interim hypothesis of memory aliasing, drawn from ELF symbol addresses that don't describe the aarch64 runtime image, was wrong and is withdrawn. **The fix:** - Every site now uses the VM's own `vm_pop(vm)` / `vm_push(vm, x)`, which check bounds. - The four one-item guards are now `dsp < 0`. - The two- and three-item guards (`dsp < 1`, `dsp < 2`) were already correct for the top-index convention and are unchanged. **Evidence.** A probe (reverted) compared the 8 bytes before the VM struct across POST, plus console checks, on all three ISAs (`logs/20260924-111124/amd64/`, `-112508/aarch64/`, `-113949/riscv64/`): - `STACKFIX PROBE: … UNCHANGED across POST`; - `DECAY-RATE@ DEPTH . .` gives `1 1`; - `S" DUP" FROZEN? DEPTH . .` gives `1 0`; - `HEAT@` leaves 1 item, and `HEAT-PERCENTILES` leaves 3; - `1 LOOKUP-STRATEGY! LOOKUP-STRATEGY@ .` gives `1`, depth 0, where it used to error; and `0 …` gives `0`. Acceptance in the same boots: - the O-A checks: G interleave A = 40/B = 40/48 runs, `HHH` precedence, and the typed line running mid-stream; - relay `3`, idle growth 0; - `dict_hash` unchanged (531, `0x572daac83b797332`); - POST passes; - clean kernel and hosted builds. -
Stage S-1a — parity hashing covers the whole dictionary (
FABRIC-3.9.0.md§8.1, class C). 2026-09-24. Captain Bob: "all as proposed, commit and start S-1."**The change** (`src/starkernel/vm/parity.c`, `include/starkernel/vm/parity.h`): - **`MAX_DICT_ENTRIES` (2048) removed.** The walk covers the whole link chain. The entry list is allocated to the dictionary's actual size (`kmalloc` in the kernel, `malloc` hosted) and freed after. - **Cycle check replaces the cap.** The only thing the cap guarded against, a corrupt cyclic chain, is now detected directly (Floyd: a second pointer moving two links per step). - **The colon-body walk is bounded by `vm->here`,** not a 1024-cell cap. A body that reaches `HERE` without `EXIT` or a NULL cell is marked incomplete, not quietly truncated. - **Failures follow §8.1's rules.** Every failure (cyclic chain, entry list not allocated, malformed body) is: - printed in full to the console, `PARITY:INCOMPLETE #n -- <why>`. `log_message`'s line buffer is 64 bytes and cut every reason off mid-sentence. - logged at `LOG_ERROR` for the persisted record; - counted (`sk_parity_incomplete_count()`). - **An incomplete hash is marked as such.** The record prints `hash=INCOMPLETE` (new `ParityPacket.hash_complete`) and `PARITY:FAIL code=4` (`SK_BOOTSTRAP_PARITY_INCOMPLETE`), never a value that looks valid. - `sk_dict_word_count` returns 0 on a cyclic chain, logged and counted. **Two pre-existing defects found by the probe, both fixed in the walker:** 1. **The colon-body walker dereferenced inline data.** It treated every cell of a body as a `DictEntry*` and followed it. Only `LIT`'s operand was known to be data. `S"` string bytes, branch offsets and other inline operands were followed too. - This was harmless while parity only ever ran at early boot, before any colon bodies existed. - The first hash of a mature dictionary took a #GP (vector 13) and halted the kernel. This is the "interrupt exception" Captain Bob reported (`logs/20260924-094537/amd64/`, `-095154/amd64/`). - The **original** code faults the same way (`logs/20260924-095730/amd64/`), so the defect predates S-1a. - **Fix:** a cell is dereferenced only if it is a known dictionary entry, found by binary search in a sorted copy of the entry list. Any other cell is hashed as its raw value and never followed. 2. **A `LIT` operand holding an entry pointer was hashed by address.** `['] WORD` compiles `LIT <DictEntry*>`, a native address that differs per ISA (seen in `ACL-BOOT`'s `ACL-PIN` sequences). So the mature-dictionary hash differed across ISAs: - amd64 `0x945a76bc6ace1fdc`, aarch64 `0x8cc1255e6a40c12e` (`-100252`, `-100732`). - **Fix:** rule 2 (word_id, never address) now applies to such an operand. The walker hashes a tag byte (`0xE7`) plus the entry's `word_id`, so it can't collide with a plain number. Any other literal is still hashed as its value. A third defect, an incomplete counter one too high on aarch64 only (`-101803`, `-102504`, `-103207`, `-103820`), was not in parity. It was the out-of-bounds stack write fixed in its own commit (the entry above). **Evidence.** A probe (reverted) at the end of boot: 1. hashed Hera's full dictionary; 2. registered 2,000 more words (`PZ0000`…) and hashed again; 3. made the chain cyclic and asked for both the word count and the hash. The results are byte-identical on all three ISAs (`logs/20260924-115524/amd64/`, `-115954/aarch64/`, `-120533/riscv64/`): | Probe step | Result | |---|---| | before | `word_count=563 hash=0x224007f9af328111`, `PARITY:OK` | | after +2000 | `word_count=2563 hash=0xcf85876337e75a84`, `PARITY:OK`. Past the old 2048 cap: the whole dictionary is hashed. | | cyclic | `PARITY:INCOMPLETE #1`/`#2 -- dictionary link chain is cyclic (corrupt)`, two `[HADES][ERROR]` lines, `word_count=0 hash=INCOMPLETE`, `PARITY:FAIL code=4` | | counter | `incomplete_count=2` (word count + hash) | The "after" record shows `latest_id=4294967295`. That is `WORD_ID_INVALID`: the 2,000 probe words ran past the 1024 stable word ids, and each logged "out of stable IDs, disabling metrics". That is S-1b's defect, not S-1a's. The §8.1 row now notes that this path does log a warning, but neither counts nor refuses. **Acceptance** on the clean tree, with the probe reverted (`logs/20260924-121207/amd64/`, `-122500/aarch64/`, `-123759/riscv64/`): - Boot M7.1a is unchanged: `word_count=531 hash=0x572daac83b797332`. At boot there are no colon bodies or entry-pointer literals yet, so neither fix changes the boot hash. - The `dict_hash` set is unchanged: Hera `0xda9742ce9c3376a2`, Hestia `0x413e7d086f342d8b`, Artemis `0xdfd68316d5f5ba85`. - The O-A checks pass: G interleave A = 40/B = 40/48 runs, `HHH` precedence, and a typed `1 2 + .` gives `3` mid-stream. - Mint → WIREBIND → `USE` → `1 2 + .` → `3`. Idle log growth 0. Zero `UNKNOWN WORD` or faults. QEMU exits cleanly. -
Stage S-1b — dictionary word ids grow with the dictionary (
FABRIC-3.9.0.md§8.1, class C). 2026-09-24. Captain Bob: "go ahead with S-1b."**The defect.** Stable word ids came from fixed `word_id_map[1024]` / `recycled_word_ids[1024]` tables in the `VM` struct. Word 1025 onward got `WORD_ID_INVALID` and a `LOG_WARN` ("out of stable IDs, disabling metrics"), but was still defined. It then ran outside the physics: - the rolling window and the pipelining hooks skipped it; - Stadium never admitted it; - parity hashed it as `0xFFFFFFFF`. Nothing counted it and nothing refused it. **The change:** - **`vm.h` / `dictionary_management.c`: the core.** - `word_id_map` and `recycled_word_ids` are heap tables sharing one `word_id_capacity`. - They start at 64 and double when a fresh id reaches capacity. - Growth is all-or-nothing: both new tables are allocated before either old one is touched. It uses allocate-copy-free, because the kernel's `sf_realloc()` does not copy. - Numbering is unchanged (a recycled id first, else `next_word_id++`). That is why boot parity is unchanged. - Both `vm_cleanup()`s free the tables. - **Refusal is whole.** - `vm_dictionary_track_entry()` now returns a status. - `vm_create_word()` takes the id *before* linking the entry. On failure it frees the entry and its transition metrics, sets `vm->error`, and returns NULL. - The failure is logged at `LOG_ERROR` ("word id table can't grow: '…' refused") and counted (`vm_dictionary_word_id_refusals()`). - Every caller already handled NULL from the existing malloc-failure and pinned-shadow paths. - **The bound for every reader of the id space is `vm->next_word_id`,** not `DICTIONARY_SIZE`. Every issued id, live or recycled, is below it: - `lookup_by_word_id`, `untrack`; - both interpreters' physics hooks (`physics_execution_hooks.c`, `vm_core.c`); - the rolling-window seed (its frequency array is now sized to the bound); - `inference_engine.c`; - the pipelining diagnostics. - **`transition_heat` (`physics_pipelining_metrics.c`).** Each word's array was sized once, at whatever size its first record passed in. With a dynamic bound, that would have been a heap overflow on the first transition to a newer id. - It now has its own `transition_heat_cap` and grows on record (to the bound, at least doubling). - Every reader bounds by that field. `get_probability` had no bounds check at all. - A failed grow drops that one transition (the word already ran; nothing is left to refuse). It is logged and counted (`transition_metrics_grow_failures()`). - **Untested at runtime.** Pipelining is compiled out of the kernel *and* the default hosted build (`ENABLE_PIPELINING=0` in both), so this grow path is dead in every shipped configuration. No run exercised it. It is corrected, not proven. - **Stadium's id→cell rows (`stadium_words.c`).** These were `[slot][DICTIONARY_SIZE]`. - Each row now starts empty and grows on dispatch (`row_cap[slot]`), not at definition, because a quota slot can be granted after a VM's words exist, and slots are reused across VMs. - A failed grow is logged and counted (`stadium_words_row_grow_failures()`). The word is not admitted that dispatch, which is the same, already-conserving outcome as a full Stadium. - This is the id→cell map, not the cell pool, so **S-2 stays open**. - `DICTIONARY_SIZE` stays defined for its one remaining user, reported below. **Evidence, named before the run.** A probe (reverted) at the end of boot, byte-identical on all three ISAs (38 lines; `logs/20260924-130702/amd64/`, `-131140/aarch64/`, `-131624/riscv64/`): | Check | Result | |---|---| | Start | `next_word_id=738`, capacity 1024 (grown 64 → 1024 during boot) | | +2000 words | `next_word_id=2738`, capacity 4096, `PZ1999 word_id=2737`, refusals 0, and no "out of stable IDs" line. Parity OK, `word_count=2563`, `latest_id=2737` (was `4294967295` at S-1a), `hash=0xc873c20962d728ed`. The hash differs from S-1a's `0xcf85…` as expected: 1,539 words now hash real ids instead of `0xFFFFFFFF`. | | Physics past id 1024 | `: PZHOT 1 DROP ; : PZRUN 200 0 DO PZHOT LOOP ; PZRUN` gives PZHOT id 2738 and PZRUN id 2739; `lookup(2738)==PZHOT`; **200** PZHOT entries in the rolling window (the hooks used to skip any id ≥ 1024); Stadium promotions +2 (both words admitted); row-grow failures 0. | | FORGET/recycle | `FORGET PZHOT` leaves 2 recycled ids and `lookup(2738)==NULL`. `: PZNEW ;` reuses id 2738, and `lookup` returns PZNEW. | | Forced grow failure | After filling the tables to capacity (4096), one allocation failure was injected into the grow. `PZFAIL` then gives NULL, `vm->error=1`, refusals 1, and `[HADES][ERROR] word id table can't grow: 'PZFAIL' refused`. `latest`, `word_count`, capacity and `next_word_id` are all unchanged, and `PZFAIL` is not findable. | | Retry | With the injection cleared, `PZOK` is defined: id 4096, capacity 8192. | | Final parity | `word_count=3922`, `hash=0x5af2d7e820d21624`, `incomplete_count=0` | **Acceptance** on the clean tree, with the probe reverted (`logs/20260924-132055/amd64/`, `-133339/aarch64/`, `-134638/riscv64/`): - Boot M7.1a is unchanged (`word_count=531`, `latest_id=700`, `0x572daac83b797332`), and so is the `dict_hash` set (Hera `0xda9742ce9c3376a2`, Hestia `0x413e7d086f342d8b`, Artemis `0xdfd68316d5f5ba85`). - The O-A checks pass: G interleave A = 40/B = 40/48 runs, `HHH` precedence, and a typed `1 2 + .` gives `3` mid-stream. - Mint → WIREBIND → `USE` → `3`. Idle log growth 0. Zero `UNKNOWN WORD` or faults. QEMU exits cleanly. - Clean kernel builds on all three ISAs, plus the hosted build. **Found, reported, not fixed:** 1. `dictionary_heat_optimization.c:123`: `cell_t heats[DICTIONARY_SIZE]` snapshots at most 1024 words' heat and silently ignores the rest. That is class C, keyed on the dictionary count, not on ids. 2. `vm_create_word()`: a failed transition-metrics malloc logs a WARN and defines the word anyway, with no pipelining metrics. That is a partial. 3. Neither `vm_cleanup()` frees `transition_heat` (or `context_window`) inside each word's metrics, only the metrics struct itself. That is a leak on every VM kill with pipelining on. 4. `transition_metrics_update_cache()` scans the whole id space on every recorded transition. The hot-path cost grows with the dictionary, now that the dictionary can grow. 5. The kernel's FC-bucket grow (`sf_fc_reserve`) copies after `sf_realloc()`, which is correct, but never frees the old block. Each bucket grow leaks it. 6. §8.1 asks that each failure counter be readable by a FORTH word or DoE row. S-1a and S-1b expose C accessors only (`sk_parity_incomplete_count()`, `vm_dictionary_word_id_refusals()`, `transition_metrics_grow_failures()`, `stadium_words_row_grow_failures()`). Adding a word changes the `dict_hash` set, so this awaits Captain Bob's ruling. **Follow-up review (same day, own commit; acceptance `logs/20260924-140359/amd64/`, `-141643/aarch64/`, `-142946/riscv64/`: boot parity and `dict_hash` set unchanged, O-A checks, relay `3`, idle growth 0, no faults):** - **The refusal path leaked each metrics' `context_window`.** It freed the `WordTransitionMetrics` struct without `transition_metrics_cleanup()`. That call is now made first. - **Moving tables were checked for concurrent readers.** Before S-1b the id map never moved; now a grow frees the old map, and likewise the Stadium rows. Every id lookup off the interpreter path runs under `dict_lock`: the heartbeat decay cursor (`vm_time.c`, `vm_runtime.c`), the rolling-window seed, and inference. `vm_create_word()` holds that lock across the grow. In the kernel, the heartbeat is mainline and execution-paced, not an interrupt, and Stadium rows are touched only from the interpreter and `stadium_words_resident_heat`'s FORTH word. No interrupt reads either. - **Sweep for other arrays indexed by word ids** (not only those named `DICTIONARY_SIZE`): - `rolling_window_find_hottest_word()` sizes by its own `dict_size` parameter and has no callers. - No word-id field narrower than 32 bits. - Nothing else found. -
Stage S-1c —
WORD's buffer is per VM, and the input buffers sit below all compiled code (FABRIC-3.9.0.md§8.1, class C). 2026-09-24. Captain Bob: "go ahead with S-1c", then, on the defect below, "Allot at VM init".**The defect as scoped.** `WORD` built its counted string in one `static vaddr_t word_scratch_addr` shared by every VM: - It was `vm_allot`'d once, in whichever VM called first, at wherever that VM's `HERE` stood. - A second VM got an address meant for someone else's space. - Tokens were silently cut to 62 characters (a 64-byte buffer). **The deeper defect, found by the first probe.** The input buffers were allotted lazily by `vm_input_ensure()` at the current `HERE`: the TIB (`INPUT_BUFFER_SIZE`), `>IN`, `SPAN`, and `WORD`'s scratch. - Three things later roll `HERE` back below them and compile the dictionary over them: POST's test harness (`restore_dict_state()`), `FORGET`, and `COLD` (which clamps `HERE` to 1024). - On Hera, POST allotted the TIB at offset 120 and then restored `HERE`. **`ACL-RECHECK`'s colon body lived at offset 824, inside the TIB.** - The first S-1c probe wrote the TIB, as `QUERY` does, which zeroed that body. Hera hit `execute_colon_word: NULL cell in 'ACL-RECHECK'` and never reached her prompt (`logs/20260924-151033/amd64/`; layout diagnostic `-152047/amd64/`). - So before this stage, `QUERY` wrote into Hera's compiled code, and so did `WORD` through the old static scratch. Nothing in a normal boot calls either afterwards, which is why acceptance never saw it. The first draft of the per-VM buffer, allotted the same lazy way, inherited the defect. **The change:** - **`vm.h`:** - `VM.word_buf`, one per VM. - `WORD_BUFFER_SIZE 257`: a count byte, 255 characters (the most a counted string can hold, a format limit, not a chosen bound), and a NUL. - **`vm_api.c`:** `vm_input_ensure()` also allots `word_buf`, after `SPAN`, so TIB/`>IN`/ `SPAN` keep their relative layout. - **Both `vm_init()`s** (`src/vm_bootstrap.c`, `src/starkernel/vm/vm_bootstrap.c`) call `vm_input_ensure()` first, before POST, capsules or any definition. - The buffers take offsets 0–1305 in every VM. - Every later `HERE` rollback (test restore, `FORGET`) lands above them. - If the allotment fails, `vm_init` fails with an error. - **`COLD`** (`system_words.c`) still clamps `HERE` to 1024, but never below the end of the input buffers. - **`string_words.c`:** the static is gone. `WORD` builds into `vm->word_buf`. - A token over 255 characters is **refused whole**: the delimiter goes back on the stack, `>IN` is not consumed, and `vm->error` is set. - The refusal is logged at `LOG_ERROR` ("WORD: token over 255 chars refused") and counted (`string_word_word_refusals()`). **Evidence** (probe, reverted; byte-identical on all three ISAs, 37 lines; `logs/20260924-152935/amd64/`, `-153409/aarch64/`, `-153842/riscv64/`): | Check | Result | |---|---| | Layout, Hera and Hestia | TIB offset 0, `>IN` 1032, `SPAN` 1040, `word_buf` 1048, end 1305. Colon bodies inside the buffers: **0** (Hera 26 checked, Hestia 179). | | Isolation | Hera's `WORD` returns her own `word_buf`, in her own memory. Hestia's returns hers, in hers, and the two are distinct. Hera's `HERAWORD` is intact after Hestia parses `HESTIAWORD`. | | Length | 62, 100 and 255 characters come back at full length with the correct last character. It used to be 62 at most. | | 300-char token | Refused, `vm->error=1`, refusals 1, the ERROR line logged, the delimiter restored, `>IN` 0. | | After the probe | The probe wrote the TIB full-width, and `1 2 + .` still prints `3`. Hera reaches `[zuse@Hera] ok>`. | **Acceptance** on the clean tree (`logs/20260924-154256/amd64/`, `-155542/aarch64/`, `-160845/riscv64/`): - Boot M7.1a `word_count=531`, hash `0x572daac83b797332`, unchanged. Only its `here` moves, from `0xd8` to `0x5f8`, the 1,305 buffer bytes now allotted first. - The `dict_hash` set is unchanged (Hera `0xda9742ce9c3376a2`, Hestia `0x413e7d086f342d8b`, Artemis `0xdfd68316d5f5ba85`). The canonical hash walks word ids and body cells, and no colon body carries an absolute address that the shift would change. - The O-A checks pass: G interleave 40/40/48, `HHH` precedence, and a typed `1 2 + .` gives `3` mid-stream. - Mint → WIREBIND → `USE` → `3`. Idle log growth 0. No faults. QEMU exits cleanly. - Clean kernel builds on all three ISAs, plus the hosted build. **Found, reported, not fixed:** 1. **`COLD` rewinds `HERE` to 1024 without rewinding `latest`.** Every word defined past 1024 stays linked while new definitions are compiled over its body. S-1c only keeps the clamp off the input buffers. 2. **`PAD` overlaps the block I/O window.** It returns `VM_MEMORY_SIZE - 512`, which is inside the top block slot (`BLK_VM_WINDOW_BASE` = `VM_MEMORY_SIZE - 4096`), so `PAD` and the 4th `BLOCK` buffer share bytes. -
Stage M-0 — kernel-Hermes grows on demand (
FABRIC-3.9.0.md§7.0, rulings M-0 and M-0b). 2026-09-24. Captain Bob: "go ahead with M-0."**Corrected before starting: the mechanism, not the ruling.** §7.0 proposed the arena "grow by doubling … the same allocate-copy-free pattern `vm_frame.c` already uses". That would move messages on every grow, but callers hold `SkHermesMessage *` across later allocations: - the boot self-tests hold up to 32; - the switch decision peeks; - **the drain holds one across `vm_interpret_kind()`**, and a payload that `SEND`s can grow the arena mid-drain. A copy-on-grow arena would leave all of them dangling. So the arena is a **directory of fixed-size segments**: messages never move once allocated, and only the directory is allocate-copy-free (nothing holds pointers into it). It still grows on demand, from the kernel heap, with RAM the only limit, as ruled. **The change** (`kernel_hermes.c/.h`, `kernel_main.c`'s self-test): - **The arena.** `sk_hermes_msgs[32]` is gone. - `SK_HERMES_ARENA_SEGMENT` (32) is the growth *granularity*, not a limit. A full arena adds a segment. - Each message carries its own `index`, its identity. Queues hold indices, never pointers. - Numbering is unchanged: lowest free index first. - **The pending queues.** Each queue's ring is heap-grown on push (doubling, unwrapped into FIFO order). `SK_HERMES_PENDING_MAX` is gone. A reused queue-table entry keeps its ring rather than leaking it. - **The channel members.** `SkHermesMembership` is a pointer plus a capacity, grown on subscribe and freed by `sk_hermes_channel_destroy()`. `SK_HERMES_MEMBER_MAX` (64) is gone. - **Reassembly.** `present[]` is allocated to `n_chunks` per call, with a single exit path. That also removes the old cap of 32 chunks. - **M-0b.** `SK_HERMES_Q_SLOT` is `Q48_ONE / SK_HERMES_Q_SLOT_DIVISOR` (32): its own constant, with the same value, no longer derived from the arena size. How many messages a VM can have in flight is now purely a heat question. - **Failures follow §8.1.** Each grow path (the arena directory, a segment, a queue ring, a member list, the reassembly scratch): - prints `Kernel-Hermes: can't grow <what>` in full; - logs at `LOG_ERROR`; - counts (`sk_hermes_grow_failures()`); - refuses whole. A send whose queue can't grow releases the message it already allocated, so the ledger stays exact. A reservoir refusal is a physics outcome, not a grow failure, and stays uncounted. - **The `kernel_main.c` alloc/release self-test.** Its `msgs[]` is sized from `expected_n + 1`, and every loop is bounded by that. The refusal after `expected_n` must now come from the reservoir. It prints `arena_capacity` and `grow_failures`. - **Not in scope:** the queue table and the channel table are still RAM-sized at boot. Those are S-3's per-VM-tables item. **Evidence** (probe, reverted; synthetic, quota-granted sender ids, not Tripod VMs; byte-identical on all three ISAs, 86 lines; `logs/20260924-170043/amd64/`, `-170507/aarch64/`, `-170940/riscv64/`): | Check | Result | |---|---| | Boot self-tests | All PASS; `expected_n = got_n = 32`, `arena_capacity=32`, `grow_failures=0`. | | Past 32, system-wide and in one queue | Three funded senders put **70** messages into one queue. 70 are live system-wide, and the arena grew 32 → 96. | | Refused by the reservoir | The sender with 30 in flight managed **2** more, then was refused with its reservoir at **0**. Grow failures: 0. | | Intact across the grows | All 72 popped in send order with the right payloads, and every release succeeded. | | Past 64 members | 100 subscribed, `member_count` 100, member 99 present; one unsubscribed gives 99; destroy OK. | | **Live drain across a grow** (the discriminating case) | The arena was filled to exactly 96/96. Hera's queue held a `SEND` payload (`: PZD 10 0 DO S" 1 DROP" S" Hestia" SEND DROP LOOP ; PZD`) followed by a marker. `sk_hermes_drain_checkpoint(Hera)` returned 1, the arena grew to **128 mid-drain**, and Hestia received **10**. The next head was the marker, **intact**; Hera's pending count went 2 → 1, and the marker then drained too. Audit failures 0. | | Forced failure, arena | With the arena **full, 128/128**, one injected segment failure: refused, +1 counted, reservoir untouched, capacity and ledger unchanged. The retry grows and succeeds. | | Forced failure, queue | With 8 queued, a 9th with an injected ring failure: refused, +1, still 8 pending, the message rolled back (held unchanged). The retry gives 9. | | Forced failure, members | The 9th subscribe with an injected failure: refused, +1, still 8. The retry gives 9. | | Forced failure, reassembly | Injected scratch failure: refused, +1. Separately, bad chunks are refused **without** counting as a grow failure. | | At rest | Grow failures exactly 4 (the injected ones). Audit failures 0, `scan_check` 1. Live messages are 10, Hestia's pending, and `held` delta = 10 × Q_SLOT. `stadium_conserved` true for every sender, the receiver and Hera. | *(The first two probe runs filled the arena short of full, so the arena-failure check found a free slot and never tried to grow. They were flawed probe runs, not M-0 failures. The fill was fixed and those logs deleted.)* **Acceptance** on the clean tree (`logs/20260924-171403/amd64/`, `-172648/aarch64/`, `-173952/riscv64/`): - Boot M7.1a and the `dict_hash` set are unchanged. - **The live ledger line is byte-identical to before M-0:** `held=2048 pulled=258048 returned=255263 consumed=737`. Q_SLOT's value and the message numbering did not move. - The O-A checks pass: 40/40/48, `HHH`, and `3` mid-stream. - The relay answers `3`, idle log growth is 0, there are no faults, and QEMU exits cleanly. - Clean builds on all three ISAs plus the hosted build. **Found, reported, not fixed:** the boot self-tests print `Kernel-Hermes drain self-test: SKIPPED (Hermes not live)` and `channel-open policy self-test: SKIPPED (Hera/Hermes not available)`. Both gates still look for the Hermes VM, which was removed in FABRIC-3.6 Phase 4, so two self-tests have silently never run since then. -
Stage M-A — manual messaging words (
FABRIC-3.9.0.md§7.2, rulings M-1/M-2). 2026-09-24. Captain Bob: "go ahead with M-A." The names are as ruled, with one exception found at the start:LEAVEis FORTH-79's DO-loop exit, and registering a messagingLEAVEwould have shadowed it everywhere. Captain Bob: "UNJOIN" (§7.2's row is updated).JOIN-VM/LEAVE-VMdon't collide and stay as ruled.**The change:** - **`repl.c`:** eleven words beside `SEND`, registered for every VM: | Word | Stack | |---|---| | `CHANNEL` | `( -- ch \| -1 )` | | `CHANNEL-CLOSE` | `( ch -- ok? )`, refuses channel 0 | | `JOIN` / `UNJOIN` | `( ch -- ok? )`, the calling VM | | `JOIN-VM` / `LEAVE-VM` | `( ch name-c name-u -- ok? )` | | `PUBLISH` | `( paddr plen ch -- n )`, n delivered, or -1 for a bad channel or an oversized payload | | `INJECT` | `( paddr plen name-c name-u -- ok? )` | | `MAIL?` | `( name-c name-u -- n )`, -1 if there is no such VM | | `.MAIL` | `( name-c name-u -- )`: index, type, sender, length, first 32 characters | | `.CHANNELS` | `( -- )`: each channel in use with its members, by name | - `from` is always the calling VM, derived in C, never taken from the stack. - Payloads get the same one-block bound as `SEND`: refused, never truncated. - **`INJECT`** (ruling M-2): sender recorded as the root (Hera) and funded from her reservoir, with a new type, `SK_HERMES_MSG_TYPE_INJECT` (31), so `.MAIL` and the log can tell it from a `SEND`. - **ACL-gated:** pinned STRICT in `kernel_main.c`, beside `BIRTH`. That is the sanctioned home for kernel-only words, since `ACL.4th` must stay host-portable. - The policy itself is `ACL-RECHECK`'s. Today that allows STRICT words and rechecks them on every call, so the gate is in place and the policy is `ACL.4th`'s to tighten. - The pin is on Hera's dictionary, as `BIRTH`'s is. Other VMs' copies are unpinned. - **`kernel_hermes.c/.h`:** two read-only accessors, `sk_hermes_pending_at()` and `sk_hermes_channel_member_at()`, for `.MAIL` and `.CHANNELS`. **Evidence** (typed at `[zuse@Hera] ok>`; the same transcript on all three ISAs; `logs/20260924-183922/amd64/`, `-185413/aarch64/`, `-190905/riscv64/`): | Line typed | Result | |---|---| | `CHANNEL DUP . CONSTANT CH1`, then `JOIN-VM` of `wa` and `wb` | `1`, then `1 1` | | `.CHANNELS` | `ch 0 (5 members): Hera Hestia Artemis wa wb` / `ch 1 (2 members): wa wb` | | `S" 77 EMIT" CH1 PUBLISH . S" wa" MAIL? . S" wb" MAIL? .` | `2 1 1`, then `MM`: **both printed** | | `CH1 S" wb" LEAVE-VM .`, then the same `PUBLISH` with 78 | `1 1 1 0`, then one `N` | | `S" 79 EMIT" S" wa" INJECT . S" wa" MAIL? . S" wa" .MAIL` | `1 1`, then `MAIL wa: 1 pending` / `#0 type=31 from=Hera len=8 "79 EMIT"`, **before `wa` ran it**; then `O` | | `CH1 CHANNEL-CLOSE . 0 CHANNEL-CLOSE .` | `1 0`; channel 1 is gone from `.CHANNELS` | | `CHANNEL CONSTANT CH2 CH2 JOIN .`, then `CH2 UNJOIN . CH2 CHANNEL-CLOSE .` | `1`; `.CHANNELS` shows `ch 1 (1 members): Hera`; then `1 1` | | `S" 1" S" nosuch" INJECT . S" nosuch" MAIL? . S" 1" 999 PUBLISH .` | `0 -1 -1` | Boot prints `ACL: INJECT pinned STRICT`. **Acceptance**, on the same boots, run M-A first, then O-A, then acceptance: - **The `dict_hash` set changes by the 11 new words and is identical across ISAs.** M7.1a is `word_count=542` (was 531), `latest_id=711`, `hash=0x719abceff4be5564`. Hera `0xf0024c865b83190e`, Hestia `0x544d50d7a8d96893`, Artemis `0x7e40bf2675813654`. - The G interleave is 40/40/48, byte-identical across ISAs. It differs slightly from the fresh-worker string, because `wa`/`wb` had already run the M-A payloads. - `HHH` precedence, and `3` mid-stream. - The ledger line is `pulled=266240`: exactly 4 × Q_SLOT above the pre-M-A value, for the 2 + 1 `PUBLISH` copies and 1 `INJECT`. `stadium_conserved` holds. - The relay answers `3`, idle log growth is 0, there are no faults, and QEMU exits cleanly. - Clean builds on all three ISAs plus the hosted build. **Found, reported, not fixed:** riscv64 logs intermittent `virtio_blk: vblk_io timed out (write=1 …)` errors on disk writes, four during this boot's POST and one during the first `WORKER-BIRTH`. That last one is the only difference between the riscv64 and amd64 M-A transcripts. It is pre-existing: the same message is in riscv64 logs back to 2026-09-10, including this morning's S-1b follow-up acceptance (`-142946`). A TCG timing issue in the virtio-blk driver, not M-A. -
Fix, ahead of M-B: a dead VM leaves kernel-Hermes (found while starting M-B, 2026-09-24). Captain Bob: "Fix first, own commit."
**The defect.** `capsule_vm_kill()` and `capsule_vm_force_reap()` tore a VM down but never touched kernel-Hermes: - The dead VM stayed a member of every channel it had joined, channel 0 included. It showed in `.CHANNELS` as a nameless `?…` member. - Its undelivered mail was never released. - Anything published to it afterwards was queued to a VM that would never drain it, its heat held forever. M-B makes channel delivery automatic, so every event reaching a dead subscriber would have leaked. **The change:** - `sk_hermes_vm_departed(vm_id, …)` (`kernel_hermes.c`) drops the VM from every channel and releases and pops its pending queue. Each message's heat goes back to whoever funded it, and the ledger stays exact. The queue-table entry is freed for reuse. - `capsule_birth.c` calls it from both `capsule_vm_kill()` and `capsule_vm_force_reap()`, and reports `HERMES: <name> departed -- left N channel(s), released M pending`. - `capsule_vm_kill_all_nonmama()` is left alone: its only callers (BYE, cold restart) reset the machine immediately afterwards. **Evidence** (console; the same transcript on all three ISAs; `logs/20260924-192807/amd64/`, `-194215/aarch64/`, `-195620/riscv64/`): - `CD S" wa" JOIN-VM .` then `.CHANNELS` shows `wa` in channel 0 and `CD`. - With 2 `SEND`s pending to `wa` (`MAIL?` gives 2, and Hera's `STADIUM-RES@` fell by exactly 4096 = 2 × Q_SLOT), `S" wa" KILL` gives `HERMES: wa departed -- left 2 channel(s), released 2 pending`. - `.CHANNELS` then shows `ch 0 (4 members): Hera Hestia Artemis wb` and `CD` with 0 members. - `S" 1 DROP" CD PUBLISH .` gives `0`: nothing queued to the dead VM. - `wb`, killed with no mail, gives `left 1 channel(s), released 0 pending`. - **The exact check is the ledger.** On the same boots, acceptance's relay shows `held=2048`, the one in-flight console message. Without the release, `wa`'s two stranded messages would have left it at 6144. - Hera's reservoir is not a clean meter for this: it sits at the word-dispatch floor (Q48_ONE / 3 = 21845), and Stadium word-cooling pushes heat back into it between lines. **Not exercised:** the `capsule_vm_force_reap()` call. No reap happened in these runs. It shares the same helper. **Acceptance** (same boots): boot parity and the `dict_hash` set are unchanged from M-A, and the O-A checks pass (40/40/48, `HHH`, `3` mid-stream). The relay answers `3`, idle log growth is 0, there are no faults, and QEMU exits cleanly. -
Stage M-B — autonomous events (
FABRIC-3.9.0.md§7.3, rulings M-3 to M-6). 2026-09-24. Captain Bob: "go ahead with M-B."**The change:** - **Event channels.** `sk_hermes_channels_boot_init()` creates channels **1 `EV-DONE`, 2 `EV-BORN`, 3 `EV-DIED`** beside channel 0. - All four are permanent: `sk_hermes_channel_destroy()` and `CHANNEL-CLOSE` refuse 0–3. - The first `CHANNEL` a user makes is now 4. - FORTH constants `EV-DONE` `EV-BORN` `EV-DIED` push the ids, so `JOIN` and the ACL can name them. - **`sk_hermes_event()`** (`kernel_hermes.c`) publishes `S" <name>" ON-DONE|ON-BORN|ON-DIED` as the new type `SK_HERMES_MSG_TYPE_EVENT` (32). - It goes to every member **except the VM the event is about**. - **Never blocking (M-6):** a copy that can't be admitted is dropped, counted, printed and logged at `LOG_ERROR`. - The counter is readable as `EV-DROPPED ( -- n )`, as §7.3 asks. The name is mine; it wasn't in the ruled word list. - **The firing points**, participants only (M-4) and funded per M-5: | Event | Fired from | Funded by | |---|---|---| | `EV-DONE` | `switch.c`'s trampoline: a participant that did real work finds its queue empty | the VM itself | | `EV-BORN` | `capsule_vm_registry_set_name()`, on a LIVE VM's first naming | the newborn, whose quota was granted during birth | | `EV-DIED` | the departure helper, from `KILL` and reap, after the VM has left every channel | the root (Hera) | - `capsule_birth_baby()` leaves every VM unnamed, and each birth path (`BIRTH`, `WORKER-BIRTH`, identity births, console, runcap) names it right afterwards. So a VM's first naming is its birth moment, under the name its subscribers know. - For `EV-DIED`, each caller records the observer flag before `sk_vm_switch_signal_unregister()` erases it. - **Storm guard.** `VM.ev_real_work` is set only when the drain handles a message that is *not* an event. `EV-DONE` fires, and clears it, only when that work runs dry. - Together with "never to the VM it's about", this stops two VMs that both joined `EV-DONE` from answering each other's completions forever. - Several messages queued together give one `EV-DONE`: the VM goes from having work to having none once. **Evidence: console** (clean tree; the same transcript on all three ISAs; `logs/20260924-201420/amd64/`, `-202909/aarch64/`, `-204414/riscv64/`). Hera defines `: ON-BORN ." [BORN " TYPE ." ]" ;` (and `ON-DONE`, `ON-DIED` likewise), then `EV-DONE JOIN . EV-BORN JOIN . EV-DIED JOIN .`, which gives `1 1 1`. The ids are `1 2 3`. No job text contains a `SEND`: | Action | Hera sees | |---|---| | `WORKER-BIRTH` of `wa`, then of `wb` | `[BORN wa]`, `[BORN wb]`: one each | | Two messages queued to `wa` in one line, one to `wb` | **one** `[DONE wa]`, one `[DONE wb]` | | Storm guard: `wa` and `wb` define `ON-DONE` as `42 EMIT` and are `JOIN-VM`'d to `EV-DONE`; then one job to `wa` | `[DONE wa]` to Hera, and **exactly one `*`** from `wb`. `wb` handling an event fired nothing; `wa` never got its own event. | | `EV-DONE CHANNEL-CLOSE .` (and `EV-BORN`, `EV-DIED`) | `0 0 0`: permanent | | `S" Hestia" MAIL? . S" Artemis" MAIL? .` | `0 0`: VMs that didn't join see nothing, and there are zero `UNKNOWN WORD`s anywhere | | `EV-DROPPED .` | `0` in normal use | | `KILL` of `wa`, then of `wb` | `[DIED wa]`, `[DIED wb]`, after each `HERMES: … departed` | | Then `UNJOIN` of all three | `1 1 1`, and the event channels are empty | **Evidence: flood probe** (reverted; separate boots; byte-identical on all three ISAs; `logs/20260924-205927/amd64/`, `-210352/aarch64/`, `-210825/riscv64/`): - 40 synthetic ids join `EV-DIED`. One `EV-DIED` fires about one of them, funded by a sender whose reservoir pays for 32. - Result: `delivered=32 dropped=7` (39 recipients, since the VM it's about is excluded), and `Kernel-Hermes: event ON-DIED for PZFLOOD dropped for 7 member(s)`. - The call **returned**: never blocked. - The "about" member got nothing, while another member has 1 pending. - The departure cleanup released all 32. `held` and the sender's reservoir came back exactly, with audit failures 0 and `stadium_conserved` true. **Acceptance** (clean-tree boots): - **The `dict_hash` set changes by the 4 new words and is identical across ISAs.** M7.1a is `word_count=546`, `latest_id=715`, `hash=0x99b01134c3d3049c`. Hera `0x00340a8b2c842eb7`, Hestia `0x78bf28439e4a643f`, Artemis `0x66886d174d5f3b5a`. - The G interleave is the fresh-worker string, 40/40/48. `HHH` precedence, and `3` mid-stream. - The relay ledger shows `held=2048`: every event's heat was released. - Idle log growth is 0, there are no faults, and QEMU exits cleanly. Clean builds on all three ISAs plus the hosted build. -
Stage O-B — Hera acts on her own mail the moment she is idle (
FABRIC-3.9.0.md§2.5). 2026-09-24. Captain Bob: "go ahead with O-B."**The change:** - **`sk_vm_idle_point(int may_drain_self)`** (`switch.c`): when Hera is running with no frame live and has mail, it drains one message, then returns to the caller's poll loop, which calls it again. - The existing `frames.count != 0` check already refuses whenever a line, word, `KEY`/ `EXPECT`/`QUERY` or drain of hers is in progress. So the drain can't re-enter her interpreter mid-parse, which is the hazard the old flags guarded. - **The callers** (`repl.c`): - readline passes **`n == 0`**, keeping the mid-edit gate: a drained payload never prints into a line being typed; - the headless wait passes 1; - the blocking key read passes 0, since a key read is someone mid-interaction, and it is always inside a word anyway. - **Removed:** `sk_repl_idle()`'s timer-paced drain of Hera's queue (one message per `SK_IDLE_BEAT_INTERVAL` = 100 beats, about 1 s), and with it the two flags that guarded it (`g_mama_interpreting`, `g_idle_pump_active`). - Nothing else read them. The dispatch's set/restore of `g_mama_interpreting` is gone too. - `sk_repl_idle()` keeps its USB and block-sync servicing, unchanged. **Evidence.** A probe (reverted) stamped each message to Hera with the beat count at admission, and printed the gap when she drained it. On the same boots, run from the console: - `wa` defines `PINGH`, which sends `72 EMIT` to Hera. Both strings are built in `wa`'s own `PAD`, because an `S"` can't nest inside the `S"` that carries the job. - Jobs `: PZW 30000 0 DO LOOP PINGH ; PZW` (idle case) and `3000000` iterations (mid-edit case). Logs: `logs/20260924-213049/amd64/`, `-214100/aarch64/`, `-214803/riscv64/`. | Case | amd64 | aarch64 | riscv64 | |---|---|---|---| | **Idle at the prompt**, 3 runs: admission → drain | **0, 0, 0 beats** | 0, 1, 0 | 1, 0, 0 | | **Mid-edit:** the message arrives while `1 2` is typed but not entered; ` + .` Enter about 40 s later | drained only after the line ran (`3 ok`, then `H`), **3587 beats** | drained at the entered line's first word boundary (`H3 ok`), 2080 beats | same as aarch64, 2613 beats | | **Busy:** `EV-BORN` queued while Hera runs `WORKER-BIRTH` | 14 beats, the moment the line ended | 1 | 0 | | **`BLK-ATTACH`** (type 9) at boot, from Artemis | 1 beat | 1 | 1 | - Before this stage, a message to an idle Hera waited for the next idle beat: up to 100 beats. Now it is 0–1. - The gate held on every ISA: nothing drained while the line was being edited. - Once Enter is pressed the message goes at the first clean boundary. That is either Hera's existing in-word drain checkpoint (FABRIC-3.8 Stage B) at the entered line's first word boundary, or the idle point after the line. Which comes first is timing, so the order of `H` and `3` differs by ISA. Either way it is a line boundary, never inside typed text. - The amd64 log also holds a first pass of the script whose job texts used a bare `DO … LOOP` in interpret mode (`DO: compile-only`). That was a script error, corrected in the same boot, and all the figures above come from the corrected pass. **Acceptance** on the clean tree (`logs/20260924-215452/amd64/`, `-220741/aarch64/`, `-222049/riscv64/`): - Boot parity and the `dict_hash` set are unchanged from M-B (546 words, `0x99b01134c3d3049c`). - The O-A checks pass (40/40/48, `HHH`, `3` mid-stream). - The relay and `BLK-ATTACH` flows are unchanged: mint → WIREBIND → `USE` → `3`, with the ledger at the standard `held=2048 pulled=258048 returned=255263 consumed=737`. - Idle log growth is 0, there are no faults, and QEMU exits cleanly.