Files
LithosAnanake/FABRIC-3.8.md
T
Robert Allan JamesandClaude Opus 5.5 dd23c3aa45 Stage I: strip dead code -- pump drain loop, SWITCH-MARK-WORK, global interpret depth, native_switch_back_to (FABRIC-3.8 §4 row I)
Each shown dead three ways (FABRIC-3.5 §XXII): the pump loop by its
condition, by construction (birth cap == switch table capacity, every birth
registers) and at run time (probe silent through boot, G interleave, an
8-worker DoE trial and the relay); SWITCH-MARK-WORK by callers, capsules
(source and baked), experiments/tools/logs; the depth counter and back_to
field by having no readers.

Kept: Hera's own pump drain (last timer-paced delivery, Hera only) and the
always-0 readiness CSV columns (a DoE schema call). Acceptance on amd64/
aarch64/riscv64: G interleave unchanged, cfg-0 trial fail=0 2/2 dead,
relay 3, idle growth 0; dict_hash set changed by the removed word
(word_count 531) and identical across ISAs. FABRIC-3.8 stages A-I done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 06:23:41 -04:00

1374 lines
96 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. `" <n> 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.7 Finding at Stage G: under C-4 (α), a sender can't dispatch a batch (2026-09-23)
`SEND` is built (§3.8). The naive interleave test, typed at `[zuse@Hera] ok>` with ordinary
words only (`logs/20260923-201602/amd64/`):
```
S" workload-1-lite.4th" S" wa" WORKER-BIRTH . → 1
S" workload-1-lite.4th" S" wb" WORKER-BIRTH . → 1
S" : LA 40 0 DO 65 EMIT 300 0 DO LOOP LOOP ; LA" S" wa" SEND . S" : LB 40 0 DO 66 EMIT 300 0 DO LOOP LOOP ; LB" S" wb" SEND .
→ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB1 ok
```
`SEND` works: each payload is delivered by a switch and runs on the worker's own stack. But the
run is **serial**, and the mechanism is exactly C-4 (α):
- The first `SEND` makes `wa` eligible at heat 0.
- Hera (heat ≈ `Q48_ONE`) is displaced at her next owned checkpoint, before she even prints that
`SEND`'s `1`.
- She is eligible only as "parked mid-task" and is the hottest VM, so she gets the CPU back only
once `wa` has no work left. Then she sends to `wb`.
The rebuilt DoE driver (Stage H) would serialize the same way: it is Hera sending to N workers in
a loop. So would the console under load (already recorded under C-b).
**Needs Captain Bob's ruling (C-4 reopened):**
- **(β) compare work heat, not absolute heat.** Work heat is each VM's cumulative heat pulled in
by its own per-beat charges (Stage C-a). It is conserved-transfer bookkeeping already happening,
so no new physics, but it is a different quantity than the one "coolest" has meant so far.
Hera's structural endowment doesn't count.
- Predicted: Hera keeps the CPU while she has work, because on a tie the incumbent stays and the
workers' heat is still 0. She sends both messages, and the workers then alternate by work
heat.
- Hera with pending console input is cooler than busy workers, so the console gets turns under
load.
- **(δ) mail arrival stops triggering a decision for the running sender.** Decisions happen only
at beats, the trampoline and idle points.
- Batches sent within one beat all land before anyone runs.
- Where the beat boundary falls decides whether a given batch lands intact. It's deterministic,
not guaranteed.
- It doesn't address the console starvation.
- **(ε) keep (α)** and prove interleaving with a chain instead: worker A's payload `SEND`s B's.
That satisfies Stage G's evidence, but leaves the DoE driver and the console serialized.
**RULED 2026-09-23, Captain Bob: (β) work heat.** The decision compares
`vm_physics_work_heat_of()`, not `vm_physics_heat_of()`. Absolute heat still moves, is still
conserved, and still appears in the DoE heat columns; it just no longer decides turns. The
prediction held on all three ISAs: see the Stage G entry in §6.
**Refined at Stage H, RULED 2026-09-23, Captain Bob: "floor on contact."** Two changes to what
work heat is, both forced by a live stall (Stage H entry, §6):
- **Assessed, not moved.** Work heat accrues each beat's assessed charge (the slope, in the
carry's sub-unit scale), whether or not the rest of the fleet held heat to move.
- As first built, it counted only heat actually moved.
- A busy-waiting Hera, with nobody else holding absolute heat, then stayed at 0 forever, tied
the workers, and never yielded.
- **Floor on contact.** When a VM with no work gains some (its kernel-Hermes queue goes from
empty to one message, and it is neither running nor parked mid-task), its work heat is raised
to the lowest work heat among the VMs that have work right now: the running one, and every
eligible participant.
- Without the floor, a fresh worker at 0 would run alone until it had done as much work as
Hera has since boot. The batch would start serially.
- No tuning constant.
- Rejected alternatives: decay/dissipation (needs a rate constant), and assessed charge
without the floor.
#### 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) — **RULED 2026-09-23: `SEND`.**
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) — RULED 2026-09-23: (β) work heat** (§3.13.7; first taken as (α) at C-b,
reopened at Stage G). 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.
- [x] **Stage G — `SEND`, and two workers interleave (§3.8, §3.13.7).** 2026-09-23. Captain
Bob: *"SEND, go ahead with stage G"*, then ruling C-4 (β), work heat, when the first run
came out serial.
Changes:
- `repl.c`: new `SEND ( paddr plen name-c name-u -- ok? )`, registered for every VM
through the standard word set (`sk_repl_register_words`).
- It resolves the target by registry name, copies the payload (at most 1023 chars plus
the NUL; oversized payloads are **refused, not truncated**), and calls
`sk_hermes_send_one()`. That stamps the message and sets decision-due (C-b).
- `from` is the calling VM, derived in C, never taken from the stack.
- It returns 1 if admitted, 0 if refused.
- No C-side restriction; ACL is `ACL.4th`'s job.
- `kernel_hermes.h`: new `SK_HERMES_MSG_TYPE_SEND` (30), clear of every older type space.
- **C-4 (β):**
- `capsule_vm_physics.c/.h`: a per-VM `work_heat_q48` accumulates what each of the VM's
own per-beat charges actually moved in. Read with `vm_physics_work_heat_of()`.
- `sk_vm_switch_decide()` compares work heat for candidates and for the incumbent.
**First run, before (β):** `logs/20260923-201602/amd64/`, the §3.13.7 finding. It came
out `AAAA…A1 BBBB…B1`, serial.
**Evidence, as named in §4.** Only ordinary words, typed at `[zuse@Hera] ok>`, no probe:
```
S" workload-1-lite.4th" S" wa" WORKER-BIRTH .
S" workload-1-lite.4th" S" wb" WORKER-BIRTH .
S" : LA 40 0 DO 65 EMIT 300 0 DO LOOP LOOP ; LA" S" wa" SEND . S" : LB 40 0 DO 66 EMIT 300 0 DO LOOP LOOP ; LB" S" wb" SEND .
```
Result, **byte-identical on all three ISAs** (`logs/20260923-202403/amd64/`,
`-204054/aarch64/`, `-205150/riscv64/`):
```
[zuse@Hera] 1 1 ok
[zuse@Hera] ok> AAABBBBBBBBAAAAAAAABBBBBAAAAABBBBBAAAAABBBBBAAAAABBBBBAAAAABBBBBAAAAABBBBBAAAABB
```
- **A = 40, B = 40, in 16 alternating runs.**
- Hera kept the CPU while she had work (work heat tied at ~0, so the incumbent stays), and
both sends landed.
- From her idle point, `wa` and `wb` took turns. Each switch came from the per-beat charge
making the runner strictly hotter by work heat.
- Nothing in either payload asks to yield.
- The sequence is deterministic across ISAs, as the execution-paced beat implies.
**Acceptance,** in the same three boots, after the interleave test with both workers still
alive:
- All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD`, failures and faults.
- Mint → WIREBIND → `USE` → `1 2 + .` → `3`, with `stadium_conserved(self)=true`.
- Idle log growth: 0 bytes.
- **The `dict_hash` set changes, as expected, because `SEND` is a new word in every VM's
dictionary, and is identical across all three ISAs.**
| | New | Stages A–E |
|---|---|---|
| `word_count` | 533 | 532 |
| M7.1a | `0x330b28ce0f4e1c75` | `0x3933d76939ef66c6` |
| Hera | `0x0bf15066a73f6e32` | `0x27ba08011349842b` |
| Hestia | `0x9116e19b34d2cb0b` | `0xdd1aa879b3a6c5df` |
| Artemis | `0x165a9a66eafce131` | `0xe9d0408b1119d51f` |
- Clean build on all three plus hosted, no new warnings.
**Harness note (not a kernel change):** the first aarch64 attempt
(`logs/20260923-203226/aarch64/`, kept) typed the next line while Hera was still birthing
`wa`. Serial input received while Hera isn't polling is dropped, so the line arrived
truncated (`S" workload-1-li…`) and gave `UNKNOWN WORD`. My test script now waits for each
prompt before typing. Serial input is lost while the console isn't reading, which is the
pre-existing behaviour.
**Consequences of (β) to know:**
- Work heat only grows. A long-lived VM that has done a lot of work loses ties to newer
ones until they catch up.
- Hera's work heat grows only when other VMs hold absolute heat for her charges to pull
from. She runs cheaply while they hold ~0.
- Both are properties of the ruled quantity, recorded here, not changed.
- *(Superseded at Stage H: both consequences above turned out to matter, and the "floor on
contact" refinement replaced them. See §3.13.7 and the Stage H entry.)*
- [x] **Stage H — the DoE rebuilt on ordinary words; "floor on contact" (§3.9, §3.13.7).**
2026-09-23. Captain Bob: *"defs-only, no users factor yet, accept PRNG, go ahead with H"*,
and then ruled **floor on contact** when the first trial stalled.
**Changes, capsules:**
- The ten workload capsules the DoE births (`workload-0`, `-1-lite`, `-2`, `-3`, `-4`,
`-5-lite`, `-6`, `-7`, `-8`, `-9`) are now **definitions only**.
- Every top-level line that executed at birth is removed. That includes the heavy
self-exec tails and the mid-file `SEED`/wave/`RUN-ASYM`/`RUN-DUAL` runs in 2, 3 and 9.
- Each gains `: WL-RUN <exactly what it used to run at birth> <its entry word, twice> ;`.
That is the old trial's birth work plus its two `VM-EXEC` passes, now one job.
- Each also gains `: WL-DONE-ARGS S" MU-DONE" S" Hera" ;`.
- `workload-4` keeps its `4:dopi-ok` print inside `WL-RUN`.
- `workload-7` needed a second block for the two definitions. Block 2151 was unclaimed;
the conflict gate and `mkcapsule --lint` pass (37 files, 0 violations).
- `workload-1` and `-5` (full versions) are untouched, since the DoE doesn't birth them.
- `multiuser-doe.4th`:
- `WL-ENTRY`, `MU-TOUCH-WORKER/ALL` (the serialized `VM-EXEC` round-robin) and
`MU-CHECK-WORKER/ALL` are gone.
- New: `MU-DONE-COUNT`/`MU-DONE`, `MU-JOB` (the constant job text `WL-RUN WL-DONE-ARGS
SEND`), `MU-SEND-WORKER/ALL`, and `MU-WAIT-ALL` (an ordinary `BEGIN … WHILE REPEAT`;
Hera's own checkpoints drain the `MU-DONE`s).
- `MU-RUN-TRIAL` is now birth all, send all, wait, kill all, marker.
- A refused `SEND` counts as a failure **and** as done, so the wait can't hang on it.
**Changes, kernel:**
- `vm_core.c`: the between-top-level-words checkpoint (C-b) doesn't fire after the
**last** word (`vm_input_exhausted()`). The job's final word is the `SEND` to Hera, which
makes a decision due. Switching the worker out right there would park it "mid-task" with
nothing left, and `KILL` would refuse it. The trampoline decides a moment later anyway.
- **Floor on contact** (§3.13.7):
- `vm_physics_charge()` accrues the assessed slope into `work_heat_q48`.
- New `vm_physics_work_heat_floor()`.
- New `sk_vm_switch_signal_note_work_gained()`, called by `sk_hermes_send_one()` when the
recipient's queue goes from empty to one message.
**First trial, before the floor** (`logs/20260923-211253/amd64/`, kept): stalled.
- After 8,200+ beats the rows showed `switch_count_cumulative=0` and
`switch_current_slot=2` (Hera) throughout.
- Hera sat in `MU-WAIT-ALL`, with work heat 0 against workers at 0: a tie, so the
incumbent stays, forever.
**Evidence, as named in §4.**
- **Rows** (`logs/20260923-212353/amd64/`, cfg 4 with `HB-ON`, stopped deliberately once
captured because per-VM rows at every beat make a full trial take hours):
- `switch_count_cumulative` reached 44,297.
- Over the first ~7,600 rows, `switch_current_slot` was spread **evenly** across Hera
(slot 2: 851) and all eight workers (slots 5–12: 844–846 each).
- **Full trials** at cfg 4 (8 workers, uniform `workload-8`), rows off, one per ISA:
`logs/20260923-220439/amd64/`, `-222512/aarch64/`, `-231553/riscv64/`.
- Each ends with **`MU-TRIAL isa=<isa>run=999 cfg=4 rep=0 nw=8 mode=0 fail=0`**.
- **All 8 `KILL: muN dead`, 0 refused.** Every worker ended parked idle and killable.
- Times: amd64 ≈ 13 min, aarch64 ≈ 43 min, riscv64 ≈ 23 min.
- **Stage G's test, re-run on each boot under the refined rule:** byte-identical on all
three, `ABBAABBABAABBAABBABAABBA…` (A = 40, B = 40, **48** runs). It is finer than
Stage G's 16 runs because work heat now moves by sub-unit steps every beat.
**Acceptance,** in the same three boots after the trial:
- All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD`, failures and faults.
- The `dict_hash` set is unchanged from Stage G (word_count 533, M7.1a
`0x330b28ce0f4e1c75`).
- Mint → WIREBIND → `USE` → `1 2 + .` → `3`.
- Idle log growth: 0 bytes.
- Clean build on all three plus hosted, no new warnings.
**Known limits, recorded, not changed:**
- **A worker whose job errors never sends `MU-DONE`,** so `MU-WAIT-ALL` waits forever.
The error prints, but the trial produces no marker.
- **Worker execution errors are no longer counted.** `VM-ERROR?` can't see them, because
`sk_hermes_drain_checkpoint()` restores the VM's error flag after each payload (by
design: a bad message must not abort the enclosing context). `fail` now counts only
refused `SEND`s.
- **Hera's wait loop is a busy loop.** She takes a fair share of the CPU
(1/(N+1)), as the row tally shows.
- Per-VM, per-beat `HB-ON` rows make trials slow. One row in that capture shows a negative
(wrapped) `tick_interval_ns` for a VM a beat behind the others. That's a DoE-row
observation, not investigated.
- Stage F's remaining evidence (work proceeds with the console idle) is shown by Stage G's
interleave and by these trials.
- [x] **Stage I — strip the dead code (§4 row I).** 2026-09-24. Captain Bob: *"go ahead with
stage I."* Per `FABRIC-3.5.md` §XXII's discipline, each removal's reachability was shown
three ways, not by grep alone.
**Removed:**
| Item | Why dead |
|---|---|
| **The idle pump's drain loop** (`repl.c`, `sk_repl_idle()`), which drained every non-Hera live VM on Hera's stack (G3) | (1) **Static:** since C-b it acted only on a live **non-participant**. (2) **Construction:** births are refused at `stadium_max_vm_count()` live VMs (`capsule_birth.c:731`), the same capacity as the switch-signal table. Every birth registers (Stage E), stillborn VMs never go live, and `KILL`/reap unregister, so registration can't fail and no live non-participant can exist. (3) **Runtime:** a probe line in that branch (temporary, removed) printed **nothing** through a boot, the Stage G interleave, a full 8-worker cfg-4 trial (`fail=0`, 8/8 dead) and the WIREBIND relay (`logs/20260924-051657/amd64/`). |
| **`SWITCH-MARK-WORK`** (the word, `mama_word_switch_mark_work`, and `sk_vm_switch_signal_mark_work()`) | (1) Its only caller, `MSG-SEND` in `capsules/common/messaging.4th`, was deleted in `FABRIC-3.6.md` Phase 4. (2) No capsule source, no baked capsule (`capsule_generated.c`: 0), no experiment, tool or committed log invokes it; only the design docs mention it. (3) Since C-b, eligibility **is** the queue, so the word had nothing left to mark. |
| **The global interpret depth** (`g_vm_interpret_depth`, `sk_vm_at_outermost_interpret()`) | No caller since Stage A replaced it with the per-VM ownership gate. Only comments referred to it; they're updated. |
| **`VM.native_switch_back_to`** (`vm.h`) | Written by `sk_vm_context_switch()`, read by nothing since C-b's trampoline stopped yielding to "whoever switched in" (the Stage B/FR probes, which read it, are long reverted). |
**Kept, deliberately:**
- **Hera's own drain in the idle pump.** She is the one VM never switched to for her mail,
since she's the one running there. It still runs at the `SK_IDLE_BEAT_INTERVAL`
(timer-paced) cadence, and at her own word checkpoints. **This is the last
timer-paced delivery left, and it applies only to Hera's own queue.** Moving it to
`sk_vm_idle_point()` would need care with the readline `n > 0` mid-edit gate. Noted for
Captain Bob.
- **The three `*_readiness` DoE CSV columns** (always 0 since C-b). Dropping them changes
the CSV layout the extractor and analysis read. That's a DoE-schema decision for the
campaign relaunch, not a dead-code strip.
**Acceptance** (on every ISA: the Stage G interleave, a cfg-0 DoE trial, then the relay):
`logs/20260924-054146/amd64/`, `-055249/aarch64/`, `-061024/riscv64/`.
- The G interleave is byte-identical to Stage H on all three (A = 40, B = 40, 48 runs).
- `MU-TRIAL … cfg=0 … nw=2 … fail=0`, with 2/2 `KILL … dead` and 0 refused.
- All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD`, failures and faults.
- Mint → WIREBIND → `USE` → `1 2 + .` → `3`.
- Idle log growth: 0 bytes.
- Clean build on all three plus hosted (`vm.h` is shared), no new warnings.
- **The `dict_hash` set changes, as expected, because `SWITCH-MARK-WORK` is gone, and is
identical across ISAs.** `word_count` drops by 2 because the word was registered twice
in Hera's dictionary.
| | New | Stages G/H |
|---|---|---|
| `word_count` | 531 | 533 |
| M7.1a | `0x572daac83b797332` | `0x330b28ce0f4e1c75` |
| Hera | `0xda9742ce9c3376a2` | |
| Hestia | `0x413e7d086f342d8b` | |
| Artemis | `0xdfd68316d5f5ba85` | |
**FABRIC-3.8's staged plan (A–I) is complete.** What's next is the per-ISA DoE relaunch
(seed 12345, 3 reps) on the rebuilt driver, then the merge to master.