# FABRIC-3.8.md — Real concurrency: finishing what the reshuffle started **Status: OPEN — Stages A, B and FR (VM frames) done (§6); C–I not started. §3.11 ruled (coolest VM with pending work, by physical comparison; no scheduler).** Each stage below needs Captain Bob's explicit go, gets its own commit, and gets its own three-architecture acceptance (`clean qemu`, amd64 → aarch64 → riscv64, foreground, visible framebuffer, one at a time). **Provenance.** Opened 2026-09-23. The multiuser/multitasking DoE (`FABRIC-3.md` §XXXIII–§XXXV) was launched on amd64 (`12345 3 S" amd64" DOE`, `logs/20260923-125936/`) and stopped by direct instruction about 49 minutes in, with zero trials complete, once it was established that the harness measures a **cooperative, serialized round-robin**, not concurrency (log and CSV kept). Captain Bob: *"that was the point of this branch"* — the `v2.1.0` Tripod/kernel reshuffle (`FABRIC-3.5.md`/`-3.6.md`, now on `master`) was meant to deliver real concurrent execution. This document records why it doesn't yet, and the design that finishes it. **The governing objective, Captain Bob's words:** *"the object: doe contains zero dependence on special execution states."* Concurrency must be a property of the OS for **every** VM. The DoE may only use ordinary words (birth a VM, send it a message, kill it). No DoE-specific registration, no DoE-specific dispatch path, no special mode. If the DoE needs something the OS doesn't give every VM, that's an OS defect to fix here, not a harness workaround. Successor-in-topic to `FABRIC-3.5.md`/`FABRIC-3.6.md` (both CLOSED/ARCHIVAL at `v2.1.0`), whose close headers say further fleet work gets its own document. `FABRIC-3.md` stays open and authoritative for bare-metal boot; this doesn't supersede it. --- ## 1. What the reshuffle ruled, and what actually landed **Ruled** (`FABRIC-3.5.md`): - §XXXII: *kernel-Hermes publishes eligibility; it does not dispatch.* The switcher is **the sole mover of control**. The MSG-TICK idle pump is retired. - §XLIII: *the target drains its own queue, in its own context, at its own outermost checkpoint.* Nobody `VM-EXEC`s into anybody. - §XV.3: nobody owns turn order. It's compudynamic. **Landed** (`FABRIC-3.6.md` Phases 0–5, `v2.1.0`): the message allocator and heat ledger, per-VM pending queues, the drain function, channels, chunking, per-VM native stacks (§XXVIII Stage 1), the switch primitive (Stage 2), the timer-driven switch signal (Stage 3), and WIREBIND participation (Stage 4). **Not landed, traced 2026-09-23 against this branch (`e40cafc`, same code as `origin/master` for every file below):** | # | Gap | Evidence | |---|---|---| | G1 | **Kernel-Hermes never publishes eligibility.** `sk_hermes_send_one()` doesn't call `sk_vm_switch_signal_mark_work()`. `has_work` is never set, so the switcher never switches. | `kernel_hermes.c:485–537` — zero `mark_work` references in the file. The only caller left is the `SWITCH-MARK-WORK` word (`mama_forth_words.c:867`), which had one caller: FORTH `MSG-SEND`, deleted with `messaging.4th` in Phase 4. | | G2 | **A switched-to VM does nothing.** First entry lands in the Stage-2 placeholder, which yields straight back forever. | `switch.c:100–105`, `sk_vm_switch_entry()`. Its own comment: "no production behavior defined yet." | | G3 | **Delivery to passive VMs is still the pump.** Task 3.9 put the kernel-Hermes drain into `sk_repl_idle()`'s registry walk: Hera calls `vm_interpret()` on each live VM, **on Hera's own physical stack**, once per idle beat. | `repl.c:909–928`. §XXXII ruled this pump retired. | | G4 | **Only the Tripod and WIREBIND user VMs are switch participants.** VMs born through `capsule_birth_baby()` (`BIRTH`, `CAPSULE-BIRTH`, `WORKER-BIRTH`, `UNATTENDED-BIRTH`) never register. | `kernel_main.c:1261/1270/1280`, `capsule_wirebind.c:316` — the only `sk_vm_switch_signal_register()` call sites. | | G5 | **The stack-ownership gate is a single global.** `g_vm_interpret_depth` counts *all* `vm_interpret()` nesting across all VMs, and the gate is `depth <= 1`. Once VMs genuinely interleave on their own stacks, that global stops meaning "this VM owns this stack at its outermost frame." Same for `g_log_attrib_vm`. | `vm_core.c:1164–1178`, `:1183–1220`. | | G6 | **Kill vs. parked VMs.** `capsule_vm_kill()` refuses any `VM_STATE_SWITCHED_OUT` VM, and the ordinary kill path never unregisters from the switcher (only `capsule_vm_force_reap()` does). A worker that finished and parked could never be killed; one killed some other way would leave a stale switch slot. | `capsule_birth.c:412–` (refusal at the function's `SWITCHED_OUT` check); `capsule_birth.c:371` (the only unregister in that file, in the reap path). | | G7 | **Switches only happen inside word dispatch.** The checkpoint lives in `execute_colon_word()`. A VM sitting at an idle point (Hera at her REPL, blocked in `sk_console_readline()`) never takes a pending switch. | `vm_core.c:913–990`. `sk_repl_idle()` never calls `sk_vm_switch_signal_take_pending()`. | | G8 | **Shared file-scope state.** `RANDOM`/`SEED` share one `g_prng_state` across every VM. Under interleaving, one VM's draws perturb another's stream, and reproducibility of any seeded sequence becomes timing-dependent. | `starforth_words.c:71`, `:657`. The Isabelle sweep memory lists more of this class (control-flow compile stack, vocabulary globals). | **Latent hazard created by combining G1 with G3.** Wiring G1 alone would wake the switcher for every message recipient, WIREBIND `~user` VMs included, and those are already participants. The pump (G3) runs their relayed console commands via `vm_interpret()` on **Hera's** stack at global depth 1, so the gate (G5) passes. The first pending switch would then save Hera's stack pointer under the `~user` VM's `native_stack_saved_sp`. That's `FABRIC-3.md` §XXVIII.1's stack-ownership corruption, reborn on the live console path. **G1 must not land before G5 and G3 are fixed** (§4). **Why the DoE couldn't see any of this.** `multiuser-doe.4th`'s `MU-TOUCH-ALL` calls `VM-EXEC` into `mu0…muN` in index order. `VM-EXEC` runs `vm_interpret()` synchronously on Hera's stack at depth 2, where the gate defers anyway. So the "concurrency" factor (2/4/8) counted resident VMs; execution was strictly serialized. That's `FABRIC-3.md` §XIX's own named null hypothesis. --- ## 2. Design principles 1. **One mover.** Only `sk_vm_context_switch()`, driven by the switch signal, moves control between VMs. Nothing else runs a VM's code on another VM's stack. (§XXXII) 2. **Work runs on its owner's stack.** A VM's payloads and words execute only while that VM physically owns the CPU stack it's standing on. This is the property the depth gate was approximating. 3. **Kernel-Hermes publishes facts.** It enqueues, and marks the recipient as having work. It never dispatches or orders anything. (§XXXII, §XV.3) 4. **Generic, not DoE-shaped.** Every VM born through `capsule_birth_baby()` is a participant, drained the same way, killable the same way. The DoE gets concurrency by using `BIRTH` and a message send, exactly like any future program would. 5. ~~**Nothing new decides turn order.** The existing readiness/threshold signal stays as it is. Its hardcoded `50` threshold remains the flagged seam (`FABRIC-3.5.md` §XV.3), not something this pass tunes.~~ **CORRECTED 2026-09-23, Captain Bob: *"the timer is NOT the source of preemption, compudynamics is."*** Today's switch decision is timer-driven: `heartbeat_tick()` (`heartbeat.c:179`, ISR context) calls `sk_vm_switch_signal_tick()`, which adds one unit of readiness per timer tick to every non-current participant and requests a switch once readiness passes a fixed `50` (`capsule_vm_switch_signal.c:58`, `:191–210`). That's a time-slice scheduler with a threshold, the opposite of `FABRIC-3.5.md` §XV.3 ("turn order is compudynamic"). **Principle 5 is replaced by:** *the decision to preempt, and whom to run, must come from the compudynamics (the physics the fleet already computes), not from elapsed timer ticks.* §3.11 holds the design question; nothing is decided there yet. Stage A (the ownership gate, §3.1) is unaffected: it governs *where* a switch may safely happen, not *whether* one should. --- ## 3. Mechanism ### 3.1 The ownership gate becomes per-VM (G5) Replace the global depth test with the real invariant: > A checkpoint may switch or drain only when **`sk_vm_switch_current_vm() == vm`** (this VM > physically owns the stack) **and `vm->interpret_depth == 1`** (at its own outermost > interpret frame). - New per-VM field `interpret_depth`, incremented and decremented in `vm_interpret()` beside the existing global. The global stays for log attribution until §3.7 retires it. - `sk_vm_switch_current_vm()` (`switch.c:68–72`) already tracks the physical owner, seeded at boot for Hera and updated on every switch. It becomes load-bearing here, which is what it was built for. - A nested `VM-EXEC` into another VM fails the first clause (that VM isn't the stack owner), so the existing defer-if-nested behavior is preserved exactly, now by construction. - **Also gate on `vm->mode != MODE_COMPILE`**: never switch or drain mid-definition (G8's compile statics). ### 3.2 The trampoline becomes every participant's idle loop (G2) `sk_vm_switch_entry()` stops being a pure bounce. On each entry or resume: 1. If this VM has a pending message: set `vm->hermes_draining = 1`, drain **one** message (`sk_hermes_drain_checkpoint()`, ruled one per checkpoint, `FABRIC-3.5.md` §XLVI 3.0e), clear the flag. 2. Otherwise, mark this VM **parked-idle** (§3.5) and yield back to `native_switch_back_to`. The payload runs via `vm_interpret()` at this VM's depth 1, on its own stack. So its own word checkpoints can preempt it (§3.1 passes), and preemption now happens inside real work, not just at message boundaries. ### 3.3 One drain at a time per VM (nested drain) `FABRIC-3.5.md` §XLIII.5's "recursive drain prevented for free" assumed every checkpoint sits under some outer `vm_interpret()`. With the trampoline as a base, a payload runs at depth 1, and the existing in-word drain (`vm_core.c:923–925`) would then drain a *second* message **inside** the first's execution. **Rule:** a per-VM `hermes_draining` flag. No drain site drains while it's set. The in-word drain site stays: it's how Hera and any VM busy running a long word still receive mail. It just can't nest. ### 3.4 Eligibility is published, and the pump stops draining participants (G1 + G3, one commit) - `sk_hermes_send_one()` calls `sk_vm_switch_signal_mark_work(to)` after a successful enqueue. That's the §XXXII ruling, one line. - `sk_repl_idle()`'s registry walk (`repl.c:909–920`) **skips switch participants.** Their queues are drained only by themselves (§3.2). Non-participants, if any remain, keep today's behavior. Once §3.6 makes every born VM a participant, the loop becomes dead code, and it's stripped in its own commit per this series' strip discipline. - These two changes **must land together**. Either alone is either inert (mark without trampoline behavior) or the §1 latent hazard. ### 3.5 Parked-idle is a killable state (G6) `VM_STATE_SWITCHED_OUT` currently means "a live frame is parked here, don't free," and that's correct mid-work. A VM that yielded from its trampoline's idle branch (§3.2 step 2) has nothing borrowed on its stack and nothing in progress. **New per-VM flag `parked_idle`**, set only on that branch and cleared on every switch-in. `capsule_vm_kill()` allows a `SWITCHED_OUT` VM whose `parked_idle` is set. The same function gains the `sk_vm_switch_signal_unregister()` call its reap sibling already has (`capsule_birth.c:371`). A plain flag, not a new `VMState`, following the `pending_reap` precedent (`FABRIC-3.md` §XXX: `VM_STATE_DEAD` is treated as idempotent success and would swallow it). ### 3.6 Every born VM participates (G4) `capsule_birth_baby()` registers the new VM with the switch signal at its `VM_STATE_LIVE` completion point. That's the same choke point task 3.2 already uses to subscribe every VM to the common channel (`FABRIC-3.6.md` task 3.2). Registration happens **after** birth completes, never before, the same rule `kernel_main.c`'s Tripod block states. `WORKER-BIRTH`/`UNATTENDED-BIRTH`/ `BIRTH` gain nothing of their own. `kernel_main.c`'s explicit Artemis/Hestia calls and `capsule_wirebind.c:316` become duplicates. Remove or guard them (registering twice would double-allocate a slot), in the same commit. **Birth itself stays synchronous.** `capsule_birth_baby()` runs the capsule's code to completion before returning. Any workload a capsule self-executes at load (every `workload-N.4th` does, `FABRIC-3.md` §XXXIII.4) therefore still runs serially at birth. That's addressed in §3.9 as a DoE ruling, not an OS change. ### 3.7 An idle point is a switch point (G7) `sk_repl_idle()` runs on Hera's own stack at depth 0. That's a clean switch point by §3.1's invariant. It checks `sk_vm_switch_signal_take_pending()` and performs the switch, exactly as the word-dispatch checkpoint does. Without this, concurrency stops whenever Hera sits at her prompt, which is exactly when a human has launched background work and is waiting. **`sk_repl_headless_wait()` gets the same treatment**, since it's also an idle loop on Hera's stack. Neither may switch while `g_mama_interpreting`/`g_idle_pump_active` is set (the existing reentrancy guards stay until proven dead). ### 3.8 A generic send word (needed by any program, not just the DoE) There's no general FORTH-callable kernel-Hermes send today, only the fixed-purpose `KH-BLK-ATTACH-SEND`/`KH-ELEVATE-SEND` (`repl.c:385–488`), each hardwired to send to Hera. **New word `SEND ( paddr plen name-c name-u -- ok? )`** (name is Bob's call, see §5): resolves the target by registry name, sends a copy of the payload via `sk_hermes_send_one()` (so G1's eligibility mark fires), and returns 0/1. `from` is the calling VM, derived in C, never taken from the stack, the same anti-spoof rule `KH-ELEVATE-SEND` established. Payload bound is one block (1024), per `FABRIC-3.5.md` §XLV.3. Registered for every VM. A plain unconditional primitive: restricting it, if ever wanted, is `ACL.4th`, never a C check. ### 3.9 The DoE, rebuilt on ordinary words (after §3.1–§3.8 land) Using only `BIRTH`-family words, `SEND`, and `KILL`: 1. **Birth** N workers (serial, as all birth is). 2. **Send** each worker its workload command as a message. Workers now run **concurrently**, each on its own stack, preempted by the switch signal. 3. Each workload payload ends by `SEND`ing a completion line back to Hera (e.g. `" MU-DONE"`), which runs in Hera's own context when she drains it and increments a counter. 4. Hera **waits with an ordinary loop** (`BEGIN … MU-DONE-COUNT @ N = UNTIL`). Her word checkpoints drain her mail and let the switcher preempt her. No special wait state. 5. Check errors, `KILL` each worker (legal now they're parked-idle, §3.5), and print and persist the trial marker as today. **DoE ruling needed (§5):** workload capsules self-execute at load, so the heavy work happens serially at birth. For the concurrent phase to be the measured phase, workers should be born from **definitions-only** capsules, with the workload arriving as the first message. ### 3.10 Shared state that interleaving exposes (G8), stated rather than fixed - `g_prng_state`: the DoE's Fisher-Yates shuffle runs before any worker exists, so the **run order** stays seed-deterministic across ISAs. Any `RANDOM` a workload calls mid-trial is not. Whether that matters depends on which workloads draw from it (not yet checked). - The compile-time control-flow and vocabulary statics named by the Isabelle sweep: protected for switches by §3.1's `MODE_COMPILE` gate. A payload that *itself* compiles (`:` … `;`) is protected as long as it can't be preempted mid-definition, and the gate guarantees that. - **Not fixed in this pass.** Making these per-VM is real work in vendored shared source (`src/word_source/`), which is `FABRIC-4.md`'s declared scope. ### 3.11 Preemption comes from the compudynamics, not the timer (OPEN, Captain Bob's ruling of principle) **Ruled:** the timer isn't the source of preemption; the compudynamics is. **Not yet designed:** which physical quantity decides "switch now, to whom." Material already in the tree, listed so the design starts from what exists (traced, not proposed): - **VM-physics heat** (`execution_heat_q48`, `capsule_vm_physics.c`; FORTH `VM-HEAT`). Moves when a VM does real work (`vm_physics_touch()`) and is conserved fleet-wide (fleet K). `FABRIC-3.md` §XIX already argued the turn-attractor from this: *the coolest live VM with pending work goes next*, the same least-dense judgment `stadium_admit()` uses for eviction, applied to "whose turn." - **Stadium heat/reservoir** (per-VM quota conservation, `stadium_conserved()`). A message's heat is the Stadium cell it occupies (`FABRIC-3.5.md` §XL.4), so pending mail already has a physical weight. - **Loop #7's adaptive heartbeat** (`HeartbeatState`, per VM). This project's "one and only clock" (memory `project_acl_ttl_overhead_final`), and adaptive rather than a fixed period. Distinct from the hardware timer ISR (`heartbeat_ticks()`), which is what currently drives readiness. **The shape this implies, for ruling (not decided):** the switch decision moves out of ISR readiness counting and into the ownership-gated checkpoint (§3.1), where it compares physics. Switch when some VM with pending work (`has_work`) is compudynamically favored over the current one (e.g. cooler per §XIX), and never merely because N timer ticks have elapsed. The timer ISR would then keep only what physics needs from it, and nothing that decides turns. The fixed `50` threshold and the per-tick readiness accrual go away. **Clarified 2026-09-23, Captain Bob:** *"this is kind of like a mixture of preemptive and cooperative multitasking… It's the compudynamics that drives the whole thing. Not necessarily a preemptive timer. Not necessarily cooperatively, as in 'when I finish my task, I move on'. But it's a mixture of both."* Read as: - **Where a switch may happen is cooperative in shape.** Only at a VM's own clean boundary, the ownership-gated word checkpoint (§3.1): stack owned, own outermost frame, not compiling. Never at an arbitrary instruction, never because an interrupt fired. - **Whether, and to whom, is preemptive in effect.** A running VM doesn't hold the CPU until it finishes. At any such boundary it can be displaced mid-task when the compudynamics favors another VM with pending work. - **Running out of work parks a VM.** That's the plain cooperative case (§3.2's idle loop). - **Neither a clock nor task completion decides. The physics does.** **Constraint, Captain Bob 2026-09-23:** *"make sure nothing inside of a given execution state or VM controls this. There are no special words saying I'm giving up control or anything like that. This is strictly all operating system and strictly all compudynamically driven."* No yield word, no `PAUSE`, no "I'm done" word, anywhere. Parking happens in the kernel's trampoline when the OS sees an empty queue (§3.2), and every switch decision is kernel-side. **Also a Stage C input:** the trampoline's point between two messages is a clean switch point (own stack, depth 0). The compudynamic decision has to be consulted there too, or a VM whose queue holds only checkpoint-free payloads (primitive words, no colon calls) never yields. Noted in `sk_vm_switch_entry()`'s own comment; not built in Stage B. **RULED 2026-09-23, Captain Bob:** *"VM frames first, then coolest VM with work goes next"* — and, immediately after: ***"no scheduler allowed."*** So the switch decision at Stage C is a **physical comparison made at the ownership-gated checkpoint**. Among VMs with pending work, the one with the lowest heat is favored over the one running, the same least-dense judgment `stadium_admit()` already makes for eviction and the §XIX turn-attractor argument. It is **not** a scheduler: no run queue, no priority list, no ordering data structure, no component that owns or computes an order, nothing that runs on a timer. The comparison is re-evaluated only at the moments the physics already exposes (checkpoints, the trampoline's between-message point, idle points), and the heat it reads is the heat the fleet already conserves. The per-tick readiness accrual and the fixed `50` threshold are removed at Stage C, not tuned. ~~**This is a design question for Captain Bob before Stage C**~~ (settled above) (Stage C is the first stage where anything actually switches). Stages A and B don't depend on it. ### 3.12 VM frames: per-VM execution state that travels with the VM (OPEN, raised by Captain Bob) **Captain Bob, 2026-09-23:** *"I'm thinking we may need VM frames for state."* Once VMs genuinely interleave, any execution context that isn't already inside the `VM` struct or on the VM's own native stack is shared across VMs, and it goes wrong at the first switch that lands inside it. The Stage B probe hit an instance of this class on its first run (§6, the amd64 alignment finding is the same family: state the first-entry frame should have carried and didn't). **Known members of that class today, traced:** | State | Where | Why it breaks under interleaving | |---|---|---| | `g_log_attrib_vm` | `vm_core.c` (save/restore in `vm_interpret()`) | Save/restore assumes last-in-first-out nesting. Hera switches out mid-interpret; Hestia's `vm_interpret()` saves `prev=Hera`; Hestia switches back mid-payload → attribution stays "Hestia" while Hera runs. `(LOG-APPEND-RAW)` takes its source from this, so persisted log records name the wrong VM (defeating `FABRIC-3.md` §XXVII's anti-spoof rule). | | `g_vm_interpret_depth` | `vm_core.c` | Same LIFO assumption; superseded by per-VM `interpret_depth` (Stage A) and retired in Stage I. | | `g_switch_entry_vm` | `switch.c` | Read once at first entry; safe only while first entries can't overlap. | | `g_prng_state` | `starforth_words.c:71` | One `RANDOM` stream for every VM (G8). | | Compile-time control-flow and vocabulary statics | vendored `src/word_source/` (Isabelle sweep memory) | Shared across VMs; kept safe for switches by the Stage A `MODE_COMPILE` gate, not by ownership. | **Shape, for ruling (not decided):** a `VMFrame` struct in its own header (per `feedback_dedicated_headers_not_inline_fields`: related state gets a header, not loose `VM` fields), embedded in `VM`, holding the per-VM execution context that is global today. The switch primitive then needs to swap nothing extra: whatever lives in the frame moves with the VM by construction. Log attribution becomes "the frame of `sk_vm_switch_current_vm()`" rather than a saved global. The vendored-source statics are `FABRIC-4.md`'s scope, but the frame is where they'd go. **Should land before or with Stage C**, since Stage C is the first stage where switches happen for real. **RULED 2026-09-23: VM frames land first, as Stage FR, before Stage C.** Concrete design: - **`include/starkernel/vm/vm_frame.h`**, new: `typedef struct VMFrame { int interpret_depth; int hermes_draining; int parked_idle; struct VM *attrib_vm; } VMFrame;`. `VM` gets one field, `VMFrame frame;`. The three loose fields Stages A and B added (`interpret_depth`, `hermes_draining`, `parked_idle`) **move into it**, since they are exactly this cluster. All their uses are renamed; behavior is unchanged. - **Stack-scoped log attribution.** `attrib_vm` lives in the frame of the VM that **owns the physical stack** (`sk_vm_switch_current_vm()`), and means "the innermost VM interpreting on this stack." `vm_interpret(vm, …)` saves and restores `owner->frame.attrib_vm` instead of the global. A nested `VM-EXEC` into Artemis on Hera's stack still attributes to Artemis, exactly as today. `vm_log_attributed_vm()` returns `current->frame.attrib_vm` (or `current` itself when no interpret is live on its stack). Because each stack carries its own chain, a switch can never leave attribution pointing at the VM that just switched out. Before the switch mechanism is seeded at boot (no current VM), the existing global path is kept as the fallback. - **Not in the frame this stage:** `g_prng_state` (vendored `starforth_words.c`, shared with the hosted build, open ruling §5 item 5), the compile-time statics (covered by the Stage A `MODE_COMPILE` gate; `FABRIC-4.md` scope), and `g_vm_interpret_depth` (retired in Stage I). **REVISED 2026-09-23, Captain Bob: *"give frames IDs and call by ID"*; chose *"frames stack per VM, each with an ID."*** This supersedes the single-struct shape above (written, never booted): - **`VMFrameEntry { VMFrameID id; VMUuid vm_id; uint32_t kind; }`**. `id` is a system-wide monotonic 64-bit frame ID (0 = none, never reused). `vm_id` is the VM executing at that level, **held as an ID, never a pointer**, so nothing can dangle after a VM dies. `kind` is `VM_FRAME_INTERPRET` or `VM_FRAME_DRAIN`. - **`VMFrameStack { entries, count, cap, max }`**, one per VM, embedded as `VM.frames`. It holds the nested interpret levels running **on that VM's own native stack** (a nested `VM-EXEC` into Artemis on Hera's stack is a frame on *Hera's* stack whose `vm_id` is Artemis). Growable like `VMCallState`, but grown by allocate-copy-free rather than `sf_realloc` (which orphans the old block). Before the switch mechanism is seeded, a boot-time stack in `vm_core.c` stands in. - **`vm_interpret()` pushes one frame and pops it by ID.** It keeps its own frame's ID and, at exit, checks the top is that ID before popping. A mismatch is logged as frame corruption. That's the "call by ID" integrity check across switches. - **Everything the loose fields held is now derived from the stack:** - ownership gate (§3.1): `current == vm`, the stack holds exactly one frame, and its `vm_id` is `vm`; - drain guard (§3.3): a `VM_FRAME_DRAIN` frame for `vm` is already on the current stack (the drain pushes its interpret as `DRAIN` kind); - parked-idle (§3.5): `VM_STATE_SWITCHED_OUT` **and the top frame is `VM_FRAME_IDLE`**. The trampoline pushes that frame just around its idle yield and pops it on resume, *before* draining. An empty stack alone is not enough: Hera parked at an idle point (Stage F) has zero interpret frames but her whole REPL call chain live on her stack. The frame records *where* the VM is parked, which the flag used to; - log attribution: the top frame's `vm_id` on the current owner's stack, **resolved by ID** through the VM registry. An empty stack means NULL, which maps to `HADES`. - **Failure rules.** A frame push that can't grow **fails closed**: log it, set `vm->error`, don't interpret. Interpreting without a frame would silently break the gate and attribution. An initial capacity is allocated at birth so the hot path almost never allocates. A **pop-by-ID mismatch** means corruption (every stack is LIFO, since resume restores the owner as current): log both IDs, count it, and remove *our own* ID wherever it sits rather than popping someone else's frame. No code holds a `VMFrameEntry *` across an interpret body (growth copies), only the ID. The frame-ID counter has a single writer: `vm_interpret()`, mainline only, with no interrupt-context caller (checked by grep). **Evidence, named in advance (probe, reverted after capture).** Two temporary C words, `SB-GO` (switch to Hestia) and `SB-BACK` (switch back to whoever switched in), plus `SB-ATTRIB` (record `vm_log_attributed_vm()`'s name). These are *probe-only* control words, never committed; they exist to force the interleaving that Stage C will produce naturally. Hestia is queued a message `SB-ATTRIB SB-BACK SB-ATTRIB`, then Hera runs `SB-ATTRIB SB-GO SB-ATTRIB SB-GO`. The recorded sequence must be **Hera, Hestia, Hera, Hestia**. The third entry is the discriminator: Hera resuming while Hestia is still parked mid-payload. **Baseline first:** the same probe on the pre-frame code must *fail* on that third entry (reading Hestia), proving the test detects the defect before the fix is claimed. ### 3.13 Stage C design: heat under self-driven execution, and the switch decision (FOR REVIEW, 2026-09-23) §3.11 ruled *coolest VM with pending work goes next, no scheduler*. Before designing the comparison, I traced (read-only) what that heat actually does once a VM runs its own mail on its own stack. **The finding blocks Stage C as written.** Everything below is traced unless marked as a proposal. #### 3.13.1 What the trace found **G9: when a VM runs its own mail, VM-physics heat doesn't move.** - `vm_physics_touch()` (`capsule_vm_physics.c:284`) is the only thing that moves `execution_heat_q48` between live VMs (apart from `retire`, which moves heat to the root on kill). Its only callers are Hera's three orchestration verbs: `VM-STEP`, `VM-EXEC` and `VM-CALL` (`mama_forth_words.c:723/811/946`). Each one touches the *target* at dispatch. - `sk_hermes_drain_checkpoint()` and the trampoline (`switch.c`) call nothing in physics. A VM that runs a kernel-Hermes payload on its own stack, which is the whole point of Stages C–H, leaves every heat value exactly where it was. - Starting state (`vm_physics_init:188`): Hera holds all of `Q48_ONE` (0x10000 raw) and every other VM starts at 0. - **Consequence:** once the DoE is rebuilt on `SEND` (§3.9), nothing ever calls `VM-EXEC`. Every worker stays at 0 and Hera stays at `Q48_ONE` forever. "Coolest with work" becomes a permanent tie among all workers, and whatever breaks that tie is the scheduler in all but name. That violates *"no scheduler allowed."* **The transfer law barely moves heat at switch granularity** (`vm_physics_touch`, lines 301–343): - The formula is `amount = elapsed_beats × slope >> 16`, with slope seeded at `65536/3` = 21845 raw. - `elapsed_beats` counts fleet beats since *this VM's own last touch*. Every touch resets it (`last_active_tick = now`), and a VM's first-ever touch moves nothing (`touched == 0`). - Worked out: elapsed 1, 2 or 3 moves **0**. 3 × 21845 = 65535, and 65535 >> 16 = 0. Elapsed 4 moves 1 raw unit (1/65536 of the fleet). Elapsed 100 moves 33 raw units, about 0.05%. - The truncated remainder is thrown away, not carried. So touching often moves *nothing*, which rules out the obvious "touch the running VM at each of its beats": elapsed would always be 1. - The seed's own comment (lines 138–144) already says the slope is **"UNVALIDATED after the ns-to-tick restatement… a real re-tune is DoE work (item 5.1)."** **What a beat is, and that every VM produces them** (checked because Bob ruled out the timer): - The fleet beat (`fleet_heartbeat_tick_count`) advances once per `vm_heartbeat_run_cycle()` → `vm_tick()` → `vm_physics_heartbeat_tick()`. - That runs every `HEARTBEAT_CHECK_FREQUENCY` (256, Kconfig) words of **a VM's own execution**. The per-VM `check_counter` is bumped at both `vm_core.c:909` (each colon word) and `:1286` (each word parsed by `vm_interpret`). - `heartbeat_enabled = 1` and `worker = NULL` are set for every VM in `vm_init` (`vm_bootstrap.c:305/308`). The pthread worker path is hosted-only. - **So the beat is execution-paced, produced by whichever VM is running, and not the timer.** The *timer* ISR is a different function, `heartbeat_tick()` in `heartbeat.c`. Today it calls `sk_vm_switch_signal_tick()` (`heartbeat.c:179`), which is the readiness path Bob ruled out. In this doc, "beat" always means the execution-paced one. - Each VM also keeps its own beat count, `vm->heartbeat.tick_count` (`vm_runtime.c:118`), so the beats a VM produced during one turn can be read directly. **Stadium heat doesn't answer "whose turn" either.** - A message's Stadium heat is pulled from the **sender's** reservoir at `sk_hermes_alloc()`, and the cell's owner is the sender. On release it goes back to the **sender** (`stadium_evict()`). - The recipient's Stadium state never changes when it receives or drains. - `sk_hermes_decay()` is only called from the boot self-tests (`kernel_main.c:969/1006/1033`). - So Stadium heat measures a VM's *outstanding sent mail*. It says nothing about how much CPU a recipient has had. It's listed for completeness, not proposed. **Stage C reverses a recorded decision.** `capsule_vm_switch_signal.h:28–34` deliberately did *not* use VM-physics heat for switching, on timescale grounds: word-level dispatch over millions of executions versus instant-by-instant readiness. Bob's §3.11 ruling overrides it. The truncation above is that timescale gap in concrete form, and §3.13.2 is how it would be closed. **The existing law measures age, not work** (derived from the code, worth stating because it changes what "coolest" means): - A touch pulls in proportion to beats *since the last touch*. - Over a VM's life, the sum of its pulls is its age in beats × slope, however often or rarely it actually ran. How often it ran only changes *when* it gets charged. - It loses heat when others are touched, in proportion to its share. - So under the current law, "coolest" ranks VMs by charge timing and share, not by CPU used. #### 3.13.2 Proposal: charge a VM for the beats it produced (ruling C-1) **Proposed: charge the running VM once at each of its own beats, keeping a carry.** - At every beat a VM produces while it is the switch-current VM, it is charged one beat's worth: `slope` raw units, plus the carry left over from its previous beats. - The whole units are pulled from the rest of the live fleet in proportion to each VM's heat, through the existing conservative `vm_physics_transfer()`. Nothing is created, and fleet K is still conserved. - The fraction below one unit stays in a per-VM carry. That carry is what makes a per-beat charge work where a per-beat *touch* can't (see row (d)). - It would be one new entry point in `capsule_vm_physics.c`, e.g. `vm_physics_charge(vm_id)`, called from the beat path (`vm_physics_heartbeat_tick()`'s caller) for the running VM. - It is kernel-side and invisible to anything a VM executes. - Result: **the VM that has done the most work is the hottest,** and its heat rises *while it runs*. That matches the plain meaning of "coolest VM with work goes next." **Why per beat and not at switch-out (corrected before review).** An earlier draft of this section charged only at switch-out. That design is **purely cooperative**, which contradicts §3.11: - During a turn nothing would change. The running VM's stored heat stays where it was at switch-in, and the waiting VMs are untouched. - So every decision taken during the turn repeats the answer from switch-in. The running VM is never displaced, and a long payload keeps the CPU until it finishes. - Charging per beat is what makes the running VM's own execution displace it, which is the preemptive effect. **Worked example.** Two workers start at 0 with equal work. - The running worker gains 21845/65536 of a unit per beat, so a whole unit moves every 3 beats. - At that point the other worker is strictly cooler and takes over at the next beat decision. - They alternate every ~3–6 beats. The turn length comes out of the physics; nobody chose it. | Option | Moves heat under self-driven execution? | Preemptive in effect? | Side effects | |---|---|---|---| | **(a) Charge at each of the running VM's own beats, with carry** (proposed) | Yes, in proportion to work | **Yes**, because heat rises during the turn | New entry point. Proposed: records **no** `fleet_window` samples (see "Frozen slope" below). | | (a′) Charge at switch-out by `tick_count` delta | Yes, in proportion to work | **No**, unless decisions compare *projected* heat: stored heat plus the charge for the beats produced so far this turn. With projection it is equivalent to (a), but without any mid-turn transfer. | Same as (a). | | (b) `vm_physics_touch()` on switch-in (the `VM-EXEC` analogue) | Yes, but it charges *time away*, not work (see "age, not work"), and needs ≥4 beats away before anything moves | No | Writes `fleet_window` samples, which changes slope adaptation and comparability with existing DoE heat columns. | | (d) `vm_physics_touch()` at each beat | **No.** Elapsed is always 1 and the remainder is discarded, so 0 moves, forever | — | Rejected on the arithmetic. Row (a) differs only by keeping the carry and not resetting the elapsed count. | **Truncation (ruling C-2).** With the carry, nothing is lost to rounding under (a). The other choices: - (i) no carry: then (a) moves nothing, the same as (d); - (iii) refit the slope at beat scale. That is DoE item 5.1 and **out of scope** here. The carry applies to the new charge only. `vm_physics_touch()` is untouched, so `VM-EXEC` transfers stay byte-for-byte as today. **Frozen slope (part of C-1).** - With no samples recorded by the charge, and no `VM-EXEC` in the rebuilt DoE, the adaptive `fleet_transfer_slope_q48` **never updates under self-driven load**. It stays at the unvalidated seed. - Recording samples would let it adapt. It would also change what the existing heat columns mean compared with past campaigns. - Bob should choose this knowingly. **Two laws on one ledger.** Artemis, and any participant that Hera also `VM-EXEC`s, is both *touched* (the dispatch law) and *charged* (the work law). Both go through `vm_physics_transfer()` on the same heat, so conservation holds, but the VM's heat then mixes two meanings. Proposed: accept this and document it. `VM-EXEC` into a participant becomes rare once §3.8's `SEND` exists. **Where the charge starts.** Each VM's charge baseline is its beats at the moment it becomes switch-current. Hera's is seeded at `sk_vm_switch_set_current()`, so her first charge doesn't cover every beat since boot. The clamp to `others_total` would make that harmless anyway, but it should be defined rather than left to the clamp. **What a beat counts** (corrected at the start of C-a, from reading `vm_core.c:909` in context). - A beat is every 256 words a VM executes. That counts each word dispatched inside a colon body, primitives included (the `check_counter` bump in `execute_colon_word`'s inner loop), plus each word parsed at top level by `vm_interpret` (`:1286`). - The earlier draft said primitives inside colon bodies weren't counted. That was wrong. - The "words" figures below are therefore beat counts × 256 executed words. **Hera's reservoir mass (ruling C-4, open, arithmetic shown).** - Hera starts with all of `Q48_ONE` as the fleet's structural root (`vm_physics_init`'s "cold mass added to a closed system"). - At slope 1/3 raw per beat, the workers together have to produce about `65536 × 3 × (N/(N+1))` beats before Hera cools to a worker's level. For N = 8 that is ≈175k beats, ≈45M executed words (see "What a beat counts" above). - Until then, Hera is never the coolest while any worker has work, **so the console gets no turn under load.** This doesn't block Stage C's relay evidence, because the relay runs while the workers are idle. It does block Stage F/H usability. - Options for Bob: - (α) accept it; - (β) compare the heat each VM has been *charged* (its turn heat) rather than its absolute heat, so the root's structural mass doesn't count against it; - (γ) something else Bob rules. - I have no recommendation yet. (β) is a different physical quantity, and Bob should choose it knowingly. #### 3.13.3 Proposal: the decision (ruling C-3 on ties) **Eligible** means the VM is a registered participant, is not the one running, is not dead or stillborn, and **either** of the following holds: - its kernel-Hermes pending count is > 0, read directly from the queue (`sk_hermes_pending_count`), not from a `has_work` flag. The queue *is* the eligibility, so `SWITCH-MARK-WORK` and G1's missing mark become unnecessary; - it is switched out, its **frame stack is non-empty, and its top frame is not `VM_FRAME_IDLE`**. That means it was displaced mid-task, or it is Hera with her REPL chain live. - Without this clause, a VM preempted in the middle of a payload never gets picked again. This is what the frame stacks were for. - A never-entered VM has an empty stack, so it is not eligible unless it has mail. - Hera parked at an idle point pushes an IDLE frame (§3.12, and §3.13.5 below), so she is not eligible there unless she has mail or input. **Switch when** some eligible VM is **strictly** cooler than the one running. If it is equal, the running VM stays: a tie with the incumbent is "no reason to move," not a coin toss. **Ties among several equally cool candidates (ruling C-3).** - **Proposed:** the candidate that has been *waiting longest*, measured by one single-writer event count. - Each message is stamped with it when it is admitted (a new `SkHermesMessage` field). - Each VM is stamped with it when it is displaced mid-task. - A candidate's wait stamp is the minimum of its oldest pending mail and its displacement. - Every stamp is unique, so the result is unique. - This is the arrival order of the mail itself, the kernel's FIFO. It is a fact about the messages, not a ranking of VMs. - **Bob to rule** whether arrival order counts as "an ordering" under *"no scheduler allowed."* - Rejected outright: slot index, UUID and registry order. Each of those is a fixed order of VMs. **Testable "not a scheduler" criterion.** - The decision is a pure function of physical state: heat, pending mail, frames and wait stamps. It keeps no queue of its own and remembers nothing between decisions. - It must return the same VM whichever order the participant table is walked. - This is Stage C-b's evidence (below). **When the decision is made** (only at moments the physics exposes, never on a clock): 1. **The running VM's own beat.** The beat sets a per-VM "decide" flag. The next *owned* checkpoint consumes it (`sk_vm_owns_outermost_frame`). There are two checkpoint sites: - the existing one in `execute_colon_word`; - the between-words point in `vm_interpret_kind`'s loop, which needs a new gated checkpoint so that a payload made only of primitives can be displaced. 2. **Mail arrival** to a VM that would then win. 3. **The trampoline's between-message point**, as already noted in `sk_vm_switch_entry()`. 4. **Idle points** (Stage F). **Why not every word.** Under (a), heat changes only at beats, and eligibility changes only when mail arrives or a queue empties. Between those events every decision would repeat the last one, so deciding every word is pure overhead. Deciding at the beat means a turn lasts at least one beat unless the VM runs out of work first. **Flagged honestly:** that minimum is the existing Loop #7 cadence constant (`HEARTBEAT_CHECK_FREQUENCY`). It is not new, but it does act as a floor on turn length. **The switch target on an empty queue.** - Today the trampoline yields to whoever switched in (`native_switch_back_to`). - **Proposed:** it asks the decision instead, and falls back to `native_switch_back_to` only when no VM is eligible. #### 3.13.4 What removing the timer path changes - `heartbeat.c:179`'s `sk_vm_switch_signal_tick()` call is removed. The ISR returns to its pre-§XXVIII minimal shape, so the timer no longer decides anything. - These are removed: - the readiness accrual; - `SK_SWITCH_READINESS_THRESHOLD` (50); - `has_work`; - `take_pending`. - `note_switch_performed` is kept only as the counter. - **Effect on the DoE CSV** (`doe_log.c:205–230`): - `switch_count` and `switch_current_slot` survive. - `switch_ticks_since` loses its meaning. Proposed: it becomes beats since the last switch. - The three `*_readiness` columns die. - Found while tracing, *not fixed*: those columns and the heat columns still look up `"Hermes"`, which no longer exists as a VM since the reshuffle, so those values are always 0 today. #### 3.13.5 The relay needs the idle point in the same stage (traced) Stage C-b's own evidence, the WIREBIND relay delivered by a real switch, **can't be reached without §3.7's idle point** (Stage F): - The console line is forwarded from C. `sk_hermes_send_one(vm->stadium_vm_id, paired.vm_id, CONSOLE_CMD, …)` at `repl.c:1510`, then `goto out`. Hera interprets nothing and goes straight back to polling. - Today the `~user` VM's mail is delivered by the idle pump (`repl.c:~909–928`), which drains every live VM *on Hera's stack*. That is G3. - Once the pump skips participants (C-b), the only places a switch can happen are owned word checkpoints and beats. Hera at her poll loop reaches neither: she is running C, not words. - So nothing switches, the `~user` VM's `CONSOLE-CMD-EVENT` sits in its queue, and the relay breaks. **Proposed:** fold §3.7's idle point into C-b. - The pump loop's own location (the idle point, Hera between lines) becomes a decision point. Hera pushes an IDLE frame, consults the decision, and switches to the coolest eligible VM instead of draining participants on her stack. The pump then shrinks to Hera's own queue. - Stage F then only has to show that work proceeds with the console idle. - The alternative is to keep the pump draining participants until F. That keeps G3's borrowed-stack delivery alive through C-b, so C-b's relay evidence would prove nothing about switching. **Hera's eligibility at the idle point.** Keyboard or serial input pending is Hera's mail. At the idle point she is eligible when input is pending (the REPL's own input poll), exactly as a worker is eligible when its kernel-Hermes queue is non-empty. **Ruling C-5**, below. #### 3.13.6 Stage C, split This splits the single C row in §4 in two: - **C-a** is physics only: heat starts moving and nothing switches yet. - **C-b** is the decision: pump skip, eligibility read from the queue, the ISR path removed, and the idle point (§3.13.5). C-a is observable on its own, and doing it first proves the heat signal is alive before anything depends on it. The §4 table below is updated to show this split, **pending rulings C-1 to C-4.** --- ## 4. Staged plan Each stage is its own commit and its own three-arch acceptance, and each names its evidence **before** it runs (`FABRIC-3.5.md` §XXXV.6). A green boot proves nothing here: a switch storm and a healthy idle produce the same serial log (`FABRIC-3.md` §XXVIII.1). | Stage | Change | Evidence that proves it (named in advance) | |---|---|---| | **A** | §3.1 per-VM ownership gate (+ `MODE_COMPILE` gate). Nothing else. | Byte-identical behavior: `dict_hash` triple unchanged, identical boot, WIREBIND mint→attach→`USE`→relay (the `FABRIC-3.6.md` Phase 4 acceptance sequence) still `1 2 + .` → `3`. | | **B** | §3.2 trampoline idle loop + §3.3 `hermes_draining` + §3.5 parked-idle flag. Still inert (nothing marks work yet). | Same as A: nothing can reach the new trampoline branch yet, so behavior is identical. A synthetic self-test (kernel_main.c, the §XXVIII Stage 2 `SWITCH-TEST` pattern: write, capture, revert) switches into **Hestia** (not Artemis, whom the Zuse attach flow `VM-EXEC`s; a parked VM refuses `VM-EXEC`) with **two** messages queued, the first calling a colon word so the in-word drain checkpoint is actually reached. The ordering witness `SB-SEQ` must equal 12 (one-at-a-time drain), not 21 (nested). She must end parked-idle at depth 0 with her context saved on her own stack, current VM back to Hera, and the ledger and `stadium_conserved()` restored. | | **FR** | §3.12 VM frames: `VMFrame` header; Stage A/B fields move into it; stack-scoped log attribution. | Probe `SB-ATTRIB`/`SB-GO`/`SB-BACK` (reverted): attribution sequence Hera, Hestia, **Hera**, Hestia on all three ISAs, and the **same probe fails on the pre-frame baseline** (third entry reads Hestia). Then formal acceptance: `dict_hash` unchanged, relay works. | | ~~**C**~~ | ~~§3.4 eligibility marked + pump skips participants (one commit).~~ Split into C-a/C-b by §3.13 (pending rulings C-1–C-4). | (see C-a, C-b) | | **C-a** | §3.13.2 the running VM is charged at each of its own beats, with carry. Physics only; switching unchanged. | Temporary probe (reverted), which switches Hestia in with the Stage FR probe's `SB-GO`/`SB-BACK` words, because C-a itself switches nothing. Hestia runs a colon-word payload that produces ≥ 8 beats. Her `VM-HEAT` is sampled **at a beat during the turn** and again after it, and must rise from 0 by `beats × slope` (whole units, remainder kept in carry). The rest of the live fleet must fall by the same total. `vm_physics_conserved()` holds, and the `fleet_window` sample count is unchanged. **Baseline first:** the same probe on current code shows heat unchanged (proves G9). Then acceptance: `dict_hash` unchanged and the relay works. | | **C-b** | §3.13.3 decision at owned checkpoints, beats, the trampoline and **the idle point** (§3.13.5, which absorbs Stage F's §3.7). Eligibility comes from the queue or a non-IDLE top frame. The pump skips participants. The ISR switch path and readiness are removed (§3.13.4). | The WIREBIND relay still works, now delivered by a real switch: `switch_count` increments across the relay and the `~user` VM's own drain line appears. Ledger and `stadium_conserved()` hold. `heartbeat_tick()` contains no switch call (source). **Walk-order probe** (temporary): every decision is also computed with the participant table walked in reverse; disagreements = 0. No `native_stack_saved_sp` ever inside Hera's stack range (temporary probe). No switch storm (log growth at idle `ok>`, QMP `send-key` computes a value). | | **D** | §3.5 kill of parked-idle + unregister in `capsule_vm_kill()`. | Birth, message, idle-park, `KILL` succeeds; the switch-signal slot count returns to its prior value. | | **E** | §3.6 generic registration in `capsule_birth_baby()`, duplicate call sites removed. | The Tripod and WIREBIND VMs still register exactly once (slot count), and a `BIRTH`-ed VM appears as a participant. | | **F** | §3.7 idle point is a switch point. *(If C-5 is ruled yes, the mechanism lands in C-b and F keeps only this evidence.)* | With Hera idle at `ok>`, a message sent to a worker **gets processed without typing anything**: the worker's output appears while the console is idle. | | **G** | §3.8 `SEND`. | Two workers, each sent a payload that loops printing a tagged marker (`A` / `B`) N times: **the markers interleave** in the serial log. This is the one-line proof of concurrency, and it's the test this whole document exists to pass. | | **H** | §3.9 DoE driver rebuilt (capsule change only). | A single trial at cfg 4 (8 workers): `switch_current_slot` in the per-beat DoE rows moves across **multiple worker slots within one trial**, and `MU-TRIAL … fail=0`. Then Bob's call on relaunching the campaign. | | **I** | Strip dead code: the pump loop once every VM is a participant, `SWITCH-MARK-WORK` if nothing calls it, the global depth counter once attribution no longer needs it. | Per `FABRIC-3.5.md` §XXII's strip discipline: reachability shown three ways, not by grep alone. | **Stop conditions** (re-plan, don't push on): `dict_hash` diverges across ISAs; the ledger or `stadium_conserved()` goes false; any evidence of a stack saved under the wrong VM; a switch storm (serial log frozen with QEMU pinned, checked by log line-count growth at an idle prompt and by QMP `send-key` computing a value, per `FABRIC-3.6.md` task 1.5). --- ## 5. Rulings needed from Captain Bob before code 1. **Go/no-go on the staged plan (§4)**, one stage at a time. 2. **Workload capsules (§3.9):** keep self-execution at birth (the heavy phase stays serial), or born definitions-only with the workload as the first message (recommended, so the measured phase is the concurrent one). If the latter: new `-def` capsule variants, or strip the tail call from the existing `workload-N.4th` files (check their other users first, per `FABRIC-3.5.md` §XXII.2's three reachability routes)? 3. **The send word's name** (§3.8): `SEND`, `HERMES-SEND`, `KH-SEND`, something else. 4. **The multiuser factor.** The DoE as built varies concurrency × workload mode and runs once per ISA. `FABRIC-3.md` §XXXIII.2 planned identity origin as a fixed arm, never built. Should identities (`UNATTENDED-BIRTH` capsule identities, or WIREBIND thumbdrives) become a crossed factor alongside concurrency and ISA, now that "users" is meant to be as important as the other two? This decides the DoE's shape, not the OS work, and can wait until Stage H. 5. **§3.10's shared PRNG:** accept it for this pass (run order is still deterministic), or put per-VM `RANDOM` state in scope. 6. **C-1 (§3.13.2):** how heat is charged under self-driven execution. - Proposed (a): the running VM is charged at each of its own beats. Switch-out-only charging is purely cooperative, unless decisions compare projected heat (a′). - Also: charge records no `fleet_window` samples, which means the slope stays frozen at its unvalidated seed under self-driven load. Or record them and let it adapt. 7. **C-2 (§3.13.2):** a per-VM carry on the new charge only. Without it, (a) moves nothing. 8. **C-3 (§3.13.3):** ties. The incumbent stays on a tie with itself. Among several candidates: whether "waiting longest," by a single-writer arrival stamp, is acceptable under *"no scheduler allowed,"* or whether Bob wants something else. Slot, UUID and registry order are rejected. 9. **C-4 (§3.13.2):** Hera's reservoir mass means the console gets no turn under worker load. Options are (α) accept it, (β) compare charged heat instead of absolute heat, or (γ) something else. No recommendation. 10. **C-5 (§3.13.5):** fold the idle point (Stage F's §3.7) into C-b, since the relay can't be delivered by a switch without it. Also: Hera's pending console input counts as her mail for eligibility. **Nothing in this document authorizes an edit.** Per Captain Bob's Law, no stage starts without an explicit instruction. --- ## 6. Execution log - [x] **Stage A — per-VM ownership gate (§3.1).** 2026-09-23. `include/vm.h`: new `VM.interpret_depth`. `vm_core.c`: `vm_interpret()` raises and lowers it beside the global counter; new `sk_vm_owns_outermost_frame(vm)` (`sk_vm_switch_current_vm() == vm && vm->interpret_depth == 1 && vm->mode != MODE_COMPILE`) replaces `sk_vm_at_outermost_interpret()` at both checkpoint sites in `execute_colon_word()` (drain and switch). The global counter and the old function stay until Stage I. **Evidence, as named in §4:** clean build on all three ISAs with no new warnings (only the pre-existing `mkcapsule`/linker ones), hosted build clean. Foreground, visible-framebuffer boots, with `disk/artemis.img` + `zuse-thumb-ident.img` + `bob-thumb-ident.img` reset together before each: `logs/20260923-140120/amd64/`, `logs/20260923-140654/aarch64/`, `logs/20260923-141303/riscv64/`. All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD`, zero self-test FAIL/fault/panic, and a `dict_hash` set **identical across all three and unchanged from the pre-Stage-A boot** (`logs/20260923-110505/amd64/`): M7.1a `0x3933d76939ef66c6`, Hera `0x27ba08011349842b`, Hestia `0xdd1aa879b3a6c5df`, Artemis `0xe9d0408b1119d51f`. The full live WIREBIND sequence on each ISA: hot-attach blank `bob` via QMP → `MINT` (`-1`, restricted) → detach/reattach → `WIREBIND: rajames attached` → `S" rajames" USE` → `1 2 + .` → `3`, delivered via `Kernel-Hermes CONSOLE-CMD-EVENT` with `stadium_conserved(self)=true`. Clean QMP `quit` each time, no leaked processes. note: this relay still runs through the idle pump on Hera's stack (G3, unchanged until Stage C). With the new gate, checkpoints inside that relayed payload now correctly **fail** the ownership test (Hera owns the stack, not `rajames~user`). Before Stage A they passed on the global depth count, which was the latent §1 hazard. - [x] **Stage B — trampoline idle loop, one drain at a time, parked-idle (§3.2/§3.3/§3.5).** 2026-09-23. `include/vm.h`: `VM.hermes_draining`, `VM.parked_idle`. `kernel_hermes.c`: `sk_hermes_drain_checkpoint()` refuses while `hermes_draining` is set, and sets and clears it around its `vm_interpret()` (set only once a message is in hand; single exit). `switch.c`: `sk_vm_switch_entry()` is now each participant's kernel-side idle loop. It drains one of its own messages per pass, driven by the drain's return value, not a separate pending probe (so a non-consuming drain can't spin with no checkpoint to break it). When nothing is drained, it sets `parked_idle` and yields to `native_switch_back_to`; a failed yield panics. Inert in normal operation: nothing marks work yet (Stage C), so no real boot enters it. **Finding, fixed in this stage: amd64 first-entry stack misalignment.** The first probe boot (`logs/20260923-142526/amd64/`, kept) took **#GP (vector 13)** the moment Hestia's trampoline called into `vm_interpret()`. `sk_vm_switch_prime` (amd64 `switch.S`) built its frame at `top − 56`, so the entry function ran with `rsp == top` (16-aligned). SysV needs `rsp ≡ 8 (mod 16)` at entry, so every compiler-emitted aligned SSE spill faulted. Latent since §XXVIII Stage 2, because the old placeholder never called anything that spilled. Fixed: frame at `top − 64` with a zeroed pad slot. aarch64 and riscv64 return through the link register and already land 16-aligned; no change there. **Evidence, as named in §4:** a temporary probe (kernel_main.c, written, captured, **reverted**; `git diff` afterward shows only `vm.h`, `kernel_hermes.c`, `switch.c` and amd64 `switch.S`) queued two messages to Hestia, the first calling a colon word, and switched Hera→Hestia. `STAGE-B PROBE: PASS` on all three: `logs/20260923-143121/amd64/`, `-143435/aarch64/`, `-143838/riscv64/`. Sends ok, switch ok, 0 pending, `parked_idle=1`, depth 0, context saved on her own stack, current VM back to Hera, **`SB-SEQ = 12`** (one at a time; a nested drain gives 21), ledger `held` restored, `stadium_conserved(hera)` true. The probe's printed line reads `seq=ld …` because this kernel's freestanding `snprintf` has no `%lld` and shifted the arguments after it; the PASS test compares values directly and is unaffected. Formal acceptance with the probe gone, coupled images reset before each: `logs/20260923-144234/amd64/`, `-144701/aarch64/`, `-145224/riscv64/`. All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD`/failures/faults, the `dict_hash` set identical across ISAs and unchanged from Stage A, and the full mint → WIREBIND → `USE` → `1 2 + .` → `3` relay with `stadium_conserved(self)=true`. - [x] **Stage FR — VM frames: a frame stack per VM, each frame with an ID (§3.12).** 2026-09-23. Captain Bob: *"give frames IDs and call by ID"*, *"frames stack per VM, each with an ID."* New `include/starkernel/vm/vm_frame.h` / `src/starkernel/vm/vm_frame.c`: `VMFrameEntry` `{ VMFrameID id; VMUuid vm_id; kind }` (kinds `INTERPRET`/`DRAIN`/`IDLE`); `VMFrameStack` embedded as `VM.frames`, reserved at birth (`vm_init_with_host`), freed in `vm_cleanup`, grown by allocate-copy-free. `vm_interpret()` → `vm_interpret_kind()`: pushes one frame onto the running stack's owner (`vm_frame_current_stack()`; a boot stack before seeding), holds only its ID, **fails closed** if the push can't grow, and **pops by ID** at exit (a mismatch is printed as `VM FRAME CORRUPTION` and only our own frame is removed). The Stage A/B loose fields are gone, all derived now: the ownership gate is "the owner's stack holds exactly one frame and it's `vm`'s"; the drain guard is "a `DRAIN` frame for `vm` is on the running stack"; parked-idle is a `VM_FRAME_IDLE` frame the trampoline pushes around its yield and pops before draining; log attribution is the top frame's `vm_id`, **resolved by ID** through the VM registry (empty stack → NULL → `HADES`). The attribution global is removed. **Evidence, as named in §4:** - **Baseline (pre-frame code, probe only):** `logs/20260923-150901/amd64/` → `attribution=HSSAS want=HSHAS FAIL`. The old global named Hestia while Hera resumed, which is exactly the defect. - **With frames, probe on all three ISAs:** `logs/20260923-152109/amd64/`, `-152424/aarch64/`, `-152828/riscv64/` → `attribution=HSHAS hestia_idle_top=1 hera_frames=0 PASS`. That covers interleaved attribution, nested `VM-EXEC` into Artemis still attributed to Artemis, Hestia parked with an `IDLE` top frame, and Hera's frames back to zero. The probe (`SB-GO`/`SB-BACK`/`SB-ATTRIB`, probe-only control words) was reverted (`git checkout -- src/starkernel/kernel_main.c`). - **Formal acceptance without the probe, coupled images reset before each:** `logs/20260923-153227/amd64/`, `-153644/aarch64/`, `-154208/riscv64/`. All reach `[zuse@Hera] ok>`, with zero `UNKNOWN WORD`/failures/faults, **zero `VM FRAME CORRUPTION` and zero refused interprets**. The `dict_hash` set is identical across ISAs and unchanged from Stages A/B. The full mint → WIREBIND → `USE` → `1 2 + .` → `3` relay works (the path with a nested frame on Hera's stack). Clean build on all three plus hosted, no new warnings. - [x] **Stage C-a — the work law: the running VM is charged at each of its own beats, with carry (§3.13.2).** 2026-09-23. Captain Bob: *"commit it and start stage C-a"*, which accepts §3.13.2's proposals C-1(a) and C-2 as written: charge at each beat, keep a carry, record no `fleet_window` samples. C-3, C-4 and C-5 are C-b's rulings and are still open. Changes: - `capsule_vm_physics.c` gets a new `vm_physics_charge(vm_id)`. It adds one beat's slope to a new per-VM `charge_carry`, then pulls the whole units from the rest of the live fleet in proportion to each VM's heat, through `vm_physics_transfer()`. - Clamped to what the others hold. That shortfall is dropped, as in touch, so the carry can't grow without bound while one VM runs alone. - Units lost to the proportional split's rounding go back into the carry. - No `fleet_window` sample and no `last_active_tick` change, so `vm_physics_touch()` and `VM-EXEC` behave byte-for-byte as before. - `vm_runtime.c`'s `vm_tick()` calls the charge right after `vm_physics_heartbeat_tick()`, only when the VM producing the beat is `sk_vm_switch_current_vm()`. A VM running nested inside someone else's `VM-EXEC` stays on the dispatch law. - §3.13.2 "What a beat counts" is corrected. `vm_core.c:909` is in `execute_colon_word`'s inner loop, so primitives inside colon bodies *are* counted. **Evidence, as named in §4.** A temporary probe (`kernel_main.c`: `SB-GO`/`SB-BACK` plus `SB-HEAT`, which records Hestia's heat, the rest of the fleet's heat, her beat count and `vm_physics_conserved()`; reverted with `git checkout`). It sends Hestia `: SBW ; : SBL 3000 0 DO SBW LOOP ; SB-HEAT SBL SB-HEAT SBL SB-HEAT SB-BACK`, and Hera runs `SB-GO SB-GO`. - **Baseline (pre-change code, probe only):** `logs/20260923-160929/amd64/` gives `hestia_heat 0 → 0 → 0` over beats 15 → 51 → 86, and **FAIL**. Hestia ran 71 beats of her own mail on her own stack and gained nothing, which **proves G9**. - **With the charge, all three ISAs:** `logs/20260923-161448/amd64/`, `-161959/aarch64/` and `-162543/riscv64/` give, byte-identically, `hestia_heat 0 → 11 → 23` at beats 15 → 51 → 86, `rest 65536 → 65525 → 65513`, conserved at every sample, and **PASS**. The heat rises **mid-turn** at each in-turn sample. - Exact to the arithmetic: switched in at beat 15, so ⌊36·21845/65536⌋ = 11 and ⌊71·21845/65536⌋ = 23. Every unit Hestia gained came out of the rest of the fleet (all from Hera; Artemis held 0). - `fleet_window_warm=NO` and `slope 21845` are unchanged. By source, the charge never writes `fleet_window`. **Formal acceptance with the probe gone,** coupled images reset before each: `logs/20260923-163215/amd64/`, `-163757/aarch64/`, `-164359/riscv64/`. - All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD`, failures, faults, `VM FRAME CORRUPTION` and refused interprets. - The `dict_hash` set is identical across ISAs and unchanged from Stages A/B/FR. - The full mint → WIREBIND → `USE` → `1 2 + .` → `3` relay works, with `stadium_conserved(self)=true`. - Clean build on all three plus hosted, no new warnings. **Consequence to watch in C-b:** heat now moves at boot too. Hera is switch-current from her Stage 3 registration onward and is charged for her own beats. She holds nearly all the heat, so her charges pull almost nothing; that is the C-4 arithmetic playing out as expected. The per-beat DoE heat columns will read differently from pre-C-a runs. - [x] **Stage C-b — the compudynamic switch decision, the idle point, the timer path removed (§3.13.3–§3.13.5).** 2026-09-23. Captain Bob: *"go ahead with C-b as proposed."* - Rulings: **C-3** as proposed (the incumbent stays on a tie; among candidates, the one waiting longest, by unique wait stamp). **C-5** as proposed (the idle point folded in; Hera's pending console input is her mail). - **C-4** had no proposal, so I took **(α) accept**. No special case for Hera; the consequence is recorded below. Changes: - `capsule_vm_switch_signal.c/.h`: - Removed: readiness, `has_work`, the 50-tick threshold, `tick()`, `take_pending()` and the readiness getters. - New: `sk_vm_switch_decide(self, self_competes)`. It considers participants other than `self` that are alive, not `pending_reap`, and have a native stack. A candidate is eligible with pending kernel-Hermes mail, or when parked mid-task (frame stack non-empty, top frame not IDLE), or for Hera, pending console input. The lowest VM-physics heat wins; on equal heat, the lowest wait stamp. Stamps are unique: either a message's admission or the last time the VM was parked. With `self_competes` set, it returns a candidate only if that candidate is **strictly** cooler than `self`. It keeps no state between calls. - Also new: wait stamps (`next_stamp`, `note_parked`), the "decision due" flag (set by `note_beat` and `note_mail`, consumed at the next owned checkpoint), `is_participant`, and `sk_vm_switch_reap_pending()`. The latter is the Stage 4 `pending_reap` handling, now run at every safe point, one reap per call. - `ticks_since_switch` now counts beats. - `heartbeat.c`: the ISR's `sk_vm_switch_signal_tick()` call is gone. The timer decides nothing. - `kernel_hermes.c/.h`: each admitted message carries `wait_stamp`, and a send sets decision-due. - `vm_runtime.c`: the running VM's beat also calls `note_beat()`, right after its C-a charge. - `switch.c/.h`: - `sk_vm_context_switch()` stamps `from` as parked. - `sk_vm_switch_checkpoint(vm)` re-decides only when a decision is due. - The trampoline decides after every drain: with mail left, it switches only if someone is strictly cooler; with no mail, it goes to the coolest VM with work, or to Hera when nobody has any. That fallback isn't `native_switch_back_to`, because two idle trampolines would bounce into each other forever. A drain that leaves mail unconsumed panics rather than spins. - `sk_vm_idle_point()`: Hera, running with no frame live (between lines), parks with an IDLE frame and switches to the coolest VM with work. - `vm_core.c`: - The `execute_colon_word` checkpoint calls `sk_vm_switch_checkpoint()`. - **New checkpoint between top-level words** in `vm_interpret_kind`, with the same ownership gate, so a primitives-only payload can be displaced. - `repl.c`: - The idle pump skips participants. Their mail goes on their own stacks now; this closes G3 for participants. - `sk_vm_idle_point()` is called on every no-input pass of all three console poll loops (`sk_console_getkey`, `sk_repl_headless_wait`, `sk_console_readline`), **not** at the `SK_IDLE_BEAT_INTERVAL` cadence. That cadence comes from the timer. - `doe_log.c`: the three readiness columns are kept and always 0, so the CSV shape doesn't change. Stage I decides whether to drop them. **Evidence, as named in §4.** A temporary probe, restored afterwards from a pre-probe snapshot and verified free of probe code: - a walk-order check that recomputes every decision walking the slot table in reverse; - a line per switch (names, heats, decision and disagreement counts); - a check that each parked VM's `native_stack_saved_sp` lies inside its own native stack (guard, top). Probe runs with the full relay: `logs/20260923-171359/amd64/`, `-172010/aarch64/`, `-172635/riscv64/` (plus an earlier amd64 run, `-170740`, before the stack check could print). On every ISA: - **`switch 1 Hera(heat 65536) → rajames~user(heat 0)`** happens at Hera's idle point after she forwards the console line. `rajames~user` drains `CONSOLE-CMD-EVENT` **on its own stack** (`stadium_conserved(self)=true`) and prints `3`. - **`switch 2 rajames~user → Hera`** is the trampoline's no-work fallback. - `switch_count` reached 2. - **`parked rajames~user saved_sp inside own stack (checked=1 bad=0)`**. - **`disagree=0` over 40,246,330 (amd64), 4,182,971 (aarch64) and 25,030,081 (riscv64) decisions.** The decision doesn't depend on walk order. - **Idle log growth over 20 s after the relay: 0 bytes.** No switch storm. - Nothing switched during boot. No participant has mail then, and Hera is hottest. **Formal acceptance with the probe gone:** `logs/20260923-173341/amd64/`, `-173947/aarch64/`, `-174610/riscv64/`. - All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD`, failures, faults, frame corruption and refused interprets. - The `dict_hash` set is identical and unchanged. - Mint → WIREBIND → `USE` → `1 2 + .` → **`3`**, delivered by a real switch. - Idle growth: 0 bytes. - Clean build on all three plus hosted, no new warnings. **Observed and recorded, not changed:** - The relayed line's output now follows Hera's `ok>` on the same line (`[rajames@rajames] ok> Kernel-Hermes CONSOLE-CMD-EVENT …`), because delivery happens at her idle point after she prints the prompt. This is cosmetic. - **Idle cost:** the idle point runs the decision on every poll pass (tens of millions of decisions per boot while idle). Each walks the participant table, but the CPU was spinning there anyway. - **C-4 (α) consequence, now concrete:** Hera's other idle work, including xHCI event servicing, USB attach and detach, and block sync, runs from her idle beat. Under sustained worker load Hera is the hottest VM with work and gets no turn, so those services wait until the workers go idle. This doesn't affect the relay. It will matter for Stage G/H, and for any USB keyboard input under load (serial input still makes her eligible). - Stage F's mechanism is now in C-b. Its remaining content is evidence only: work proceeds with the console idle, which this relay already shows for one message. - [x] **Stage D — kill of a parked-idle VM, and unregister on kill (§3.5, G6).** 2026-09-23. Captain Bob: *"go ahead with stage D."* §3.5's `parked_idle` flag is the frame predicate Stage FR already made possible, not a new field. Changes: - `switch.c/.h`: new `sk_vm_parked_idle(vm)`. The VM has a saved context, and its frame stack is exactly one frame: its own `VM_FRAME_IDLE`. That describes a VM sitting at its trampoline with nothing in progress. - `capsule_birth.c/.h`: new `capsule_vm_kill_refusal(entry)`, the one place the rule lives. It refuses: - a `SWITCHED_OUT` VM that isn't parked idle (mid-task: a live frame awaits resuming); - one parked idle but with mail still pending (those messages hold their senders' Stadium heat, and nothing would drain them); - **the VM physically running**. A participant can now be the one executing `KILL`, and it can't free its own stack. `capsule_vm_kill()` uses it and now calls `sk_vm_switch_signal_unregister()`, as `capsule_vm_force_reap()` already did. Before this, an ordinary `KILL` left the slot behind. - `mama_forth_words.c`: the `KILL` word used its own copy of the old blanket `SWITCHED_OUT` refusal. It now asks `capsule_vm_kill_refusal()`, and runs its heat retire and active-VM cleanup for any VM that will actually be killed, parked-idle ones included. **Finding, fixed here: a C-b regression.** - `vm_registry_alloc()` never initialized `VMRegistryEntry.pending_reap`, and `kmalloc` doesn't zero memory, so every born VM started with whatever the allocation held. - This was latent since `FABRIC-3.md` §XXVIII Stage 4 (2026-09-14), because only the old checkpoint read it, on a timer-picked target. - C-b's `sk_vm_switch_reap_pending()` walks **every participant at every safe point**, so a garbage non-zero value reaps a healthy VM. Seen live in the first Stage D baseline (`logs/20260923-182435/amd64/`): `REAP: dprobe tombstone reclaimed` appears before any `KILL`. - Fixed at the root: `pending_reap = 0` in `vm_registry_alloc()`. It was the only `VMRegistryEntry` field left uninitialized. - The Tripod and `rajames~user` weren't reaped in C-b's runs only because their bytes happened to be 0. **Evidence, as named in §4.** A temporary probe (`kernel_main.c`, reverted with `git checkout`): - It births `dprobe` from `workload-1-lite.4th` with `WORKER-BIRTH` and registers it by hand, standing in for Stage E. - It sends it `1 2 + DROP`. Hera runs `: DPX 1 DROP ; DPX`; that colon-word checkpoint, with a decision due from the mail, switches to `dprobe`. `dprobe` drains the message, finds nobody with work, and falls back to Hera. - It then checks that `dprobe` is parked idle, runs `S" dprobe" KILL`, and checks the slot count and registry state. Runs: - **Baseline**, with the init fix but no Stage D, amd64 (`logs/20260923-182940/amd64/`): `KILL: refused -- switched-out`, `slots before=3 registered=4 after_kill=4 parked_idle=1 dead=0`, **FAIL**. That proves G6. - A first run with Stage D (`-183452`) still failed identically. That is how the `KILL` word's own copy of the refusal was found; after that, the shared rule. - **With Stage D:** `logs/20260923-184026/amd64/`, `-184502/aarch64/` and `-184946/riscv64/` give `KILL: dprobe dead`, `slots before=3 registered=4 after_kill=3 parked_idle=1 dead=1`, **PASS** on all three. **Formal acceptance with the probe gone:** `logs/20260923-185515/amd64/`, `-190119/aarch64/`, `-190745/riscv64/`. - All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD` and zero failures or faults. - The `dict_hash` set is unchanged. - The relay gives `3` through a real switch. - Idle log growth: 0 bytes. - Clean build on all three plus hosted, no new warnings. **Reported, not changed (pre-existing):** - Killing a **LIVE** VM that still has kernel-Hermes mail queued leaves those messages in its queue. They hold their senders' Stadium heat forever, because nothing drains a dead VM's queue. Stage D refuses the parked case for exactly this reason, but the LIVE path has always had it. - WIREBIND's detach path calls `capsule_vm_kill()` directly, without the `KILL` word's `vm_physics_retire()`. I have not checked whether a detached identity's VM-physics heat is retired elsewhere. - [x] **Stage E — every born VM is a switch participant (§3.6, G4).** 2026-09-23. Captain Bob: *"yes"* (go ahead with Stage E). Changes: - `capsule_birth.c`: `capsule_birth_baby()` registers the new VM right after its task 3.2 common-channel subscription, at its LIVE completion point. The capsule code has already run to completion by then. A full table is logged and doesn't fail the birth. - `capsule_vm_switch_signal.c/.h`: `sk_vm_switch_signal_register()` is now **idempotent**. An already-registered VM gets its existing slot back, not a duplicate. - `kernel_main.c`: the Artemis/Hestia registrations are removed. Hera is still registered there (she's the one VM never born through `capsule_birth_baby()`), together with `sk_vm_switch_set_current(mama)`. Nothing can switch before that: every switch point requires a current VM. - `capsule_wirebind.c`: the user VM's `sk_vm_switch_signal_register()` call becomes a participation lookup. `switch_slot` was only ever used as a ≥ 0 flag and is now documented as one, since slot indices shift when the table compacts. - The WIREBIND console VM is now a participant too. That's harmless: it never has mail, so it is never chosen. It is unregistered when it's killed at detach (Stage D). - Registration order changes: Hestia, then Artemis at their births, then Hera. Decisions don't depend on order (C-b's walk-order evidence), but the DoE's `switch_current_slot` column now numbers them in that order. - Boot order checked: `sk_vm_switch_signal_boot_init()` (`kernel_main.c:536`) runs before the Tripod births (`:1124`/`:1142`). **Evidence, as named in §4.** A temporary probe, restored away from a pre-probe snapshot and verified free of probe code: - `register()` prints every call, and flags any `DUPLICATE`; - `kernel_main.c` checks Tripod participation, then `WORKER-BIRTH`s `eprobe` **without registering it by hand**, checks it is a participant, and `KILL`s it. Probe runs with the full relay: `logs/20260923-192830/amd64/`, `-193439/aarch64/`, `-194103/riscv64/`. Identical on all three: - `register` lines for Hestia (count 0 → 1), Artemis (1 → 2), Hera (2 → 3), eprobe (3 → 4); - `STAGE-E PROBE: tripod hera=1 artemis=1 hestia=1 slots=3; WORKER-BIRTH eprobe participant=1 slots=4; after KILL slots=3`, **PASS**; - at WIREBIND attach, exactly **two** registrations (the console VM and `rajames~user`); - **zero `DUPLICATE`** lines anywhere; - the relay still gives `3`. **Formal acceptance with the probe gone:** `logs/20260923-194803/amd64/`, `-195401/aarch64/`, `-200026/riscv64/`. - All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD`, failures and faults. - The `dict_hash` set is unchanged. - The relay gives `3` through a real switch. - Idle log growth: 0 bytes. - Clean build on all three plus hosted, no new warnings. **What this makes possible (not exercised yet):** every `WORKER-BIRTH`ed DoE worker is now a participant. Mail sent to a worker is delivered by a switch onto its own stack, and it competes by heat. Stage G's `SEND` word and interleave test are next.