Files
LithosAnanake/FABRIC-3.8.md
T
Robert Allan JamesandClaude Opus 5.5 6a313c7bf2 FABRIC-3.8.md Stage FR: VM frame stacks, each frame with an ID
Captain Bob: "give frames IDs and call by ID" -- frames stack per VM, each
with an ID. New vm_frame.h/.c: VMFrameEntry { VMFrameID id; VMUuid vm_id;
kind (INTERPRET/DRAIN/IDLE) }, a growable VMFrameStack embedded as VM.frames
(reserved at birth, freed in vm_cleanup, grown by allocate-copy-free).
vm_interpret() -> vm_interpret_kind() pushes a frame onto the running stack's
owner, holds only its ID, fails closed if it cannot grow, and pops by ID
(mismatch reported as VM FRAME CORRUPTION).

The Stage A/B loose fields are gone and everything is derived from the stack:
- ownership gate: one frame, and it is vm's own
- drain guard: a DRAIN frame for vm already on the running stack
- parked-idle: an IDLE frame the trampoline pushes around its yield
- log attribution: the top frame's vm_id, resolved by ID through the registry
The single saved-and-restored attribution global is removed.

Evidence: the probe baseline on the pre-frame code read HSSAS (the global
named Hestia while Hera resumed). With frames it reads HSHAS on all three
ISAs, with Hestia's top frame IDLE and Hera's frames back to 0; the probe is
reverted. Formal acceptance on all three ISAs: dict_hash identical and
unchanged, zero UNKNOWN WORD, faults, frame corruption or refused interprets,
and the WIREBIND relay 1 2 + . -> 3 works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 15:47:07 -04:00

40 KiB
Raw Blame History

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-EXECs 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 SENDing 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.


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-EXECs; 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). The WIREBIND relay still works, now delivered by a real switch: switch_count_cumulative increments across the relay and the ~user VM's own drain line appears. The ledger and stadium_conserved() hold (fleet_conserved is not evidence). No native_stack_saved_sp ever equals an address inside Hera's stack range (temporary probe).
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. 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.

Nothing in this document authorizes an edit. Per Captain Bob's Law, no stage starts without an explicit instruction.


6. Execution log

  • 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.
  • 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.
  • 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.