Files
LithosAnanake/FABRIC-3.8.md
T

830 lines
60 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.** A beat is 256 of *either* colon-word entries or words parsed at top level
by `vm_interpret` (the two `check_counter` sites). Primitives executed inside colon bodies are
**not** counted. So the "words" figures below are beat counts × 256 in that sense, not total
primitive executions.
**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
colon-entries/top-level words (see "What a beat counts" above).
- Until then, Hera is never the coolest while any worker has work, **so the console gets no turn
under load.** This doesn't block Stage C's relay evidence, because the relay runs while the
workers are idle. It does block Stage F/H usability.
- Options for Bob:
- (α) accept it;
- (β) compare the heat each VM has been *charged* (its turn heat) rather than its absolute
heat, so the root's structural mass doesn't count against it;
- (γ) something else Bob rules.
- I have no recommendation yet. (β) is a different physical quantity, and Bob should choose it
knowingly.
#### 3.13.3 Proposal: the decision (ruling C-3 on ties)
**Eligible** means the VM is a registered participant, is not the one running, is not dead or
stillborn, and **either** of the following holds:
- its kernel-Hermes pending count is > 0, read directly from the queue
(`sk_hermes_pending_count`), not from a `has_work` flag. The queue *is* the eligibility, so
`SWITCH-MARK-WORK` and G1's missing mark become unnecessary;
- it is switched out, its **frame stack is non-empty, and its top frame is not
`VM_FRAME_IDLE`**. That means it was displaced mid-task, or it is Hera with her REPL chain live.
- Without this clause, a VM preempted in the middle of a payload never gets picked again. This
is what the frame stacks were for.
- A never-entered VM has an empty stack, so it is not eligible unless it has mail.
- Hera parked at an idle point pushes an IDLE frame (§3.12, and §3.13.5 below), so she is not
eligible there unless she has mail or input.
**Switch when** some eligible VM is **strictly** cooler than the one running. If it is equal, the
running VM stays: a tie with the incumbent is "no reason to move," not a coin toss.
**Ties among several equally cool candidates (ruling C-3).**
- **Proposed:** the candidate that has been *waiting longest*, measured by one single-writer
event count.
- Each message is stamped with it when it is admitted (a new `SkHermesMessage` field).
- Each VM is stamped with it when it is displaced mid-task.
- A candidate's wait stamp is the minimum of its oldest pending mail and its displacement.
- Every stamp is unique, so the result is unique.
- This is the arrival order of the mail itself, the kernel's FIFO. It is a fact about the
messages, not a ranking of VMs.
- **Bob to rule** whether arrival order counts as "an ordering" under *"no scheduler allowed."*
- Rejected outright: slot index, UUID and registry order. Each of those is a fixed order of VMs.
**Testable "not a scheduler" criterion.**
- The decision is a pure function of physical state: heat, pending mail, frames and wait stamps.
It keeps no queue of its own and remembers nothing between decisions.
- It must return the same VM whichever order the participant table is walked.
- This is Stage C-b's evidence (below).
**When the decision is made** (only at moments the physics exposes, never on a clock):
1. **The running VM's own beat.** The beat sets a per-VM "decide" flag. The next *owned*
checkpoint consumes it (`sk_vm_owns_outermost_frame`). There are two checkpoint sites:
- the existing one in `execute_colon_word`;
- the between-words point in `vm_interpret_kind`'s loop, which needs a new gated checkpoint
so that a payload made only of primitives can be displaced.
2. **Mail arrival** to a VM that would then win.
3. **The trampoline's between-message point**, as already noted in `sk_vm_switch_entry()`.
4. **Idle points** (Stage F).
**Why not every word.** Under (a), heat changes only at beats, and eligibility changes only when
mail arrives or a queue empties. Between those events every decision would repeat the last one,
so deciding every word is pure overhead. Deciding at the beat means a turn lasts at least one beat
unless the VM runs out of work first.
**Flagged honestly:** that minimum is the existing Loop #7 cadence constant
(`HEARTBEAT_CHECK_FREQUENCY`). It is not new, but it does act as a floor on turn length.
**The switch target on an empty queue.**
- Today the trampoline yields to whoever switched in (`native_switch_back_to`).
- **Proposed:** it asks the decision instead, and falls back to `native_switch_back_to` only when
no VM is eligible.
#### 3.13.4 What removing the timer path changes
- `heartbeat.c:179`'s `sk_vm_switch_signal_tick()` call is removed. The ISR returns to its
pre-§XXVIII minimal shape, so the timer no longer decides anything.
- These are removed:
- the readiness accrual;
- `SK_SWITCH_READINESS_THRESHOLD` (50);
- `has_work`;
- `take_pending`.
- `note_switch_performed` is kept only as the counter.
- **Effect on the DoE CSV** (`doe_log.c:205–230`):
- `switch_count` and `switch_current_slot` survive.
- `switch_ticks_since` loses its meaning. Proposed: it becomes beats since the last switch.
- The three `*_readiness` columns die.
- Found while tracing, *not fixed*: those columns and the heat columns still look up
`"Hermes"`, which no longer exists as a VM since the reshuffle, so those values are always 0
today.
#### 3.13.5 The relay needs the idle point in the same stage (traced)
Stage C-b's own evidence, the WIREBIND relay delivered by a real switch, **can't be reached
without §3.7's idle point** (Stage F):
- The console line is forwarded from C. `sk_hermes_send_one(vm->stadium_vm_id, paired.vm_id,
CONSOLE_CMD, …)` at `repl.c:1510`, then `goto out`. Hera interprets nothing and goes straight
back to polling.
- Today the `~user` VM's mail is delivered by the idle pump (`repl.c:~909–928`), which drains
every live VM *on Hera's stack*. That is G3.
- Once the pump skips participants (C-b), the only places a switch can happen are owned word
checkpoints and beats. Hera at her poll loop reaches neither: she is running C, not words.
- So nothing switches, the `~user` VM's `CONSOLE-CMD-EVENT` sits in its queue, and the relay
breaks.
**Proposed:** fold §3.7's idle point into C-b.
- The pump loop's own location (the idle point, Hera between lines) becomes a decision point.
Hera pushes an IDLE frame, consults the decision, and switches to the coolest eligible VM
instead of draining participants on her stack. The pump then shrinks to Hera's own queue.
- Stage F then only has to show that work proceeds with the console idle.
- The alternative is to keep the pump draining participants until F. That keeps G3's
borrowed-stack delivery alive through C-b, so C-b's relay evidence would prove nothing about
switching.
**Hera's eligibility at the idle point.** Keyboard or serial input pending is Hera's mail. At
the idle point she is eligible when input is pending (the REPL's own input poll), exactly as a
worker is eligible when its kernel-Hermes queue is non-empty. **Ruling C-5**, below.
#### 3.13.6 Stage C, split
This splits the single C row in §4 in two:
- **C-a** is physics only: heat starts moving and nothing switches yet.
- **C-b** is the decision: pump skip, eligibility read from the queue, the ISR path removed, and
the idle point (§3.13.5).
C-a is observable on its own, and doing it first proves the heat signal is alive before anything
depends on it. The §4 table below is updated to show this split, **pending rulings C-1 to C-4.**
---
## 4. Staged plan
Each stage is its own commit and its own three-arch acceptance, and each names its evidence
**before** it runs (`FABRIC-3.5.md` §XXXV.6). A green boot proves nothing here: a switch storm and a
healthy idle produce the same serial log (`FABRIC-3.md` §XXVIII.1).
| Stage | Change | Evidence that proves it (named in advance) |
|---|---|---|
| **A** | §3.1 per-VM ownership gate (+ `MODE_COMPILE` gate). Nothing else. | Byte-identical behavior: `dict_hash` triple unchanged, identical boot, WIREBIND mint→attach→`USE`→relay (the `FABRIC-3.6.md` Phase 4 acceptance sequence) still `1 2 + .` → `3`. |
| **B** | §3.2 trampoline idle loop + §3.3 `hermes_draining` + §3.5 parked-idle flag. Still inert (nothing marks work yet). | Same as A: nothing can reach the new trampoline branch yet, so behavior is identical. A synthetic self-test (kernel_main.c, the §XXVIII Stage 2 `SWITCH-TEST` pattern: write, capture, revert) switches into **Hestia** (not Artemis, whom the Zuse attach flow `VM-EXEC`s; a parked VM refuses `VM-EXEC`) with **two** messages queued, the first calling a colon word so the in-word drain checkpoint is actually reached. The ordering witness `SB-SEQ` must equal 12 (one-at-a-time drain), not 21 (nested). She must end parked-idle at depth 0 with her context saved on her own stack, current VM back to Hera, and the ledger and `stadium_conserved()` restored. |
| **FR** | §3.12 VM frames: `VMFrame` header; Stage A/B fields move into it; stack-scoped log attribution. | Probe `SB-ATTRIB`/`SB-GO`/`SB-BACK` (reverted): attribution sequence Hera, Hestia, **Hera**, Hestia on all three ISAs, and the **same probe fails on the pre-frame baseline** (third entry reads Hestia). Then formal acceptance: `dict_hash` unchanged, relay works. |
| ~~**C**~~ | ~~§3.4 eligibility marked + pump skips participants (one commit).~~ Split into C-a/C-b by §3.13 (pending rulings C-1–C-4). | (see C-a, C-b) |
| **C-a** | §3.13.2 the running VM is charged at each of its own beats, with carry. Physics only; switching unchanged. | Temporary probe (reverted), which switches Hestia in with the Stage FR probe's `SB-GO`/`SB-BACK` words, because C-a itself switches nothing. Hestia runs a colon-word payload that produces ≥ 8 beats. Her `VM-HEAT` is sampled **at a beat during the turn** and again after it, and must rise from 0 by `beats × slope` (whole units, remainder kept in carry). The rest of the live fleet must fall by the same total. `vm_physics_conserved()` holds, and the `fleet_window` sample count is unchanged. **Baseline first:** the same probe on current code shows heat unchanged (proves G9). Then acceptance: `dict_hash` unchanged and the relay works. |
| **C-b** | §3.13.3 decision at owned checkpoints, beats, the trampoline and **the idle point** (§3.13.5, which absorbs Stage F's §3.7). Eligibility comes from the queue or a non-IDLE top frame. The pump skips participants. The ISR switch path and readiness are removed (§3.13.4). | The WIREBIND relay still works, now delivered by a real switch: `switch_count` increments across the relay and the `~user` VM's own drain line appears. Ledger and `stadium_conserved()` hold. `heartbeat_tick()` contains no switch call (source). **Walk-order probe** (temporary): every decision is also computed with the participant table walked in reverse; disagreements = 0. No `native_stack_saved_sp` ever inside Hera's stack range (temporary probe). No switch storm (log growth at idle `ok>`, QMP `send-key` computes a value). |
| **D** | §3.5 kill of parked-idle + unregister in `capsule_vm_kill()`. | Birth, message, idle-park, `KILL` succeeds; the switch-signal slot count returns to its prior value. |
| **E** | §3.6 generic registration in `capsule_birth_baby()`, duplicate call sites removed. | The Tripod and WIREBIND VMs still register exactly once (slot count), and a `BIRTH`-ed VM appears as a participant. |
| **F** | §3.7 idle point is a switch point. *(If C-5 is ruled yes, the mechanism lands in C-b and F keeps only this evidence.)* | With Hera idle at `ok>`, a message sent to a worker **gets processed without typing anything**: the worker's output appears while the console is idle. |
| **G** | §3.8 `SEND`. | Two workers, each sent a payload that loops printing a tagged marker (`A` / `B`) N times: **the markers interleave** in the serial log. This is the one-line proof of concurrency, and it's the test this whole document exists to pass. |
| **H** | §3.9 DoE driver rebuilt (capsule change only). | A single trial at cfg 4 (8 workers): `switch_current_slot` in the per-beat DoE rows moves across **multiple worker slots within one trial**, and `MU-TRIAL … fail=0`. Then Bob's call on relaunching the campaign. |
| **I** | Strip dead code: the pump loop once every VM is a participant, `SWITCH-MARK-WORK` if nothing calls it, the global depth counter once attribution no longer needs it. | Per `FABRIC-3.5.md` §XXII's strip discipline: reachability shown three ways, not by grep alone. |
**Stop conditions** (re-plan, don't push on): `dict_hash` diverges across ISAs; the ledger or
`stadium_conserved()` goes false; any evidence of a stack saved under the wrong VM; a switch storm
(serial log frozen with QEMU pinned, checked by log line-count growth at an idle prompt and by
QMP `send-key` computing a value, per `FABRIC-3.6.md` task 1.5).
---
## 5. Rulings needed from Captain Bob before code
1. **Go/no-go on the staged plan (§4)**, one stage at a time.
2. **Workload capsules (§3.9):** keep self-execution at birth (the heavy phase stays serial), or
born definitions-only with the workload as the first message (recommended, so the measured
phase is the concurrent one). If the latter: new `-def` capsule variants, or strip the tail
call from the existing `workload-N.4th` files (check their other users first, per `FABRIC-3.5.md`
§XXII.2's three reachability routes)?
3. **The send word's name** (§3.8): `SEND`, `HERMES-SEND`, `KH-SEND`, something else.
4. **The multiuser factor.** The DoE as built varies concurrency × workload mode and runs once
per ISA. `FABRIC-3.md` §XXXIII.2 planned identity origin as a fixed arm, never built. Should
identities (`UNATTENDED-BIRTH` capsule identities, or WIREBIND thumbdrives) become a crossed
factor alongside concurrency and ISA, now that "users" is meant to be as important as the other
two? This decides the DoE's shape, not the OS work, and can wait until Stage H.
5. **§3.10's shared PRNG:** accept it for this pass (run order is still deterministic), or put
per-VM `RANDOM` state in scope.
6. **C-1 (§3.13.2):** how heat is charged under self-driven execution.
- Proposed (a): the running VM is charged at each of its own beats. Switch-out-only charging
is purely cooperative, unless decisions compare projected heat (a′).
- Also: charge records no `fleet_window` samples, which means the slope stays frozen at its
unvalidated seed under self-driven load. Or record them and let it adapt.
7. **C-2 (§3.13.2):** a per-VM carry on the new charge only. Without it, (a) moves nothing.
8. **C-3 (§3.13.3):** ties. The incumbent stays on a tie with itself. Among several candidates:
whether "waiting longest," by a single-writer arrival stamp, is acceptable under *"no scheduler
allowed,"* or whether Bob wants something else. Slot, UUID and registry order are rejected.
9. **C-4 (§3.13.2):** Hera's reservoir mass means the console gets no turn under worker load.
Options are (α) accept it, (β) compare charged heat instead of absolute heat, or (γ) something
else. No recommendation.
10. **C-5 (§3.13.5):** fold the idle point (Stage F's §3.7) into C-b, since the relay can't be
delivered by a switch without it. Also: Hera's pending console input counts as her mail for
eligibility.
**Nothing in this document authorizes an edit.** Per Captain Bob's Law, no stage starts without an
explicit instruction.
---
## 6. Execution log
- [x] **Stage A — per-VM ownership gate (§3.1).** 2026-09-23. `include/vm.h`: new
`VM.interpret_depth`. `vm_core.c`: `vm_interpret()` raises and lowers it beside the global
counter; new `sk_vm_owns_outermost_frame(vm)` (`sk_vm_switch_current_vm() == vm &&
vm->interpret_depth == 1 && vm->mode != MODE_COMPILE`) replaces `sk_vm_at_outermost_interpret()`
at both checkpoint sites in `execute_colon_word()` (drain and switch). The global counter and
the old function stay until Stage I.
**Evidence, as named in §4:** clean build on all three ISAs with no new warnings (only
the pre-existing `mkcapsule`/linker ones), hosted build clean. Foreground, visible-framebuffer
boots, with `disk/artemis.img` + `zuse-thumb-ident.img` + `bob-thumb-ident.img` reset together
before each: `logs/20260923-140120/amd64/`, `logs/20260923-140654/aarch64/`,
`logs/20260923-141303/riscv64/`. All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD`, zero
self-test FAIL/fault/panic, and a `dict_hash` set **identical across all three and unchanged
from the pre-Stage-A boot** (`logs/20260923-110505/amd64/`): M7.1a `0x3933d76939ef66c6`, Hera
`0x27ba08011349842b`, Hestia `0xdd1aa879b3a6c5df`, Artemis `0xe9d0408b1119d51f`. The full live
WIREBIND sequence on each ISA: hot-attach blank `bob` via QMP → `MINT` (`-1`,
restricted) → detach/reattach → `WIREBIND: rajames attached` → `S" rajames" USE` →
`1 2 + .` → `3`, delivered via `Kernel-Hermes CONSOLE-CMD-EVENT` with
`stadium_conserved(self)=true`. Clean QMP `quit` each time, no leaked processes.
note: this relay still runs through the idle pump on Hera's stack (G3, unchanged until
Stage C). With the new gate, checkpoints inside that relayed payload now correctly **fail**
the ownership test (Hera owns the stack, not `rajames~user`). Before Stage A they passed on
the global depth count, which was the latent §1 hazard.
- [x] **Stage B — trampoline idle loop, one drain at a time, parked-idle (§3.2/§3.3/§3.5).**
2026-09-23. `include/vm.h`: `VM.hermes_draining`, `VM.parked_idle`. `kernel_hermes.c`:
`sk_hermes_drain_checkpoint()` refuses while `hermes_draining` is set, and sets and clears it
around its `vm_interpret()` (set only once a message is in hand; single exit). `switch.c`:
`sk_vm_switch_entry()` is now each participant's kernel-side idle loop. It drains one of its
own messages per pass, driven by the drain's return value, not a separate pending probe
(so a non-consuming drain can't spin with no checkpoint to break it). When nothing is
drained, it sets `parked_idle` and yields to `native_switch_back_to`; a failed yield panics.
Inert in normal operation: nothing marks work yet (Stage C), so no real boot enters it.
**Finding, fixed in this stage: amd64 first-entry stack misalignment.** The first probe boot
(`logs/20260923-142526/amd64/`, kept) took **#GP (vector 13)** the moment Hestia's trampoline
called into `vm_interpret()`. `sk_vm_switch_prime` (amd64 `switch.S`) built its frame at
`top − 56`, so the entry function ran with `rsp == top` (16-aligned). SysV needs
`rsp ≡ 8 (mod 16)` at entry, so every compiler-emitted aligned SSE spill faulted. Latent since
§XXVIII Stage 2, because the old placeholder never called anything that spilled. Fixed:
frame at `top − 64` with a zeroed pad slot. aarch64 and riscv64 return through the link
register and already land 16-aligned; no change there.
**Evidence, as named in §4:** a temporary probe (kernel_main.c, written, captured,
**reverted**; `git diff` afterward shows only `vm.h`, `kernel_hermes.c`, `switch.c` and amd64
`switch.S`) queued two messages to Hestia, the first calling a colon word, and switched
Hera→Hestia. `STAGE-B PROBE: PASS` on all three: `logs/20260923-143121/amd64/`,
`-143435/aarch64/`, `-143838/riscv64/`. Sends ok, switch ok, 0 pending, `parked_idle=1`,
depth 0, context saved on her own stack, current VM back to Hera, **`SB-SEQ = 12`** (one at a
time; a nested drain gives 21), ledger `held` restored, `stadium_conserved(hera)` true. The
probe's printed line reads `seq=ld …` because this kernel's freestanding `snprintf` has no
`%lld` and shifted the arguments after it; the PASS test compares values directly and is
unaffected. Formal acceptance with the probe gone, coupled images reset before each:
`logs/20260923-144234/amd64/`, `-144701/aarch64/`, `-145224/riscv64/`. All reach
`[zuse@Hera] ok>` with zero `UNKNOWN WORD`/failures/faults, the `dict_hash` set identical
across ISAs and unchanged from Stage A, and the full mint → WIREBIND → `USE` → `1 2 + .` →
`3` relay with `stadium_conserved(self)=true`.
- [x] **Stage FR — VM frames: a frame stack per VM, each frame with an ID (§3.12).** 2026-09-23.
Captain Bob: *"give frames IDs and call by ID"*, *"frames stack per VM, each with an ID."*
New `include/starkernel/vm/vm_frame.h` / `src/starkernel/vm/vm_frame.c`: `VMFrameEntry`
`{ VMFrameID id; VMUuid vm_id; kind }` (kinds `INTERPRET`/`DRAIN`/`IDLE`); `VMFrameStack`
embedded as `VM.frames`, reserved at birth (`vm_init_with_host`), freed in `vm_cleanup`,
grown by allocate-copy-free. `vm_interpret()` → `vm_interpret_kind()`: pushes one frame onto
the running stack's owner (`vm_frame_current_stack()`; a boot stack before seeding), holds
only its ID, **fails closed** if the push can't grow, and **pops by ID** at exit (a mismatch
is printed as `VM FRAME CORRUPTION` and only our own frame is removed). The Stage A/B loose
fields are gone, all derived now: the ownership gate is "the owner's stack holds exactly one
frame and it's `vm`'s"; the drain guard is "a `DRAIN` frame for `vm` is on the running
stack"; parked-idle is a `VM_FRAME_IDLE` frame the trampoline pushes around its yield and
pops before draining; log attribution is the top frame's `vm_id`, **resolved by ID** through
the VM registry (empty stack → NULL → `HADES`). The attribution global is removed.
**Evidence, as named in §4:**
- **Baseline (pre-frame code, probe only):** `logs/20260923-150901/amd64/` →
`attribution=HSSAS want=HSHAS FAIL`. The old global named Hestia while Hera resumed,
which is exactly the defect.
- **With frames, probe on all three ISAs:** `logs/20260923-152109/amd64/`,
`-152424/aarch64/`, `-152828/riscv64/` → `attribution=HSHAS hestia_idle_top=1
hera_frames=0 PASS`. That covers interleaved attribution, nested `VM-EXEC` into Artemis
still attributed to Artemis, Hestia parked with an `IDLE` top frame, and Hera's frames
back to zero. The probe (`SB-GO`/`SB-BACK`/`SB-ATTRIB`, probe-only control words) was
reverted (`git checkout -- src/starkernel/kernel_main.c`).
- **Formal acceptance without the probe, coupled images reset before each:**
`logs/20260923-153227/amd64/`, `-153644/aarch64/`, `-154208/riscv64/`. All reach
`[zuse@Hera] ok>`, with zero `UNKNOWN WORD`/failures/faults, **zero `VM FRAME CORRUPTION`
and zero refused interprets**. The `dict_hash` set is identical across ISAs and unchanged
from Stages A/B. The full mint → WIREBIND → `USE` → `1 2 + .` → `3` relay works (the path
with a nested frame on Hera's stack). Clean build on all three plus hosted, no new warnings.