- 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>
741 lines
54 KiB
Markdown
741 lines
54 KiB
Markdown
# 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 zero `UNKNOWN WORD`, faults and frame corruption;
|
||
- the `dict_hash` set 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 `JOIN`s `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_hash` diverges 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
|
||
|
||
- [x] **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 checkout` of
|
||
`capsule_vm_physics.c/.h`), per ruling 5.
|
||
- **`capsule_vm_switch_signal.c/.h`:**
|
||
- Each slot carries an `observer` flag, with `sk_vm_switch_signal_set_observer()` and
|
||
`is_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_foundation` is 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.
|
||
- [x] **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.
|
||
- [x] **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.
|
||
- [x] **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.
|
||
- [x] **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.
|
||
- [x] **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.
|
||
- [x] **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: **`LEAVE` is FORTH-79's DO-loop exit**, and registering a messaging
|
||
`LEAVE` would have shadowed it everywhere. Captain Bob: *"UNJOIN"* (§7.2's row is updated).
|
||
`JOIN-VM`/`LEAVE-VM` don'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.
|
||
- [x] **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.
|
||
- [x] **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.
|
||
- [x] **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.
|