SEND ( paddr plen name-c name-u -- ok? ): general kernel-Hermes send to a named VM, from = calling VM, one block max (refused, not truncated), every VM. New SK_HERMES_MSG_TYPE_SEND (30). Ruling C-4 (beta), Captain Bob: the switch decision compares work heat -- the heat each VM pulled in by its own per-beat charges -- not absolute heat, so Hera's structural endowment no longer makes her hottest. Before it, the two-worker test ran serial (AAAA...BBBB); after it, byte-identical on amd64/aarch64/riscv64: AAABBBBBBBBAAAAAAAABBBBBAAAAABBBBBAAAAABBBBBAAAAABBBBBAAAAABBBBBAAAAABBBBBAAAABB (40 A, 40 B, 16 runs; ordinary words only, nothing yields). Acceptance: relay 3, idle growth 0; dict_hash set changed by the new word (word_count 533) and identical across ISAs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
86 KiB
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
- 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) - 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.
- Kernel-Hermes publishes facts. It enqueues, and marks the recipient as having work. It never dispatches or orders anything. (§XXXII, §XV.3)
- 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 usingBIRTHand a message send, exactly like any future program would. Nothing new decides turn order. The existing readiness/threshold signal stays as it is. Its hardcodedCORRECTED 2026-09-23, Captain Bob: "the timer is NOT the source of preemption, compudynamics is." Today's switch decision is timer-driven:50threshold remains the flagged seam (FABRIC-3.5.md§XV.3), not something this pass tunes.heartbeat_tick()(heartbeat.c:179, ISR context) callssk_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 fixed50(capsule_vm_switch_signal.c:58,:191–210). That's a time-slice scheduler with a threshold, the opposite ofFABRIC-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) andvm->interpret_depth == 1(at its own outermost interpret frame).
- New per-VM field
interpret_depth, incremented and decremented invm_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-EXECinto 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:
- 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. - 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()callssk_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:
- Birth N workers (serial, as all birth is).
- Send each worker its workload command as a message. Workers now run concurrently, each on its own stack, preempted by the switch signal.
- 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. - 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. - Check errors,
KILLeach 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. AnyRANDOMa 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_COMPILEgate. 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 isFABRIC-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; FORTHVM-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 judgmentstadium_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" (memoryproject_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;.VMgets 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_vmlives 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 restoresowner->frame.attrib_vminstead of the global. A nestedVM-EXECinto Artemis on Hera's stack still attributes to Artemis, exactly as today.vm_log_attributed_vm()returnscurrent->frame.attrib_vm(orcurrentitself 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(vendoredstarforth_words.c, shared with the hosted build, open ruling §5 item 5), the compile-time statics (covered by the Stage AMODE_COMPILEgate;FABRIC-4.mdscope), andg_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; }.idis a system-wide monotonic 64-bit frame ID (0 = none, never reused).vm_idis the VM executing at that level, held as an ID, never a pointer, so nothing can dangle after a VM dies.kindisVM_FRAME_INTERPRETorVM_FRAME_DRAIN.VMFrameStack { entries, count, cap, max }, one per VM, embedded asVM.frames. It holds the nested interpret levels running on that VM's own native stack (a nestedVM-EXECinto Artemis on Hera's stack is a frame on Hera's stack whosevm_idis Artemis). Growable likeVMCallState, but grown by allocate-copy-free rather thansf_realloc(which orphans the old block). Before the switch mechanism is seeded, a boot-time stack invm_core.cstands 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 itsvm_idisvm; - drain guard (§3.3): a
VM_FRAME_DRAINframe forvmis already on the current stack (the drain pushes its interpret asDRAINkind); - parked-idle (§3.5):
VM_STATE_SWITCHED_OUTand the top frame isVM_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_idon the current owner's stack, resolved by ID through the VM registry. An empty stack means NULL, which maps toHADES.
- ownership gate (§3.1):
- 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 aVMFrameEntry *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 movesexecution_heat_q48between live VMs (apart fromretire, which moves heat to the root on kill). Its only callers are Hera's three orchestration verbs:VM-STEP,VM-EXECandVM-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 ofQ48_ONE(0x10000 raw) and every other VM starts at 0. - Consequence: once the DoE is rebuilt on
SEND(§3.9), nothing ever callsVM-EXEC. Every worker stays at 0 and Hera stays atQ48_ONEforever. "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 at65536/3= 21845 raw. elapsed_beatscounts 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 pervm_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-VMcheck_counteris bumped at bothvm_core.c:909(each colon word) and:1286(each word parsed byvm_interpret). heartbeat_enabled = 1andworker = NULLare set for every VM invm_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()inheartbeat.c. Today it callssk_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:
sloperaw 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-EXECin the rebuilt DoE, the adaptivefleet_transfer_slope_q48never 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-EXECs, is both
touched (the dispatch law) and charged (the work law). Both go through vm_physics_transfer()
on the same heat, so conservation holds, but the VM's heat then mixes two meanings. Proposed:
accept this and document it. VM-EXEC into a participant becomes rare once §3.8's SEND exists.
Where the charge starts. Each VM's charge baseline is its beats at the moment it becomes
switch-current. Hera's is seeded at sk_vm_switch_set_current(), so her first charge doesn't
cover every beat since boot. The clamp to others_total would make that harmless anyway, but it
should be defined rather than left to the clamp.
What a beat counts (corrected at the start of C-a, from reading vm_core.c:909 in context).
- A beat is every 256 words a VM executes. That counts each word dispatched inside a colon body,
primitives included (the
check_counterbump inexecute_colon_word's inner loop), plus each word parsed at top level byvm_interpret(:1286). - The earlier draft said primitives inside colon bodies weren't counted. That was wrong.
- The "words" figures below are therefore beat counts × 256 executed words.
Hera's reservoir mass (ruling C-4, open, arithmetic shown).
- Hera starts with all of
Q48_ONEas the fleet's structural root (vm_physics_init's "cold mass added to a closed system"). - At slope 1/3 raw per beat, the workers together have to produce about
65536 × 3 × (N/(N+1))beats before Hera cools to a worker's level. For N = 8 that is ≈175k beats, ≈45M executed words (see "What a beat counts" above). - Until then, Hera is never the coolest while any worker has work, so the console gets no turn under load. This doesn't block Stage C's relay evidence, because the relay runs while the workers are idle. It does block Stage F/H usability.
- Options for Bob:
- (α) accept it;
- (β) compare the heat each VM has been charged (its turn heat) rather than its absolute heat, so the root's structural mass doesn't count against it;
- (γ) something else Bob rules.
- I have no recommendation yet. (β) is a different physical quantity, and Bob should choose it knowingly.
3.13.3 Proposal: the decision (ruling C-3 on ties)
Eligible means the VM is a registered participant, is not the one running, is not dead or stillborn, and either of the following holds:
- its kernel-Hermes pending count is > 0, read directly from the queue
(
sk_hermes_pending_count), not from ahas_workflag. The queue is the eligibility, soSWITCH-MARK-WORKand 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
SkHermesMessagefield). - 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.
- Each message is stamped with it when it is admitted (a new
- 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):
- 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.
- the existing one in
- Mail arrival to a VM that would then win.
- The trampoline's between-message point, as already noted in
sk_vm_switch_entry(). - 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_toonly when no VM is eligible.
3.13.4 What removing the timer path changes
heartbeat.c:179'ssk_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_performedis kept only as the counter.- Effect on the DoE CSV (
doe_log.c:205–230):switch_countandswitch_current_slotsurvive.switch_ticks_sinceloses its meaning. Proposed: it becomes beats since the last switch.- The three
*_readinesscolumns 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, …)atrepl.c:1510, thengoto out. Hera interprets nothing and goes straight back to polling. - Today the
~userVM'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
~userVM'sCONSOLE-CMD-EVENTsits in its queue, and the relay breaks.
Proposed: fold §3.7's idle point into C-b.
- The pump loop's own location (the idle point, Hera between lines) becomes a decision point. Hera pushes an IDLE frame, consults the decision, and switches to the coolest eligible VM instead of draining participants on her stack. The pump then shrinks to Hera's own queue.
- Stage F then only has to show that work proceeds with the console idle.
- The alternative is to keep the pump draining participants until F. That keeps G3's borrowed-stack delivery alive through C-b, so C-b's relay evidence would prove nothing about switching.
Hera's eligibility at the idle point. Keyboard or serial input pending is Hera's mail. At the idle point she is eligible when input is pending (the REPL's own input poll), exactly as a worker is eligible when its kernel-Hermes queue is non-empty. Ruling C-5, below.
3.13.7 Finding at Stage G: under C-4 (α), a sender can't dispatch a batch (2026-09-23)
SEND is built (§3.8). The naive interleave test, typed at [zuse@Hera] ok> with ordinary
words only (logs/20260923-201602/amd64/):
S" workload-1-lite.4th" S" wa" WORKER-BIRTH . → 1
S" workload-1-lite.4th" S" wb" WORKER-BIRTH . → 1
S" : LA 40 0 DO 65 EMIT 300 0 DO LOOP LOOP ; LA" S" wa" SEND . S" : LB 40 0 DO 66 EMIT 300 0 DO LOOP LOOP ; LB" S" wb" SEND .
→ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB1 ok
SEND works: each payload is delivered by a switch and runs on the worker's own stack. But the
run is serial, and the mechanism is exactly C-4 (α):
- The first
SENDmakeswaeligible at heat 0. - Hera (heat ≈
Q48_ONE) is displaced at her next owned checkpoint, before she even prints thatSEND's1. - She is eligible only as "parked mid-task" and is the hottest VM, so she gets the CPU back only
once
wahas no work left. Then she sends towb.
The rebuilt DoE driver (Stage H) would serialize the same way: it is Hera sending to N workers in a loop. So would the console under load (already recorded under C-b).
Needs Captain Bob's ruling (C-4 reopened):
- (β) compare work heat, not absolute heat. Work heat is each VM's cumulative heat pulled in
by its own per-beat charges (Stage C-a). It is conserved-transfer bookkeeping already happening,
so no new physics, but it is a different quantity than the one "coolest" has meant so far.
Hera's structural endowment doesn't count.
- Predicted: Hera keeps the CPU while she has work, because on a tie the incumbent stays and the workers' heat is still 0. She sends both messages, and the workers then alternate by work heat.
- Hera with pending console input is cooler than busy workers, so the console gets turns under load.
- (δ) mail arrival stops triggering a decision for the running sender. Decisions happen only
at beats, the trampoline and idle points.
- Batches sent within one beat all land before anyone runs.
- Where the beat boundary falls decides whether a given batch lands intact. It's deterministic, not guaranteed.
- It doesn't address the console starvation.
- (ε) keep (α) and prove interleaving with a chain instead: worker A's payload
SENDs B's. That satisfies Stage G's evidence, but leaves the DoE driver and the console serialized.
RULED 2026-09-23, Captain Bob: (β) work heat. The decision compares
vm_physics_work_heat_of(), not vm_physics_heat_of(). Absolute heat still moves, is still
conserved, and still appears in the DoE heat columns; it just no longer decides turns. The
prediction held on all three ISAs: see the Stage G entry in §6.
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-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. |
| (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
-
Go/no-go on the staged plan (§4), one stage at a time.
-
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
-defcapsule variants, or strip the tail call from the existingworkload-N.4thfiles (check their other users first, perFABRIC-3.5.md§XXII.2's three reachability routes)? -
The send word's name (§3.8) — RULED 2026-09-23:
SEND. -
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-BIRTHcapsule 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. -
§3.10's shared PRNG: accept it for this pass (run order is still deterministic), or put per-VM
RANDOMstate in scope. -
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_windowsamples, which means the slope stays frozen at its unvalidated seed under self-driven load. Or record them and let it adapt.
-
C-2 (§3.13.2): a per-VM carry on the new charge only. Without it, (a) moves nothing.
-
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.
-
C-4 (§3.13.2) — RULED 2026-09-23: (β) work heat (§3.13.7; first taken as (α) at C-b, reopened at Stage G). Hera's reservoir mass means the console gets no turn under worker load. Options are (α) accept it, (β) compare charged heat instead of absolute heat, or (γ) something else. No recommendation.
-
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
-
Stage A — per-VM ownership gate (§3.1). 2026-09-23.
include/vm.h: newVM.interpret_depth.vm_core.c:vm_interpret()raises and lowers it beside the global counter; newsk_vm_owns_outermost_frame(vm)(sk_vm_switch_current_vm() == vm && vm->interpret_depth == 1 && vm->mode != MODE_COMPILE) replacessk_vm_at_outermost_interpret()at both checkpoint sites inexecute_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-existingmkcapsule/linker ones), hosted build clean. Foreground, visible-framebuffer boots, withdisk/artemis.img+zuse-thumb-ident.img+bob-thumb-ident.imgreset together before each:logs/20260923-140120/amd64/,logs/20260923-140654/aarch64/,logs/20260923-141303/riscv64/. All reach[zuse@Hera] ok>with zeroUNKNOWN WORD, zero self-test FAIL/fault/panic, and adict_hashset identical across all three and unchanged from the pre-Stage-A boot (logs/20260923-110505/amd64/): M7.1a0x3933d76939ef66c6, Hera0x27ba08011349842b, Hestia0xdd1aa879b3a6c5df, Artemis0xe9d0408b1119d51f. The full live WIREBIND sequence on each ISA: hot-attach blankbobvia QMP →MINT(-1, restricted) → detach/reattach →WIREBIND: rajames attached→S" rajames" USE→1 2 + .→3, delivered viaKernel-Hermes CONSOLE-CMD-EVENTwithstadium_conserved(self)=true. Clean QMPquiteach 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, notrajames~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 whilehermes_drainingis set, and sets and clears it around itsvm_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 setsparked_idleand yields tonative_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 intovm_interpret().sk_vm_switch_prime(amd64switch.S) built its frame attop − 56, so the entry function ran withrsp == top(16-aligned). SysV needsrsp ≡ 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 attop − 64with 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 diffafterward shows onlyvm.h,kernel_hermes.c,switch.cand amd64switch.S) queued two messages to Hestia, the first calling a colon word, and switched Hera→Hestia.STAGE-B PROBE: PASSon 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), ledgerheldrestored,stadium_conserved(hera)true. The probe's printed line readsseq=ld …because this kernel's freestandingsnprintfhas no%lldand 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 zeroUNKNOWN WORD/failures/faults, thedict_hashset identical across ISAs and unchanged from Stage A, and the full mint → WIREBIND →USE→1 2 + .→3relay withstadium_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 }(kindsINTERPRET/DRAIN/IDLE);VMFrameStackembedded asVM.frames, reserved at birth (vm_init_with_host), freed invm_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 asVM FRAME CORRUPTIONand 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'svm's"; the drain guard is "aDRAINframe forvmis on the running stack"; parked-idle is aVM_FRAME_IDLEframe the trampoline pushes around its yield and pops before draining; log attribution is the top frame'svm_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, nestedVM-EXECinto Artemis still attributed to Artemis, Hestia parked with anIDLEtop 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 zeroUNKNOWN WORD/failures/faults, zeroVM FRAME CORRUPTIONand zero refused interprets. Thedict_hashset is identical across ISAs and unchanged from Stages A/B. The full mint → WIREBIND →USE→1 2 + .→3relay works (the path with a nested frame on Hera's stack). Clean build on all three plus hosted, no new warnings. -
Stage C-a — the work law: the running VM is charged at each of its own beats, with carry (§3.13.2). 2026-09-23. Captain Bob: "commit it and start stage C-a", which accepts §3.13.2's proposals C-1(a) and C-2 as written: charge at each beat, keep a carry, record no
fleet_windowsamples. C-3, C-4 and C-5 are C-b's rulings and are still open. Changes: -capsule_vm_physics.cgets a newvm_physics_charge(vm_id). It adds one beat's slope to a new per-VMcharge_carry, then pulls the whole units from the rest of the live fleet in proportion to each VM's heat, throughvm_physics_transfer(). - Clamped to what the others hold. That shortfall is dropped, as in touch, so the carry can't grow without bound while one VM runs alone. - Units lost to the proportional split's rounding go back into the carry. - Nofleet_windowsample and nolast_active_tickchange, sovm_physics_touch()andVM-EXECbehave byte-for-byte as before. -vm_runtime.c'svm_tick()calls the charge right aftervm_physics_heartbeat_tick(), only when the VM producing the beat issk_vm_switch_current_vm(). A VM running nested inside someone else'sVM-EXECstays on the dispatch law. - §3.13.2 "What a beat counts" is corrected.vm_core.c:909is inexecute_colon_word's inner loop, so primitives inside colon bodies are counted.**Evidence, as named in §4.** A temporary probe (`kernel_main.c`: `SB-GO`/`SB-BACK` plus `SB-HEAT`, which records Hestia's heat, the rest of the fleet's heat, her beat count and `vm_physics_conserved()`; reverted with `git checkout`). It sends Hestia `: SBW ; : SBL 3000 0 DO SBW LOOP ; SB-HEAT SBL SB-HEAT SBL SB-HEAT SB-BACK`, and Hera runs `SB-GO SB-GO`. - **Baseline (pre-change code, probe only):** `logs/20260923-160929/amd64/` gives `hestia_heat 0 → 0 → 0` over beats 15 → 51 → 86, and **FAIL**. Hestia ran 71 beats of her own mail on her own stack and gained nothing, which **proves G9**. - **With the charge, all three ISAs:** `logs/20260923-161448/amd64/`, `-161959/aarch64/` and `-162543/riscv64/` give, byte-identically, `hestia_heat 0 → 11 → 23` at beats 15 → 51 → 86, `rest 65536 → 65525 → 65513`, conserved at every sample, and **PASS**. The heat rises **mid-turn** at each in-turn sample. - Exact to the arithmetic: switched in at beat 15, so ⌊36·21845/65536⌋ = 11 and ⌊71·21845/65536⌋ = 23. Every unit Hestia gained came out of the rest of the fleet (all from Hera; Artemis held 0). - `fleet_window_warm=NO` and `slope 21845` are unchanged. By source, the charge never writes `fleet_window`. **Formal acceptance with the probe gone,** coupled images reset before each: `logs/20260923-163215/amd64/`, `-163757/aarch64/`, `-164359/riscv64/`. - All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD`, failures, faults, `VM FRAME CORRUPTION` and refused interprets. - The `dict_hash` set is identical across ISAs and unchanged from Stages A/B/FR. - The full mint → WIREBIND → `USE` → `1 2 + .` → `3` relay works, with `stadium_conserved(self)=true`. - Clean build on all three plus hosted, no new warnings. **Consequence to watch in C-b:** heat now moves at boot too. Hera is switch-current from her Stage 3 registration onward and is charged for her own beats. She holds nearly all the heat, so her charges pull almost nothing; that is the C-4 arithmetic playing out as expected. The per-beat DoE heat columns will read differently from pre-C-a runs. -
Stage C-b — the compudynamic switch decision, the idle point, the timer path removed (§3.13.3–§3.13.5). 2026-09-23. Captain Bob: "go ahead with C-b as proposed." - Rulings: C-3 as proposed (the incumbent stays on a tie; among candidates, the one waiting longest, by unique wait stamp). C-5 as proposed (the idle point folded in; Hera's pending console input is her mail). - C-4 had no proposal, so I took (α) accept. No special case for Hera; the consequence is recorded below.
Changes: - `capsule_vm_switch_signal.c/.h`: - Removed: readiness, `has_work`, the 50-tick threshold, `tick()`, `take_pending()` and the readiness getters. - New: `sk_vm_switch_decide(self, self_competes)`. It considers participants other than `self` that are alive, not `pending_reap`, and have a native stack. A candidate is eligible with pending kernel-Hermes mail, or when parked mid-task (frame stack non-empty, top frame not IDLE), or for Hera, pending console input. The lowest VM-physics heat wins; on equal heat, the lowest wait stamp. Stamps are unique: either a message's admission or the last time the VM was parked. With `self_competes` set, it returns a candidate only if that candidate is **strictly** cooler than `self`. It keeps no state between calls. - Also new: wait stamps (`next_stamp`, `note_parked`), the "decision due" flag (set by `note_beat` and `note_mail`, consumed at the next owned checkpoint), `is_participant`, and `sk_vm_switch_reap_pending()`. The latter is the Stage 4 `pending_reap` handling, now run at every safe point, one reap per call. - `ticks_since_switch` now counts beats. - `heartbeat.c`: the ISR's `sk_vm_switch_signal_tick()` call is gone. The timer decides nothing. - `kernel_hermes.c/.h`: each admitted message carries `wait_stamp`, and a send sets decision-due. - `vm_runtime.c`: the running VM's beat also calls `note_beat()`, right after its C-a charge. - `switch.c/.h`: - `sk_vm_context_switch()` stamps `from` as parked. - `sk_vm_switch_checkpoint(vm)` re-decides only when a decision is due. - The trampoline decides after every drain: with mail left, it switches only if someone is strictly cooler; with no mail, it goes to the coolest VM with work, or to Hera when nobody has any. That fallback isn't `native_switch_back_to`, because two idle trampolines would bounce into each other forever. A drain that leaves mail unconsumed panics rather than spins. - `sk_vm_idle_point()`: Hera, running with no frame live (between lines), parks with an IDLE frame and switches to the coolest VM with work. - `vm_core.c`: - The `execute_colon_word` checkpoint calls `sk_vm_switch_checkpoint()`. - **New checkpoint between top-level words** in `vm_interpret_kind`, with the same ownership gate, so a primitives-only payload can be displaced. - `repl.c`: - The idle pump skips participants. Their mail goes on their own stacks now; this closes G3 for participants. - `sk_vm_idle_point()` is called on every no-input pass of all three console poll loops (`sk_console_getkey`, `sk_repl_headless_wait`, `sk_console_readline`), **not** at the `SK_IDLE_BEAT_INTERVAL` cadence. That cadence comes from the timer. - `doe_log.c`: the three readiness columns are kept and always 0, so the CSV shape doesn't change. Stage I decides whether to drop them. **Evidence, as named in §4.** A temporary probe, restored afterwards from a pre-probe snapshot and verified free of probe code: - a walk-order check that recomputes every decision walking the slot table in reverse; - a line per switch (names, heats, decision and disagreement counts); - a check that each parked VM's `native_stack_saved_sp` lies inside its own native stack (guard, top). Probe runs with the full relay: `logs/20260923-171359/amd64/`, `-172010/aarch64/`, `-172635/riscv64/` (plus an earlier amd64 run, `-170740`, before the stack check could print). On every ISA: - **`switch 1 Hera(heat 65536) → rajames~user(heat 0)`** happens at Hera's idle point after she forwards the console line. `rajames~user` drains `CONSOLE-CMD-EVENT` **on its own stack** (`stadium_conserved(self)=true`) and prints `3`. - **`switch 2 rajames~user → Hera`** is the trampoline's no-work fallback. - `switch_count` reached 2. - **`parked rajames~user saved_sp inside own stack (checked=1 bad=0)`**. - **`disagree=0` over 40,246,330 (amd64), 4,182,971 (aarch64) and 25,030,081 (riscv64) decisions.** The decision doesn't depend on walk order. - **Idle log growth over 20 s after the relay: 0 bytes.** No switch storm. - Nothing switched during boot. No participant has mail then, and Hera is hottest. **Formal acceptance with the probe gone:** `logs/20260923-173341/amd64/`, `-173947/aarch64/`, `-174610/riscv64/`. - All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD`, failures, faults, frame corruption and refused interprets. - The `dict_hash` set is identical and unchanged. - Mint → WIREBIND → `USE` → `1 2 + .` → **`3`**, delivered by a real switch. - Idle growth: 0 bytes. - Clean build on all three plus hosted, no new warnings. **Observed and recorded, not changed:** - The relayed line's output now follows Hera's `ok>` on the same line (`[rajames@rajames] ok> Kernel-Hermes CONSOLE-CMD-EVENT …`), because delivery happens at her idle point after she prints the prompt. This is cosmetic. - **Idle cost:** the idle point runs the decision on every poll pass (tens of millions of decisions per boot while idle). Each walks the participant table, but the CPU was spinning there anyway. - **C-4 (α) consequence, now concrete:** Hera's other idle work, including xHCI event servicing, USB attach and detach, and block sync, runs from her idle beat. Under sustained worker load Hera is the hottest VM with work and gets no turn, so those services wait until the workers go idle. This doesn't affect the relay. It will matter for Stage G/H, and for any USB keyboard input under load (serial input still makes her eligible). - Stage F's mechanism is now in C-b. Its remaining content is evidence only: work proceeds with the console idle, which this relay already shows for one message. -
Stage D — kill of a parked-idle VM, and unregister on kill (§3.5, G6). 2026-09-23. Captain Bob: "go ahead with stage D." §3.5's
parked_idleflag is the frame predicate Stage FR already made possible, not a new field.Changes: - `switch.c/.h`: new `sk_vm_parked_idle(vm)`. The VM has a saved context, and its frame stack is exactly one frame: its own `VM_FRAME_IDLE`. That describes a VM sitting at its trampoline with nothing in progress. - `capsule_birth.c/.h`: new `capsule_vm_kill_refusal(entry)`, the one place the rule lives. It refuses: - a `SWITCHED_OUT` VM that isn't parked idle (mid-task: a live frame awaits resuming); - one parked idle but with mail still pending (those messages hold their senders' Stadium heat, and nothing would drain them); - **the VM physically running**. A participant can now be the one executing `KILL`, and it can't free its own stack. `capsule_vm_kill()` uses it and now calls `sk_vm_switch_signal_unregister()`, as `capsule_vm_force_reap()` already did. Before this, an ordinary `KILL` left the slot behind. - `mama_forth_words.c`: the `KILL` word used its own copy of the old blanket `SWITCHED_OUT` refusal. It now asks `capsule_vm_kill_refusal()`, and runs its heat retire and active-VM cleanup for any VM that will actually be killed, parked-idle ones included. **Finding, fixed here: a C-b regression.** - `vm_registry_alloc()` never initialized `VMRegistryEntry.pending_reap`, and `kmalloc` doesn't zero memory, so every born VM started with whatever the allocation held. - This was latent since `FABRIC-3.md` §XXVIII Stage 4 (2026-09-14), because only the old checkpoint read it, on a timer-picked target. - C-b's `sk_vm_switch_reap_pending()` walks **every participant at every safe point**, so a garbage non-zero value reaps a healthy VM. Seen live in the first Stage D baseline (`logs/20260923-182435/amd64/`): `REAP: dprobe tombstone reclaimed` appears before any `KILL`. - Fixed at the root: `pending_reap = 0` in `vm_registry_alloc()`. It was the only `VMRegistryEntry` field left uninitialized. - The Tripod and `rajames~user` weren't reaped in C-b's runs only because their bytes happened to be 0. **Evidence, as named in §4.** A temporary probe (`kernel_main.c`, reverted with `git checkout`): - It births `dprobe` from `workload-1-lite.4th` with `WORKER-BIRTH` and registers it by hand, standing in for Stage E. - It sends it `1 2 + DROP`. Hera runs `: DPX 1 DROP ; DPX`; that colon-word checkpoint, with a decision due from the mail, switches to `dprobe`. `dprobe` drains the message, finds nobody with work, and falls back to Hera. - It then checks that `dprobe` is parked idle, runs `S" dprobe" KILL`, and checks the slot count and registry state. Runs: - **Baseline**, with the init fix but no Stage D, amd64 (`logs/20260923-182940/amd64/`): `KILL: refused -- switched-out`, `slots before=3 registered=4 after_kill=4 parked_idle=1 dead=0`, **FAIL**. That proves G6. - A first run with Stage D (`-183452`) still failed identically. That is how the `KILL` word's own copy of the refusal was found; after that, the shared rule. - **With Stage D:** `logs/20260923-184026/amd64/`, `-184502/aarch64/` and `-184946/riscv64/` give `KILL: dprobe dead`, `slots before=3 registered=4 after_kill=3 parked_idle=1 dead=1`, **PASS** on all three. **Formal acceptance with the probe gone:** `logs/20260923-185515/amd64/`, `-190119/aarch64/`, `-190745/riscv64/`. - All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD` and zero failures or faults. - The `dict_hash` set is unchanged. - The relay gives `3` through a real switch. - Idle log growth: 0 bytes. - Clean build on all three plus hosted, no new warnings. **Reported, not changed (pre-existing):** - Killing a **LIVE** VM that still has kernel-Hermes mail queued leaves those messages in its queue. They hold their senders' Stadium heat forever, because nothing drains a dead VM's queue. Stage D refuses the parked case for exactly this reason, but the LIVE path has always had it. - WIREBIND's detach path calls `capsule_vm_kill()` directly, without the `KILL` word's `vm_physics_retire()`. I have not checked whether a detached identity's VM-physics heat is retired elsewhere. -
Stage E — every born VM is a switch participant (§3.6, G4). 2026-09-23. Captain Bob: "yes" (go ahead with Stage E).
Changes: - `capsule_birth.c`: `capsule_birth_baby()` registers the new VM right after its task 3.2 common-channel subscription, at its LIVE completion point. The capsule code has already run to completion by then. A full table is logged and doesn't fail the birth. - `capsule_vm_switch_signal.c/.h`: `sk_vm_switch_signal_register()` is now **idempotent**. An already-registered VM gets its existing slot back, not a duplicate. - `kernel_main.c`: the Artemis/Hestia registrations are removed. Hera is still registered there (she's the one VM never born through `capsule_birth_baby()`), together with `sk_vm_switch_set_current(mama)`. Nothing can switch before that: every switch point requires a current VM. - `capsule_wirebind.c`: the user VM's `sk_vm_switch_signal_register()` call becomes a participation lookup. `switch_slot` was only ever used as a ≥ 0 flag and is now documented as one, since slot indices shift when the table compacts. - The WIREBIND console VM is now a participant too. That's harmless: it never has mail, so it is never chosen. It is unregistered when it's killed at detach (Stage D). - Registration order changes: Hestia, then Artemis at their births, then Hera. Decisions don't depend on order (C-b's walk-order evidence), but the DoE's `switch_current_slot` column now numbers them in that order. - Boot order checked: `sk_vm_switch_signal_boot_init()` (`kernel_main.c:536`) runs before the Tripod births (`:1124`/`:1142`). **Evidence, as named in §4.** A temporary probe, restored away from a pre-probe snapshot and verified free of probe code: - `register()` prints every call, and flags any `DUPLICATE`; - `kernel_main.c` checks Tripod participation, then `WORKER-BIRTH`s `eprobe` **without registering it by hand**, checks it is a participant, and `KILL`s it. Probe runs with the full relay: `logs/20260923-192830/amd64/`, `-193439/aarch64/`, `-194103/riscv64/`. Identical on all three: - `register` lines for Hestia (count 0 → 1), Artemis (1 → 2), Hera (2 → 3), eprobe (3 → 4); - `STAGE-E PROBE: tripod hera=1 artemis=1 hestia=1 slots=3; WORKER-BIRTH eprobe participant=1 slots=4; after KILL slots=3`, **PASS**; - at WIREBIND attach, exactly **two** registrations (the console VM and `rajames~user`); - **zero `DUPLICATE`** lines anywhere; - the relay still gives `3`. **Formal acceptance with the probe gone:** `logs/20260923-194803/amd64/`, `-195401/aarch64/`, `-200026/riscv64/`. - All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD`, failures and faults. - The `dict_hash` set is unchanged. - The relay gives `3` through a real switch. - Idle log growth: 0 bytes. - Clean build on all three plus hosted, no new warnings. **What this makes possible (not exercised yet):** every `WORKER-BIRTH`ed DoE worker is now a participant. Mail sent to a worker is delivered by a switch onto its own stack, and it competes by heat. Stage G's `SEND` word and interleave test are next. -
Stage G —
SEND, and two workers interleave (§3.8, §3.13.7). 2026-09-23. Captain Bob: "SEND, go ahead with stage G", then ruling C-4 (β), work heat, when the first run came out serial.Changes: - `repl.c`: new `SEND ( paddr plen name-c name-u -- ok? )`, registered for every VM through the standard word set (`sk_repl_register_words`). - It resolves the target by registry name, copies the payload (at most 1023 chars plus the NUL; oversized payloads are **refused, not truncated**), and calls `sk_hermes_send_one()`. That stamps the message and sets decision-due (C-b). - `from` is the calling VM, derived in C, never taken from the stack. - It returns 1 if admitted, 0 if refused. - No C-side restriction; ACL is `ACL.4th`'s job. - `kernel_hermes.h`: new `SK_HERMES_MSG_TYPE_SEND` (30), clear of every older type space. - **C-4 (β):** - `capsule_vm_physics.c/.h`: a per-VM `work_heat_q48` accumulates what each of the VM's own per-beat charges actually moved in. Read with `vm_physics_work_heat_of()`. - `sk_vm_switch_decide()` compares work heat for candidates and for the incumbent. **First run, before (β):** `logs/20260923-201602/amd64/`, the §3.13.7 finding. It came out `AAAA…A1 BBBB…B1`, serial. **Evidence, as named in §4.** Only ordinary words, typed at `[zuse@Hera] ok>`, no probe: ``` S" workload-1-lite.4th" S" wa" WORKER-BIRTH . S" workload-1-lite.4th" S" wb" WORKER-BIRTH . S" : LA 40 0 DO 65 EMIT 300 0 DO LOOP LOOP ; LA" S" wa" SEND . S" : LB 40 0 DO 66 EMIT 300 0 DO LOOP LOOP ; LB" S" wb" SEND . ``` Result, **byte-identical on all three ISAs** (`logs/20260923-202403/amd64/`, `-204054/aarch64/`, `-205150/riscv64/`): ``` [zuse@Hera] 1 1 ok [zuse@Hera] ok> AAABBBBBBBBAAAAAAAABBBBBAAAAABBBBBAAAAABBBBBAAAAABBBBBAAAAABBBBBAAAAABBBBBAAAABB ``` - **A = 40, B = 40, in 16 alternating runs.** - Hera kept the CPU while she had work (work heat tied at ~0, so the incumbent stays), and both sends landed. - From her idle point, `wa` and `wb` took turns. Each switch came from the per-beat charge making the runner strictly hotter by work heat. - Nothing in either payload asks to yield. - The sequence is deterministic across ISAs, as the execution-paced beat implies. **Acceptance,** in the same three boots, after the interleave test with both workers still alive: - All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD`, failures and faults. - Mint → WIREBIND → `USE` → `1 2 + .` → `3`, with `stadium_conserved(self)=true`. - Idle log growth: 0 bytes. - **The `dict_hash` set changes, as expected, because `SEND` is a new word in every VM's dictionary, and is identical across all three ISAs.** | | New | Stages A–E | |---|---|---| | `word_count` | 533 | 532 | | M7.1a | `0x330b28ce0f4e1c75` | `0x3933d76939ef66c6` | | Hera | `0x0bf15066a73f6e32` | `0x27ba08011349842b` | | Hestia | `0x9116e19b34d2cb0b` | `0xdd1aa879b3a6c5df` | | Artemis | `0x165a9a66eafce131` | `0xe9d0408b1119d51f` | - Clean build on all three plus hosted, no new warnings. **Harness note (not a kernel change):** the first aarch64 attempt (`logs/20260923-203226/aarch64/`, kept) typed the next line while Hera was still birthing `wa`. Serial input received while Hera isn't polling is dropped, so the line arrived truncated (`S" workload-1-li…`) and gave `UNKNOWN WORD`. My test script now waits for each prompt before typing. Serial input is lost while the console isn't reading, which is the pre-existing behaviour. **Consequences of (β) to know:** - Work heat only grows. A long-lived VM that has done a lot of work loses ties to newer ones until they catch up. - Hera's work heat grows only when other VMs hold absolute heat for her charges to pull from. She runs cheaply while they hold ~0. - Both are properties of the ruled quantity, recorded here, not changed.