Files
LithosAnanake/FABRIC.md
T
Robert Allan JamesandClaude Sonnet 5 f33353430f starkernel: land console_fb_init() reorder (FABRIC.md 4.4g decided)
Moves the console_fb_init() call in kernel_main.c from after
capsule_birth_mama() to before it, so the fleet-birth/self-test
transcript (Hermes x2, Artemis births, Stadium self-tests -- currently
serial-only) is also framebuffer-visible, not just the small post-birth
tail.

4.5f's -O2 experiment already showed this doesn't hang under
optimization, just costs roughly 12x more boot-time heartbeat ticks
(one-shot, paid only during fleet birth, never repeated at runtime).
Captain Bob's call: worth it, since the serial log was never the
problem -- this is about the same transcript also reaching a real
screen.

Three-arch verified: amd64/aarch64/riscv64 all reach ok>, POST
Failed: 0, identical dict-hashes across all three. amd64 screendump
confirms the framebuffer now carries the full transcript.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 17:18:58 -04:00

438 KiB
Raw Blame History

FABRIC.md — the Stadium

Status: Living working document. Started 3 August 2026 and appended to as work proceeds. Sections are marked DECIDED, LEANING, or OPEN so they can be argued with rather than inherited.

How to read it. §115 are the original design argument, written before any code was examined. §16 onward are findings and decisions made against the actual tree, in the order they happened. Where the two disagree, the later section wins — earlier text is left standing, with a pointer, because §19.4 and §19.5 quote it directly and because retracing the reasoning matters more than a tidy read.

On the name. The thing described here is the Stadium. "The arena" was the working name until it collided with src/starkernel/vm/arena.c — the PMM-backed VM page allocator, an unrelated structure. The document has been swept; "arena" now survives only inside block quotes that reproduce an earlier section verbatim, and in §12's preserved question list, which several sections quote.

§25 is the punch list. It is the authoritative statement of what is done and what is not. Read its instructions before doing any work against this document.


1. The claim

StarshipOS currently has four subsystems that each independently implement the same physics: Artemis heats blocks, Hermes ages messages, Console heats dirty cells, ACLs carry heat and TTL. Four implementations, one pattern.

The claim is that this is one mechanism wearing four costumes, and that the dictionary is already the reference implementation of it. Lift the dictionary one level of abstraction and every subsystem becomes an instance rather than a special case.

The argument that decides it: they already have the same wires. Blocks felt different because they are large and live on disk — but size and location are not properties, they are payload details. Strip those away and a block has exactly what a message has.

DECIDED. Direction is not optional. The remaining question is effort, not validity.

Updated by §20 and §17.5. There are five patron kinds, not four — VMs are the fifth and were already implemented (§20.1). And the Console's patron is the dirty event, not the cell (§17.5); "Console heats dirty cells" above is the reading that §9 flagged as suspect and §17.5 resolved.


2. The Stadium

A single region of memory, outside any VM, holding everything currently live.

  • Bounded capacity. The bound is real and inescapable.
  • Allocated at boot, before any VM exists.
  • Not part of the heap.

Corrected after §19.1. This bullet originally read "the bound is what gives K≡1.0 a fixed denominator. Without a hard outer wall, K is bookkeeping rather than a conservation law." That justification does not survive the definition of K.

§19.1 establishes K as a conserved, normalised heat share summing to 1.0. Its denominator is 1.0 by definition; capacity does not enter it, and §19.2 says outright that mass never enters K. A transfer-based sum is equally conserved at three patrons or three hundred — population is not what makes the check meaningful.

The bound is still necessary, for two reasons this section can honestly claim:

  • Finite state (§13). A bounded population is what makes induction over the Stadium straightforward and puts model checking alongside theorem proving. This is the larger payoff and it does depend on the wall.
  • Density needs a volume. §19.2 defines density as heat ÷ mass, and mass is cells occupied. Without a fixed capacity there is nothing for a patron to be dense within, and §19.3's admission rule — admit if denser than the least dense resident — has no meaning because nothing is ever full.

What makes conservation falsifiable is the mechanism, not the bound: heat that is transferred can drift and be caught; heat that is renormalised cannot. See §20.2.

The critical scoping decision, and the one that keeps this from sprawling:

The Stadium holds what is live. Not everything that exists.

DECIDED.


3. The entry

One structure. No variants, no type field, no subclassing.

Wire Meaning
identity handle or name — never a content hash while resident (§24.4)
heat conserved share of 1.0, moved by traffic (§19.1)
TTL remaining lifetime — messages and ACLs only (§17.1)
pin invariance flag (opposite of TTL, not an extension of it)
link index into the Stadium, not a pointer
code field behaviour tag from a closed enumeration (§18.3)
mass cells this patron occupies — its footprint (§19.2)
payload carried in the patron's own cells; large patrons are simply heavy (§23.1)
contains index of the patron currently held inside this one, or none — containment, not a lock (§8, item 1.1)

Fixed-size cells. Links are indices, so the Stadium stays an array — no fragmentation, and tractable for Isabelle later.

A cell is one of exactly two things

The wire table above describes a patron header. §23.1 establishes that a large patron is not held by reference but simply occupies more cells — a 1024-byte block is 17 cells, one header and sixteen of payload. Those sixteen carry no identity, no heat, no TTL and no code field.

That is a second cell shape, and this section's opening line — "One structure. No variants" — forbade it without saying so. Declared properly:

A cell is either a patron header or a continuation cell owned by exactly one patron. The union is closed, two-valued, and fixed at build time.

This introduces no new principle. It is the same discipline §18.3 applies to behaviours: a closed enumeration fixed at build time is as tractable in HOL as a single record, and a two-valued union is the smallest possible instance of one. §13's "one datatype" remains true in substance — the datatype is now a two-constructor sum rather than a single record, which costs a case split and nothing else.

What it is not is a type field. The engine does not ask a header what kind of patron it is; the two-valued distinction is structural, tells the engine only whether a cell begins a patron or continues one, and is exhausted by that. A continuation cell is never ranked, never reaped and never dispatched — it is floor space, accounted for in its owner's mass.

Amended by §19.2 and §23.1. mass is an eighth wire, added when density was defined — density is heat ÷ mass, so mass has to live in the entry. And the original payload rule ("inline if small, by reference if large") was dissolved rather than answered: a large patron occupies more cells, which is what mass already measures. By-reference is reserved for things outside the Stadium, which are not patrons.

Amended again by item 1.1 (§25.2). contains is a ninth wire — an index to the patron currently held inside this one, or none. This does not reopen the two-valued cell union above: the contained patron keeps its own independent header and cells elsewhere in the Stadium, resolved and ranked exactly as any other patron. contains is a reference to that residency, not a physical embedding of one patron's cells inside another's. See §8 for what this wire is for and why it replaced a lock.

Amended by item 3.1 (2026-08-04) — the header/continuation discriminator is an external side bitmap, not a header field. A flat-array scan must tell a header cell from a continuation cell before it knows which shape it is looking at. That rules out folding a tag into either variant's own bytes: a continuation cell has no header fields to place one in, and forcing a common leading tag byte into both shapes would eat into the continuation cell's usable payload, contradicting §23.3's 60-byte figure. Ruled: one bit per cell, in a bitmap kept outside the 64-byte cell array. Item 3.1 declares the bitmap's purpose and indexing; item 3.2 (boot-time allocation) allocates it, since it is Stadium-sized memory decided at boot alongside the cell array itself.

The code field is the entire type system. A block's code field migrates. A message's delivers. A VM's ticks. The engine never asks what kind of thing it is holding; it heats, ranks, reaps, and calls the code field.

If you find yourself wanting a type field so the engine can branch on entry kind, the design has gone wrong. The code field already answers that question.

DECIDED, including the payload question — dissolved in §23.1.


4. Heat

Heat is conferred by traffic, not intrinsic to the entry.

This is the piece that was missing for most of the session. Nothing decides what matters. An entry is hot because activity is concentrated around it — the way a crowd in front of one car makes that corner of the hall hot. Density generates heat; nobody computes it.

Consequences:

  • Ranking is read, not decided. There is no scheduler because there is no policy. The Stadium is simply already in heat order when you look at it.
  • K constrains the total, so ordering is forced by conservation rather than by tuned parameters. There is nothing to tune wrongly. This is the defensible distinction from a scheduler and it belongs in the write-up.
  • Popularity is self-limiting. A crowded entry is harder to reach, which throttles traffic to it, which cools it. The governor is local and emergent — no global damping constant to pick.

TTL expiry stays unconditional: entries leave at their own time, unscheduled, nobody's decision. Pinning remains the separate, opposite mechanism — invariance, not longevity.

DECIDED as amended by §19. The density formulation this section called for is supplied there. Three specific amendments, and the original wording above is left intact because §19.4 and §19.5 quote it:

  • "Density generates heat" is backwards (§19.5). Traffic confers heat; density is heat ÷ mass, derived downstream. One word was carrying two meanings.
  • The third bullet is struck, not repaired (§19.4). "A crowded entry is harder to reach" does not translate — a hot entry is easier to reach, which is what a cache is for. The conclusion survives via the second bullet: heat is zero-sum, so popularity is self-limiting by conservation.
  • The first bullet is now true rather than aspirational. Ranking reads density, which §19.2 makes a number.

The line about TTL and pinning is correct but incomplete — §17.1 shows there are three departure mechanisms, not two: TTL, heat decay, and pin.


5. What is not in the Stadium

This section exists because forcing everything in is how this design turns into a mess.

  • Storage is beneath the Stadium. The show floor is not the warehouse. Artemis is where entries live when they are not in play. Blocks migrate onto the floor when hot and back out when cold — which is heat-driven block migration, already built. Artemis does not become a Stadium occupant; it becomes what the Stadium pages against.
  • Devices are beside the Stadium. The framebuffer is the building's lighting, not an occupant. Console's dirty events are Stadium entries; the pixels are not.

DECIDED, and completed by §17.5, which supplies the third edge this section counted but did not list, and sharpens the second:

Category Relation Example
Warehouse beneath Artemis, and the dictionary (§17.3)
Stadium the floor patrons
Utility beside framebuffer, and devices generally

"The building's lighting" undersells the framebuffer — it reads as part of the structure. §17.5 calls it the power company: external infrastructure the building consumes. Not the Stadium, not the basement of the Stadium, a third thing.


6. Boot order

The engine cannot be a VM service, because VMs live inside the thing it manages.

  1. LithosAnanke establishes the Stadium and starts the engine.
  2. Hera becomes the first entry in it.
  3. Hera births everything else, sizing each VM as it goes.

Structurally the same move as minting Zuse's certificate at first boot: a root that cannot be produced by the mechanism it grounds.

DECIDED. The order was right, and the allocation mechanism this section left unspecified is now given: one global array of fixed-size cells, sized at boot from the memory budget, addressed by index (§17.6b, §22.3). Step 1 above allocates that array before any VM exists; step 2 makes Hera the first patron in it (§20).


7. Hera

Hera's job becomes Stadium distribution. This is not a new responsibility — allocating a VM's share is birthing it, and lifecycle is already what Hera is for.

OPEN: RESOLVED in §22 — elastic. Whether a VM's share is a hard bound or an elastic one that can grow and shrink under pressure, with capacity transferring between VMs as a conserved operation Hera arbitrates. Elastic is more powerful and more work. Under elasticity, birth sizes the rest volume rather than a cap — a more forgiving thing to have to guess right.

§22 takes the elastic option. §19's density definition turns it into a negative feedback loop that runs itself — capacity flows down the density gradient — so it costs less than this section anticipated. The layout that makes it cheap is a single global cell pool with per-VM quotas held as counts (§22.3), and capacity must move on a slower loop than heat (§22.4).


8. The mental model

An auto show hall.

Cars and people, in a building with a fixed capacity. People arrive and leave at their own times. They ask questions and converse — those are the messages. They stand in front of a car for a while and move on. Occasionally one sits in a car, which is the only exclusive thing in the room, scoped to a single object, no global lock.

The hall gets crowded. Crowds get hot.

One discipline to hold: cars and people cannot be two structures. That would be a type field re-entering through a metaphor. They are one entry shape differing only in TTL and code field — a car's lifetime is the show, a person's is a visit; a car's code field is be attended to, a person's is move and attend.

RESOLVED by item 1.1 (§25.2), 2026-08-04. "Occasionally one sits in a car, which is the only exclusive thing in the room, scoped to a single object, no global lock." The instinct that this needs an exclusivity primitive was right; the instinct that it needs a lock was not. Sitting in a car is not mutual exclusion — it is containment. The person-patron does not get barred from the car-patron; it goes inside it.

The mechanism is the ninth wire, contains (§3): an index to the patron currently held inside this one, or none. Getting in sets it; getting out clears it. The contained patron keeps its own independent header and residency — it is still ranked, still heats and cools like anything else — contains only records the relationship.

Reap is gated, not derived. A patron with a non-none contains link cannot be reaped. This is checked ahead of density ranking, as an absolute rule, not inferred from mass or density — a light, cold container with something inside it must not read as evictable just because the numbers say so. This is what actually answers the use-after-free concern §9 raised: the engine cannot select an occupied patron for reaping in the first place.

Containment chains, and unwinding is forced, not chosen. Because a patron can itself be contained, contains links can form a chain — a patron inside a patron inside a patron. If A contains B contains C, A cannot become reapable until B is empty, and B cannot become reapable until C departs. This ordering is topological, not a policy — there is no FIFO/LIFO choice to make here; the chain's own shape forces innermost-first.

Bounded, single occupant per level. Each patron holds at most one contains link (single occupant, not a set). Chain depth is capped — default 5 — enforced by the engine at containment-entry time (refuse to nest past the cap). The cap is a Kconfig symbol, not a hardcoded constant, consistent with this project's existing tunable-knob convention (STARFORTH_ENABLE_VM, HOTWORDS_CACHE_SIZE, etc.) — named at implementation time in item 3.1, default 5, scannable via menuconfig.

What this leaves genuinely open, deferred, not blocking: if multiple independent containment chains are simultaneously blocked and waiting to unwind, whether the engine gives any of them priority over another is a scheduling question, not a header-design one. It does not affect the wire, the reap gate, or the depth cap, and is left for whenever it becomes a real concern.

§22.3's earlier remark that separate-region layout gives "physical fault containment" that the single-cell-pool layout gave up is unaffected by this — contains is a logical reference within one VM's own Stadium, the same trust boundary that layout decision already accepted.


9. The admission test

Before writing code, run this on paper against every candidate entry type. Two questions, both of which must have a non-forced answer:

  1. What does heat mean for this thing?
  2. What is its reap event?

COMPLETE. Run against every candidate; all five patron kinds pass, and the two ? marks are closed:

Type Heat means Governed by Reap is Verdict
Block accessed often heat decay migration back to Artemis passes
Message delivery urgency TTL delivery passes
VM runs often heat decay death by cooling passes (§20)
Word executed often heat decay cooling off the floor passes (§17.3)
ACL checked often TTL expiry (§17.1) passes
Screen cell not a patron (§17.5)

Screen cells were the suspect case and the suspicion was correct. A cell never expires — it is a fixed grid position always present, so cells-as-entries would leave most of the Stadium inert and permanently pinned. §17.5 confirms the reading anticipated here: the patron is the dirty event, not the cell. The grid stays outside, and the event turns out to be a message with a different destination rather than a sixth kind.

Words were added to the table by §17.3 — the original list omitted them because §1 treated the dictionary as the reference implementation rather than as a population of patrons.

Ten minutes on paper. It confirmed the design and caught one case, which is what it was for.


10. Sequencing

FABRIC.md first, then Hermes native on the fabric, then measure, then Console, then Artemis last.

Amended by §16.5 and §21.2. This ordering is still right for the subsystems, but it is not the first work. A substrate floor sits beneath all of it: Hera alone, real timer interrupts and a real IRQ return path on all three ISAs (§16.1), and compudynamics driven from that tick. None of the sequencing below can begin until that exists, because the engine has nothing to run on. §25 carries the actual order.

Reasoning:

  • Hermes is unfinished, which is lucky. Finishing it the old way and refactoring later means deliberately writing code already slated for deletion. Build it on the fabric directly and it carries zero migration debt.
  • It becomes the proving ground — the fabric gets tested against a real subsystem before anything that currently works is touched.
  • It produces the effort number empirically. What Hermes costs is the multiplier for everything else. One data point from real work beats any amount of estimating.
  • Artemis reads, writes, and persists reliably today. That is banked. It goes last, because it is the thing you cannot afford to break.

Existing instrument: the POST suite exercises every dictionary word and was already earmarked as the regression gate for the shrink-to-colon-definitions pass. Same tool, second job.

Caution: a green POST suite does not mean K still holds. Those are different claims. The DoE campaign validated K on the current substrate; changing the substrate means re-running it. Automated, but budget for it.


11. Where the debt accrues

  • Dual paths — avoidable, and the big one. Never two live heat mechanisms at once. Convert one subsystem completely, prove it, move on. Every shim bridging old and new is debt, and new code will get written against whichever is convenient.
  • Speculative generality — avoidable. Only add a wire when a second entry type needs it. Generality that never pays back is still debt.
  • The exception — not avoidable, so decide it early. If one subsystem does not fit and gets special-cased, that special case is permanent and worse than not unifying: you carry the general machinery and the exception, and every future reader learns both. This is why the admission test comes before code.

Early signal: ARTEMIS.md, HERMES.md, CONSOLE.md and TRIPOD.md each currently describe their own heat mechanics. After FABRIC.md, each should shrink to roughly three lines — what an entry is here, what heat means, what the reap event is. If any one of them gets longer, that subsystem is fighting the fabric, and you will know which one before writing code.


12. Open questions — five closed, one partial

Status as of §24. Five of the six are answered or dissolved; Q5 is partial. The original text is kept below because several later sections quote it.

Question Outcome Where
Q1 Payload threshold dissolved — large patrons are simply heavy §23.1
Q2 Entry header size sized — 64-byte cell, ~32-byte header (constants to validate) §23.3
Q3 Screen cell or dirty event event; the grid is not a patron §17.5
Q4 Per-VM share hard or elastic elastic, via quota over one pool §22
Q5 Loop coupling / timescales partly — capacity must move slower than heat §22.4
Q6 One region or nested per VM nested, two levels §21

Q5 is marked partly deliberately: §22.4 fixes the one ordering that matters (capacity slower than heat) but the full eight-loop interference analysis has not been done, and §16.1 notes it cannot be until a real time base exists on all three ISAs.


  1. Payload threshold — what size goes inline versus by reference.
  2. Arena entry header size. Cardinality spans orders of magnitude (dozens of VMs, thousands of messages, potentially very many screen events). The header must be sized for the worst case, and that case is the screen. Sizing this constrains everything else, so settle it early.
  3. Screen cells: entry-per-cell or entry-per-dirty-event. (Leaning: event.)
  4. Per-VM share — hard bound or elastic under pressure.
  5. Loop coupling. Roughly eight feedback loops once Hera and heartbeat depth are counted. The algorithms are known; the risk is interference. Usual discipline is separation of timescales — keep nested loop periods an order of magnitude apart. Cheaper to decide than to debug.
  6. Whether the arena is one region for the whole system or nested per VM. Nested implies K conserved at each level with messages as the only thing crossing a boundary, which would mean no shared-memory atomicity is ever needed. Single region is simpler but reintroduces locking — the one mechanism this architecture has otherwise never wanted.

13. What this does to formal verification

This may be the largest payoff, and it was not the reason for the change.

Verifying four subsystems means four state models, four conservation arguments, and — the expensive part — proofs about how they interact. That last category grows combinatorially and is where a verification effort usually dies. Unification deletes it outright.

What the design gives Isabelle/HOL, more or less for free:

  • One datatype. The Stadium entry is a single record. Everything else is payload. You reason about entry once rather than about blocks, messages, VMs and events separately. (Amended by §3: a cell is a two-constructor sum — patron header or continuation cell — not a bare record. That costs one case split and nothing else; the point stands.)
  • No pointers. Fixed-size cells with index links means the Stadium models as a total function over a finite index set — no heap model, no separation logic, no aliasing, no null. This is the single biggest difference between a tractable proof effort and a research project.
  • Finite state. Bounded capacity means the state space is finite. Induction over the Stadium is straightforward, and model checking becomes available alongside theorem proving.
  • One conservation theorem. Every engine operation preserves K. Proved once against the engine, it holds for every entry kind — because the engine cannot distinguish them. Previously this was four proofs plus their interactions.
  • A clean model boundary. Storage below and devices beside the Stadium means disk I/O and framebuffer writes sit outside the model, at the C primitive boundary already drawn.
  • A trivial initial state. Boot order — kernel, then Stadium, then engine, then Hera — gives a base case that is trivially conserving, with everything else following by induction on operations.

One constraint this imposes, and it is not optional.

The code field is late-bound behaviour, which is the one part of this that HOL does not like: an arbitrary function stored in a record is higher-order and can wreck termination arguments. The fix is a design rule rather than a proof technique:

The set of code-field behaviours must be a closed enumeration, fixed at build time.

Model it as a datatype of behaviour tags plus a dispatch function and the whole thing stays first-order and tractable. Leave the code field open as a general extension point and you have traded four easy verification problems for one genuinely hard one.

This is consistent with the existing rule that adding a primitive requires rebuilding from source rather than doing it from inside a running system. Worth stating explicitly in the fabric design, because it is the kind of constraint that gets casually violated later by someone adding "just one" dynamic behaviour.


14. Formalism

The thermodynamic analogy holds in places and inverts in one, which matters for the paper but not for the build.

  • Fixed capacity → closed system. K≡1.0 → conservation. Capacity transfer → work. These map cleanly.
  • Heat is not entropy. Heat is closer to energy or temperature. Entropy would measure how heat is distributed: concentrated is low, uniform is high.
  • This matters practically. K is conserved, so K can never tell you anything — it is 1.0 by construction, a correctness check rather than a diagnostic. Entropy over the heat distribution actually varies, and distinguishes idle from productive from thrashing. That is the real instrument, and the quantity worth driving the LED matrix with.
  • The inversion: the second law says entropy rises spontaneously. This system does the opposite — it self-organizes, concentrating heat where work happens. That is not equilibrium thermodynamics; it is a driven dissipative system, order sustained by throughput. Prigogine, not Carnot. A stronger claim, but only if stated correctly — writing "thermodynamic system" while entropy decreases unprompted is an easy shot for a reviewer.

Phenomenon first, then mathematics. The formalism follows the phenomenon; it does not gate the build, and it is not finished until it is correct.


15. The whole thing in five lines

  • The Stadium holds the live crowd. Storage is the warehouse. Devices are the utility.
  • One entry shape. The code field is the only difference between kinds.
  • Traffic confers heat. Heat is conserved at 1.0. Density is heat per cell. Ranking reads density.
  • Departure is TTL, or cooling, or never. Pinning is invariance, not longevity.
  • The kernel opens the hall. Hera walks in first, and cannot be asked to leave.

(Amended from the original five by §17.5, §19.5, §17.1 and §20.5 #3. The earlier third line — "heat is density, conferred by traffic" — conflated two quantities; the earlier fourth — "departure is unconditional" — knew only one mechanism.)


16. Substrate findings — 2026-08-03

Naming: the arena is now called the Stadium, because src/starkernel/vm/arena.c already owns "arena" for the PMM-backed VM page allocator — an unrelated concept. The document has since been swept to the new name, and §115's substance reconciled against §1624 with each superseded claim marked in place.

Four findings from reading the tree. The first three change what step one costs. The fourth changes what the engine is allowed to be.

16.1 There is no interrupt return path on two of three ISAs

The engine has to be driven from outside the VMs (§6), which in a kernel means interrupts. That mechanism does not currently exist on most of our targets.

  • apic_timer_start() is an explicit no-op stub on aarch64 (arch/aarch64/apic.c:82) and riscv64 (arch/riscv64/apic.c:76). Both say the driver is deferred.
  • heartbeat_tick() is defined on all three architectures and called from exactly one site in the tree: arch/amd64/interrupts.c:337. On the other two it is dead code.
  • Worse: every vector in arch/aarch64/isr.S — IRQ included — is a bare branch to a handler that prints and enters for(;;) wfe. arch/riscv64/isr.S is the same shape. There is no register save, no ERET, no SRET.

So enabling a timer interrupt today halts the kernel on the first tick. The work is not "write a timer driver," it is "build the interrupt return path that was never built."

Consequence for §12 Q5. That question assumes a hierarchy of loop periods kept an order of magnitude apart. Separation of timescales presupposes a time base. There is one real time source, on one architecture; everything else paces off execution count. Q5 cannot be answered on the current substrate — it is downstream of this work, not parallel to it.

16.2 riscv64's time base is a guess

arch/riscv64/timer.c:46 sets s_counter_hz = 1000000000ULL with the comment /* assume 1 GHz */. The file header concedes rdcycle's frequency is not architecturally discoverable.

Every heartbeat variance and TIME-TRUST figure riscv64 has produced was computed against a wrong expected_delta. This has to be fixed as part of any timer work, and it means riscv64 timing numbers before and after that fix are not comparable.

16.3 The dictionary is already a Stadium

§1 claims the dictionary is the reference implementation. It is stronger than that — but not as strong as an earlier draft of this subsection claimed. Read against DictEntry (include/vm.h:335-351):

§3 wire In DictEntry Form
identity word_id + name[] correct
heat execution_heat + physics correct
TTL acl_ttl correct
pin acl_pinned, plus WORD_PINNED / WORD_FROZEN correct
link struct DictEntry *link a pointer, not an index
code field word_func_t func a raw function pointer, not an enumerated tag
mass absent
payload — (definition body lives outside the entry) absent

Four wires present in correct form, two present in the wrong form, two absent. An earlier draft said "six of eight" and named the missing two as mass and a behaviour tag, which double-counted the code field and omitted payload.

The wrong-form pair is the interesting part. link being a pointer is precisely what §13 identifies as "the single biggest difference between a tractable proof effort and a research project," and the raw function pointer is what §18.3 requires to become a closed tag.

So the honest claim is weaker than "the dictionary is a Stadium entry" and still strong enough to carry §1: the dictionary already has the concepts, and two of the eight need to change form. Everything else is what gets generalised toward it.

But run §9's admission test on it before moving it in. Its reap event is the weak wire. Blocks migrate, messages deliver, VMs die by cooling — a dictionary word does not expire. Heat decays to a floor and the word stays; FORGET is manual and rare. That is the same shape §9 already flags as suspect for screen cells: hundreds of permanently resident, largely inert entries. It may well be fine, but the dictionary is too central to wave through, and it is precisely the case §9 exists to catch.

Also: the dictionary is what parity.c hashes. Moving its representation into the Stadium changes that hash, so every committed baseline in logs/ shifts. Not a blocker — but a deliberate re-baseline with a before/after record, not something to discover later.

16.4 The engine must stay deterministic — this is a new constraint

Nothing in §115 says this, and it binds the engine tightly.

parity.c logs a dictionary hash per VM birth. The DoE's 0.000% CV across 90 runs and the patent support material both rest on the same capsule producing the same heat state on every run. Today that holds for a reason worth naming: ticking is execution-driven. vm_tick() (vm/vm_runtime.c:114) is called from execution paths, and its own header says "Synchronous (now): Called from main execution loop, every N executions." Same instruction sequence, same tick points, same decay events, same hash.

Wall-clock ticking does not have that property. Under TCG, elapsed time varies run to run on identical input.

The interrupt may supply pacing, but the engine must fire on tick count, never on elapsed wall time.

Same input → same tick ordinal → same reap and inference events → same hash. This keeps parity intact while still letting compudynamics be genuinely timer-driven.

There is a second, narrower version of the same discipline. heartbeat_tick() measures inter-tick deltas to derive variance and TIME-TRUST. If the engine's own work ran inside that handler, the handler's runtime would become part of the interval it measures — the instrument would be reporting the cost of running the instrument. So the interrupt does bookkeeping only; the engine runs outside it. The split already exists in the tree and works: adaptive_check_accumulator / adaptive_pending (include/vm.h:113-114), set at rolling_window_of_truth.c:372-375, serviced at :1302-1308.

DECIDED unless argued — it is a constraint inherited from what the system already claims, not a new preference.

Corrected by the GAP-A1 ruling — the tick is virtual

The rule above ("fire on tick count, never on elapsed wall time") was necessary but not sufficient, and its inference — same tick ordinal → same hash — was unsound. The hash covers execution_heat, which is co-written by two streams: word executions and engine ticks. A hardware timer makes the interleaving of those streams wall-clock-dependent under TCG, so same-per-tick actions do not compose into the same hash. See §25.7.1 GAP-A1 for the full argument.

RULED 2026-08-03:

The engine's tick is a virtual tick — a pure, deterministic function of the execution stream. This is what exists today (vm_tick() paced every N executions) and it is why parity holds today. The hardware heartbeat is the TIME-TRUST instrument, the idle wake source, and the driver of nothing that feeds patron state. When the system is idle, the REPL poll loop pumps virtual ticks so TTLs still expire in real time — a context in which parity was never claimed.

Phase 0's timer bring-up remains fully justified: it makes the instrument real on three ISAs instead of one, and it is the substrate SMP will eventually need. What it does not do is drive the engine.

Whatever step one turns out to be, it now has a floor under it: real timer interrupts and a real IRQ return path on all three ISAs. §10's sequencing (Hermes first, as the proving ground) sits above that floor, not below it.


17. Patrons

An occupant of the Stadium is a patron. Blocks, words, ACLs, messages and VMs are all patrons. The word is doing real work: it names the category without implying a class hierarchy, and it keeps the metaphor honest — patrons attend, they are not the building.

VMs were omitted when this section was written and added by §20, which found they were already implemented as the outer level. Five kinds, not four — the counts elsewhere in §17 predate that and should be read accordingly.

DECIDED.

17.1 Patrons die several different ways — and that is not a type field

The observation that prompted this section is correct: these things do not all end the same way. A message is consumed. An ACL lapses. A block should never be destroyed. A word should never be destroyed either.

The reflex is a decision branch on patron kind. That is the type field §3 forbids, and it is not needed — but neither is the opposite over-simplification, which an earlier draft of this section made and which is corrected here.

Heat and TTL are not the same mechanism, and neither is a special case of the other. §3 lists them as separate wires and they must stay separate. A message carries a countdown. A block does not — a block leaves the floor because it cooled, not because a timer expired. Collapsing the two forces the design, which is precisely the failure §11 warns about.

There are three mechanisms, and each patron uses the ones that genuinely apply:

Mechanism Nature Patrons Departure
TTL countdown to a definite event messages, ACLs expiry
Heat decay continuous, gradual blocks, words cooling off the floor
Pin invariance — §3's wire any never

Mapped per patron:

Patron Governed by Reap event
Message TTL delivery — consumed, gone
ACL TTL expiry
Block heat decay migration back to Artemis — evicted, not destroyed
Word heat decay cooling off the floor (see §17.3)
VM heat decay death by cooling (see §20); Hera is pinned (§20.5 #3)

Two measures, one clock

This does not mean two clocks. Both mechanisms advance off the same tick — the virtual tick of §16.4 as ruled, a deterministic function of the execution stream, not the hardware heartbeat. TTL decrements on a tick; heat decays on a tick. They are two different readings of one counter, not two independent time sources.

That is not a tidiness preference, it is forced — twice over. §16.4 requires the engine to fire deterministically so the same input reproduces the same dictionary hash. And the mechanisms cannot be split across clocks: TTL expiry has side effects on the instruction stream (a message expiring versus delivered changes what runs next), so a wall-clock TTL would corrupt heat downstream even if heat itself stayed execution-paced. One virtual clock for everything that touches patron state; the hardware heartbeat observes and wakes, never drives.

One tick. Two measures. Three mechanisms.

The engine still asks nothing about patron kind. It advances the tick, applies whichever measures a patron carries, and calls the code field when a patron departs. A pinned patron never departs. There is no type interrogation — see §18 for how the dispatch works without one.

17.2 Reaping is not destruction

The block case is the one that makes this work, and §9 already had it right: a block's reap event is migration. §5 puts storage beneath the Stadium, with blocks coming onto the floor when hot and going back off when cold.

So a block is reaped in exactly the sense the engine means — it leaves the floor. Where it goes afterwards is the code field's business, not the engine's. A message's code field ends in delivery; a block's ends in a write-back to Artemis. Same event, different behaviour, no special case.

This is worth stating plainly because "reap" reads as "free" and here it does not:

Reap means leaves the floor. It does not mean destroyed.

DECIDED.

17.3 Words: the dictionary is the warehouse, hot words are the patrons

§16.3 left words as the unresolved patron. Pinning all of them resolves nothing — several hundred permanently resident, largely inert entries is the §9 screen-cell failure with a different label, and it wastes the bounded capacity that density needs as its volume (§19.2; this sentence originally cited the K-denominator justification that §2's correction removed — D1).

The better reading applies §5 unchanged. Storage sits beneath the Stadium. The full dictionary sits beneath it too, and only hot words are on the floor.

This is not speculative — it already exists and is already measured:

  • src/physics_hotwords_cache.c maintains the hot-word set
  • cache_hits_delta is column 4 of the DoE CSV, "hot-words cache hits this tick"
  • execution heat (Loop #1) is what promotes a word; linear decay (Loop #3) is what cools it

So the hot-word population is already a live, moving crowd with an existing promotion rule and an existing cooling rule. It is the crowd. The dictionary is the warehouse it is drawn from, exactly as Artemis is the warehouse blocks are drawn from.

The existing cache is only half-aligned — and that is the argument for doing this

Reading physics_hotwords_cache.c closely turns up something that strengthens the case rather than weakening it. Heat governs admission to the cache. Nothing governs departure.

hotwords_cache_promote() (:362-383), when full, writes the new word to cache[lru_index] and advances that index modulo the size. That is round-robin. The field is named lru_index, the inline comment at :365 says "LRU eviction: remove oldest entry (round-robin)", and the doc block at :347 says "round-robin least-recently-used" — which is a contradiction in terms. Nothing anywhere tracks recency of use. Promotion is gated on execution_heat > HOTWORDS_EXECUTION_HEAT_THRESHOLD (:283); eviction consults heat not at all.

The consequence is that the hottest word in the cache can be evicted purely because its slot came up in the rotation.

That is a direct contradiction of §4:

Ranking is read, not decided. There is no scheduler because there is no policy. The Stadium is simply already in heat order when you look at it.

Round-robin eviction is exactly a policy — an arbitrary one, uninformed by the physics the rest of the system runs on.

This is the strongest practical argument for §17.3. Moving words onto the Stadium is not a relabeling exercise; it repairs a real defect by deleting the arbitrary half of an existing mechanism. And it is measurable before and after: stats.evictions, stats.promotions and stats.cache_hits are already instrumented and already flow into the DoE CSV.

Consequences if this holds:

  • Words need no pin exception. Their reap event is cooling off the floor — the same shape as a block's, one level up.
  • §16.3's objection dissolves. The dictionary does not move into the Stadium wholesale; it stays beneath it and pages against it.
  • The parity concern in §16.3 narrows considerably. The dictionary's own representation is not what changes — what becomes a patron is the hot set, which is already transient.
  • Pin stops being a general-purpose escape hatch and goes back to meaning what §3 says: invariance, for the few things that genuinely must not vary.

LEANING. DECIDED 2026-08-04, on paper, before item 4.1's code — settled after starting that item surfaced this section as its unresolved prerequisite.

The core claim stands, for the reason already given above. The existing cache is self-contradictory as built: heat gates admission, nothing governs departure, and the consequence is that the hottest word in the cache can be evicted purely because its slot came up in the round-robin rotation. Moving words onto the Stadium repairs a real defect, not a relabeling exercise, and stats.evictions/stats.promotions/stats.cache_hits make it directly measurable before and after, exactly as this section already argued.

What was actually missing was the hosted/kernel split — §25.5's own header warns "never two live heat mechanisms at once" (§11), but nothing said which way that resolves, and the two subsystems involved are not symmetric:

  • src/physics_hotwords_cache.c and src/dictionary_management.c are vendored, shared source. dictionary_management.c calls hotwords_cache_lookup() / hotwords_cache_evict_*() directly in the word-lookup path, not gated by #ifdef ENABLE_HOTWORDS_CACHE at the call sites — the toggle only controls the cache's own internal behaviour, not whether these call sites exist. This file must keep compiling and working correctly in both the hosted and kernel builds (CLAUDE.md is explicit).
  • The Stadium (stadium.h/stadium.c) is, and by construction of everything built through item 3.7 can only be, kernel-only — every declaration in it is #ifdef __STARKERNEL__. Nothing in this document has ever proposed a hosted Stadium, and building one is not something item 4.1 needs to do.
  • ENABLE_HOTWORDS_CACHE already defaults to off in both the hosted Makefile and Makefile.starkernel today (Kconfig.physics, confirmed against both Makefiles directly — a stale comment beside the Makefile default claims the opposite, but the actual default is n/0 in both). So "two live mechanisms" is not a live conflict in the default build today; it only becomes one once item 4.1's kernel-side migration and the old cache are both actually exercised at once.

Resolution: kernel and hosted diverge, and that is the correct shape, not a compromise.

  • Kernel builds: once item 4.1 lands, the old cache's effect is retired under __STARKERNEL__ — word patrons migrate onto the Stadium, and hotwords_cache_lookup()/hotwords_cache_evict_*()'s call sites in dictionary_management.c are bypassed for the kernel build regardless of the ENABLE_HOTWORDS_CACHE setting. The shared source can stay compiled as-is (untouched, for hosted's sake) while being functionally inert on the kernel side. Item 4.1 decides the exact mechanism (a build-time gate, a runtime check, or something else) — not invented here.
  • Hosted builds: unchanged. No Stadium exists there, none is being built for it, and the existing mechanism — including its current off-by-default setting — stays exactly as it is. This is what keeps CLAUDE.md's dual-target compileability requirement satisfied without inventing a second Stadium implementation nobody asked for.

Consequences from above still hold: words need no pin exception (reap event is cooling off the floor), §16.3's dictionary-parity concern narrows to the hot set alone, and pin goes back to meaning invariance rather than a general-purpose escape hatch.

17.4 Open

  1. What is a word's TTL, concretely? DISSOLVED — a word has no TTL. The premise was wrong. §17.1 (as corrected) establishes TTL and heat decay as two distinct mechanisms, not one clock read two ways: words are governed by heat decay, and only messages and ACLs carry a TTL. Nothing needed unifying and no second mechanism was required.

  2. Is the hot-word set bounded today? RESOLVED — yes, hard bounded. DictEntry *cache[HOTWORDS_CACHE_SIZE] (include/physics_hotwords_cache.h:168) is a fixed array inside the struct, with HOTWORDS_CACHE_SIZE = 32 (:84). Nothing is allocated — hotwords_cache_cleanup() notes there is nothing to free, since the cache holds borrowed pointers the dictionary owns. It is per-VM (vm->hotwords_cache, used at dictionary_management.c:320), not global. This is exactly the inescapable outer wall §2 requires.

    Two things follow. First, the bound is 32 out of a 453-word Mama dictionary — a very tight floor. Whether that is the right Stadium population or an artifact of the structure having been sized as a lookup cache rather than as a live set is a design input, not a given. Second, the eviction defect in §17.3 above.

    Reported, not fixed: in hotwords_cache_promote(), if word is NULL and the cache is full, the guard at :363 falls into the inner branch at :364 and writes NULL into cache[lru_index]. Unreachable today — every caller passes a non-NULL entry from the bucket search — but the NULL check reads as though it prevents this, and does not.

  3. ACL reap RESOLVED — TTL expiry. ACL entries already carry acl_ttl in DictEntry, so they fall under the TTL mechanism in §17.1 alongside messages. §9's ? is closed.

  4. Does a patron ever change what it is? RESOLVED in §24 — and the question was slightly wrong. Full immutability is not available: FORTH blocks mutate in place by definition (BLOCK / UPDATE / FLUSH), while words already behave the opposite way, redefinition creating a new entry. The kinds genuinely disagree.

    What actually mattered was never payload but mass and identity. §24.2 states the invariant: identity never changes during a residency; mass never changes as a side effect of use; header fields mutate freely; payload contents may mutate provided size and identity do not. That gives §13 the enumerable mass function it needed without demanding immutability nothing could deliver.

17.5 The framebuffer is not a patron — it is a utility

DECIDED. This is §5 and §2 applied rather than a new call, but it was close enough to becoming an exception that it is worth writing down explicitly.

Outside the Stadium is not the same as an exception

§11's warning is about a patron kind that needs special handling inside the engine — you end up carrying the general machinery and the carve-out, and every future reader has to learn both. That is the thing to fear, and the fear is correct.

But §5 is not a carve-out. It is a taxonomy. The test for whether something is an exception is: does the engine change because this thing exists? For the framebuffer, nothing changes. The engine never learns about it. That is a boundary, not an exception.

It fails §2's liveness test by definition, not by fiat

§2's scoping decision is the sharpest line in this document: the Stadium holds what is live, not everything that exists. A patron arrives and departs. The framebuffer does neither — it is there from init to power-off. It has no arrival event and no reap event, not because it has been excused from having them, but because it genuinely has none.

Better than "the building's lighting": a utility

§5 calls the framebuffer the building's lighting, which undersells it — that reads like part of the structure. It is closer to the power company: external infrastructure the building consumes. Not the Stadium. Not the basement of the Stadium. A third thing.

That gives three categories, all principled, none of them exceptions:

Category Relation Example
Warehouse beneath Artemis, the dictionary (§17.3)
Stadium the floor patrons
Utility beside framebuffer, and devices generally

What is live is the dirty event — and it is not a new patron kind

(Written when the taxonomy had four kinds; §20 has since added VMs as the fifth. The point stands unchanged — the dirty event adds nothing to the taxonomy at all.)

Run §9's two questions on it:

  • Heat means — a region written often is hot. A scrolling log, a blinking cursor. A static border is cold. Traffic confers heat, identically to everything else.
  • Reap is — redraw. Consumed by being painted.

Consumed on delivery, carries a TTL, dies on arrival. A dirty event is a message whose recipient happens to be the framebuffer. It does not extend the patron taxonomy; it is the message patron with a different destination.

Which yields a symmetry worth keeping:

Patron Code field terminates at Which lives
Block Artemis beneath
Dirty event framebuffer beside

Both are code fields finishing outside the Stadium. Neither is special.

This closes the last ? in §9. The screen-cell row resolves to: the event is the patron, the grid is not.

The sizing argument, independently

A framebuffer is several megabytes of fixed device memory. Making it a patron means either swamping the bounded capacity (§2) — as mass, it would dwarf every other patron and make density comparisons meaningless — or forcing a by-reference payload path to exist for exactly one pathological object, which §23.1 has since abolished for patrons entirely. Sizing a design around its single largest outlier is how the header ends up wrong for the other ten thousand entries. (This paragraph originally leaned on the K-denominator justification removed from §2 and on the pre-§23.1 payload framing; the conclusion is unchanged — D1.)

Not a patron does not mean no physics

Worth stating so it is not lost: excluding the framebuffer from the Stadium says nothing about whether compudynamic concepts apply within it. A utility can have its own internal dynamics — heat over regions, decay, adaptive refresh — without being a Stadium participant. The power company has physics too.

OPEN, deferred. What those dynamics are is a question for when the framebuffer work actually happens. It does not gate the Stadium, and it should not be designed speculatively now.

17.6 Sizing and allocation — the Stadium should be dynamic, but not heap-allocated

§3 says the Stadium stays an array with index links. That is right, but it is stated in a way that invites the wrong objection, because "array" and "fixed at compile time" are not the same thing — and it is the second one that is genuinely objectionable.

A hardcoded capacity is arbitrary: HOTWORDS_CACHE_SIZE = 32 is a number someone picked, and §17.4 shows exactly how that ages. A contiguous block of fixed-size cells, sized at boot from the memory budget and addressed by index, is dynamic in every sense that matters operationally while remaining an array in every sense §3 and §13 depend on.

Four positions, with what each costs:

What it is Cost
a Capacity fixed at compile time Arbitrary bound. What the hot-words cache does today.
b Sized at boot, contiguous, index-linked None. Retains every property below.
c Contiguous but resizable at runtime K's denominator moves; couples to §7
d Per-entry allocation, pointer links Forfeits §13

Why (b) is free

The Stadium is established before any VM exists (§6), so boot is already the moment its capacity is determined. Deriving that capacity from available memory rather than from a constant costs nothing and gives up nothing. Cells stay uniform, links stay indices, the region stays contiguous.

SUPERSEDED by §22.3 — the answer is (b) with a refinement. The Stadium is one global array of cells sized at boot, and per-VM shares are quotas held as counts rather than separate regions. That keeps (b)'s properties while making (c)'s elasticity trivial, so the two are no longer alternatives.

Why (d) is expensive — by this document's own argument

§13 is unambiguous:

No pointers. Fixed-size cells with index links means the Stadium models as a total function over a finite index set — no heap model, no separation logic, no aliasing, no null. This is the single biggest difference between a tractable proof effort and a research project.

Per-entry heap allocation gives that up and takes several things with it:

  • The finite state space. Bounded capacity is what makes induction over the Stadium straightforward and what puts model checking on the table alongside theorem proving.
  • §2's hard outer wall. The bound is what gives density a volume to be dense within (§19.2) and §13 its finite index set. (This bullet originally read "Without an inescapable bound, K is bookkeeping — §2 says this in as many words"; §2 no longer says that, and §20.2 established conservation is falsifiable regardless of the bound — D1.)
  • The engine's simplicity. This is a freestanding kernel with kmalloc.c / pmm.c and no libc. Allocation in the reap path means the engine can fail to allocate, which means the engine needs a failure mode, which means it is no longer the thing §3 describes. An engine that can fail is a different engine.

Fragmentation is the least of it, though §3 is right that indices avoid that too.

Why (c) is the genuinely open one

A contiguous region that grows and shrinks as a whole keeps index links and keeps the proof structure — the capacity becomes a parameter rather than a constant, which HOL handles without difficulty. What it complicates is K, since the denominator moves.

This is not a new question. §7 already has it open for per-VM shares: "whether a VM's share is a hard bound or an elastic one that can grow and shrink under pressure, with capacity transferring between VMs as a conserved operation Hera arbitrates." Elasticity at the Stadium level and elasticity at the per-VM level are the same question asked at two scales, and they should be answered together rather than separately.

OPEN RESOLVED — and the prediction here was right. Q6 did resolve to nested (§21), and (c) did become the attractive option (§22). But §22.3 found a cheaper route to it than resizing a contiguous region: with quotas held as counts over one shared cell pool, elasticity costs arithmetic on two integers and the denominator never moves at the level that matters. The total stays fixed; only the partition shifts.

The rule this reduces to

Dynamic in capacity. Static in structure.

Decide how big the Stadium is at runtime. Do not decide what an entry is, or how entries are addressed, at runtime.

17.7 Word-level heat conservation — DECIDED 2026-08-05, blocks item 4.1 until implemented

Item 4.1 (hot words onto the Stadium, §25.5) needs word patrons to carry a Stadium heat share. The obvious move — q48_from_u64(execution_heat), a pure representation change grafted onto DictEntry.execution_heat, no real conservation — was proposed and rejected 2026-08-04: Captain Bob wants real conservation for word heat, on the same footing as §19.1's fleet-level invariant, not just a unit conversion of the existing counter.

Corrected premise (2026-08-05): this was never a choice between converting execution_heat or leaving it alone. include/starkernel/vm/stadium.h:64 already declares the Stadium cell's heat field as Q48.16, conserved share of 1.0 (§19.1) — written when item 3.1 was done, before this section was reopened. Items 3.4 (density = heat ÷ mass) and 3.5 (admit if denser than the least-dense resident) already consume it as a real, relative, conserved quantity. L0 already has a genuine conservation mechanism; it has just never been fed, because nothing has been admitted to the Stadium yet. execution_heat and Stadium heat are two different fields with two different jobs. Item 4.1's task is to feed the second one from word dispatch, not to convert the first one into it. That reframing resolves four of the five questions this section left open:

  1. The promotion threshold breaks. RESOLVED — it doesn't, because nothing replaces it. HOTWORDS_EXECUTION_HEAT_THRESHOLD belongs to the old cache mechanism, and §17.3's resolution already retires that mechanism's effect under __STARKERNEL__ regardless of this question. Item 4.1 admits by item 3.5's rule instead — density relative to the least-dense resident — which is already the relative trigger this bullet said conservation would require, and it is already built.

  2. dict_hash moves. RESOLVED — it doesn't move, because execution_heat is not touched. capsule_dict_hash_hook() keeps hashing name and execution_heat exactly as today; the counter keeps incrementing and decaying exactly as today. Stadium heat is not part of dict_hash and item 4.1 does not need to add it there. No baseline discontinuity, no "before vs. after" comparison problem — there is nothing to reconcile.

  3. What transfers, from whom, on every dispatch — and its cost. RESOLVED 2026-08-05 — a reservoir, not a fan-out, keeping the transfer O(1). vm_physics_touch()'s proportional pull across every other live VM (capsule_vm_physics.c :281-355) is O(n) over the fleet and is explicitly justified there only because fleet touches are rare — the file's own comment (:47-49) contrasts "dozens to low hundreds" of fleet touches against word executions "in the millions." Copying that shape for words is not viable.

    Instead, each VM's inner Stadium gets one additional scalar — the reservoir — holding whatever heat is not currently claimed by a resident patron. All word-heat transfers are two-party, against the reservoir, mirroring Hera's structural role at the fleet level (a single fixed point that absorbs and donates) rather than the fleet's peer-to-peer fan-out:

    • Touch (dispatch of an already-resident word): pull a fixed Q48.16 quantum from the reservoir into the word's cell, clamped at what the reservoir holds. O(1).
    • Cooling (Loop #3's decay shape, redirected): return heat from the cell to the reservoir instead of letting it vanish. O(1) per word, same as today's independent decay.
    • Eviction: the cell's remaining heat must flow back to the reservoir before the cell returns to the free list (item 3.7) — otherwise conservation breaks on every reap.
    • Quantum size: a Kconfig constant, not an inferred rate. The fleet needed a statistical estimator (VMFleetWindow, vm_physics_tick()) because its touches are rare and irregular; words already have a simpler precedent — execution_heat's existing per-dispatch increment is a flat +1, not tick-scaled. Mirroring that shape avoids a second estimator. Actual tuning is DoE work (item 5.1), not decided here.

    Admission is the starter grant, by explicit choice (2026-08-05) — execution_heat plays no role. A non-resident word has no cell, so its density is 0 and it can never win item 3.5's "denser than the least-dense resident" comparison on its own. Two shapes were weighed: (A) gate admission attempts on execution_heat's existing threshold crossing — free, since the increment already happens, but makes execution_heat the promotion governor in kernel builds, directly against the "one governor per build" rule below; or (B) every dispatch of a non-resident word requests a fixed starter quantum from the reservoir and is admitted iff that quantum's density beats the current least-dense resident — execution_heat stays fully inert, matching the rule as already committed. Chosen: (B).

    Correction (2026-08-05): the cost below was stated wrong. An earlier draft of this paragraph took item 3.5's own commit note — written before item 3.7 — at face value instead of reading stadium_admit() as it stands today (stadium.c:312-381). The free list landed with item 3.7 and is the primary path: an O(1) pop, no scan, no comparison. The O(N) fallback only runs once a VM's own free list is exhausted, and even then it scans only that VM's own resident cells (stadium_owner[i] != slot), never the global array. Option B is O(1) in the common case — every admission attempt, for as long as the touched VM's Stadium floor has free capacity — and only degrades once that VM's floor is genuinely full, which is exactly when a real ranking decision (not a workaround) is the correct thing to be paying for. Better-justified than the original draft, not merely corrected.

  4. What happens to dict_hash and parity comparisons that predate this change. RESOLVED by #2 above — nothing predates a change that isn't being made to the hashed field.

  5. A new "L9" loop, or composes into an existing one. RESOLVED — composes into L0. The Stadium engine (§18) already owns a conserved heat wire per cell; item 4.1 populates that existing wire for the word patron kind. It is not a new loop and needs no name.

What sums to what, and admission semantics — settled by code already written, plus the reservoir above: per-VM Stadium, one pool per VM (words, blocks, ACLs, messages together, not a word-only sub-pool) — matching §21.4's "K conserved here, independently" and the stadium.h:64 field comment. Correction to this section's 2026-08-05 earlier wording: the invariant is not "residents sum to Q48_ONE" — the reservoir holds whatever residents haven't claimed, so the correct invariant is

Σ(resident patron heat) + reservoir == Q48_ONE

checked the same way vm_physics_conserved() checks the fleet sum, epsilon-bounded. No reset on admit/evict: the total stays invariant across any call, so admission and eviction are transfers against the reservoir, never a reset — mirroring capsule_vm_physics.c's VM-birth pattern (a new patron starts at 0, topped up by transfer) with the reservoir playing Hera's role: at VM-Stadium-quota-grant time, before any word patron is resident, the reservoir holds the VM's entire share, exactly as Hera holds the fleet's entire Q48_ONE before any other VM is born.

One governor per build — states explicitly what closes the §11/§25.5 "never two live heat mechanisms" gap:

  • Kernel builds: execution_heat stops being a decision input once item 4.1 lands — it keeps incrementing, decaying, and getting hashed exactly as today (ENTROPY@, ACL words, diagnostics, dict_hash all keep working unchanged), but it no longer governs residency. Stadium heat/density governs Stadium residency instead.
  • Hosted builds: unchanged, per §17.3's own resolution — no Stadium exists there, execution_heat keeps governing the old cache exactly as it does today.

This is the same per-build split §17.3 already ruled for the cache itself; word-level heat conservation follows it rather than inventing a second shape.

Open, deferred honestly rather than blocking:

  • The quantum size (Kconfig constant, §17.7 bullet 3 above) has no value yet — tuning is DoE work (item 5.1), not invented here, same treatment as STADIUM_MEMORY_PERCENT (item 3.2).
  • Whether rolling_window_seed_hotwords_cache()'s POST warm-start (rolling_window_of_truth.c:786) needs a Stadium-side counterpart to seed word patrons' initial heat distribution from the reservoir — noted here as the natural seeding site, not designed. Item 4.1 may ship without it; POST warm-start of the old cache is unaffected either way since it writes execution_heat, which stays untouched.
  • stadium_admit()'s O(N) scans (item 3.5's own recorded debt) are accepted cost for word admission under Option B, not re-litigated here. They resolve when the free list (item 3.7) supersedes the full-array scan — tracked at item 3.5, not a new item.

This section now authorizes item 4.1 to wire word patrons onto the Stadium using: the reservoir-based O(1) touch/cool transfer, Option B's starter-grant admission (no execution_heat involvement), and the corrected invariant above. execution_heat's current increment/decay behaviour and dict_hash remain explicitly out of scope — nothing in item 4.1 touches either.

Two rulings made during item 4.1's implementation, 2026-08-05

A pre-coding design pass surfaced two problems this section had not accounted for. Both were taken to Captain Bob before any file was touched; both are now closed.

  1. Cell-0 panic hazard. stadium_boot_init() grants Hera's quota with free_head = 0 so the first-ever admission pops cell 0 — documented as preserving item 3.6's "Hera is patron zero." But nothing had ever actually birthed Hera into the Stadium; item 4.1's first word dispatch would have made an ordinary, evictable word the accidental occupant of cell 0, arming stadium_evict()'s hard panic guard for the day something tried to reap it. Ruled: birth Hera for real, as part of item 4.1 (a deliberate scope addition, not silently folded in) — stadium_birth_hera() admits a pinned, zero-heat, mass-1 candidate into cell 0 before anything else can reach it. Zero heat means no reservoir transfer is needed for her admission; conservation holds trivially at boot.
  2. Quantum/cool-rate underspecification. The quantum this section names as Kconfig-tunable had no defensible starting value, and the cooling coefficient wasn't named as a fraction of the patron's own current heat per tick until this pass — the naive reading (reusing execution_heat's flat per-tick decay shape directly) can zero a cell in one tick, since Q48_ONE (65536) is a much smaller number than it looks at a glance. Ruled: two new Kconfig knobs, STADIUM_WORD_HEAT_QUANTUM (default 2048 = Q48_ONE ÷ HOTWORDS_CACHE_SIZE, i.e. one "cache slot's worth" of the mechanism this item retires) and STADIUM_WORD_COOL_RATE_Q48 (default 21845, reusing INITIAL_DECAY_SLOPE_Q48's numeric value but reinterpreted as a fraction-of-current-heat removed per tick, unit-safe for a conserved share — NOT the same quantity as execution_heat's decay, just a reasonable starting magnitude borrowed from it). Both flagged in their Kconfig help text as untuned placeholders, DoE work for item 5.1, same treatment as STADIUM_MEMORY_PERCENT.

Noted in passing, not fixed: Kconfig/menuconfig itself has never been exercised end-to-end in this repo — every knob added so far, including these two, has only been verified via its Makefile.starkernel default, never through an actual menuconfig.config → build round trip. Filed at §25.7.

Also found, reported not fixed (§25.7): stadium_admit() never writes stadium_owner[idx] on either the free-list-pop or the eviction-fallback path. Harmless today — every cell's owner byte is already 0 (Hera) from stadium_boot_init(), and Hera is the only VM with a quota — but once item 4.2 restores Hermes, a resident's evict-credit would flow to the wrong VM's reservoir unless this is fixed first.


18. The engine — L0

The engine that holds the patrons is a loop like the others, and it needs a name in the same scheme. L1L7 are taken by the existing feedback loops; L8 is the Jacquard mode selector. The engine sits beneath all of them, so: L0.

18.1 L0 and L8 bookend the gated loops

This produces a structure worth drawing, because it explains why two of the ten are different in kind:

  L8   Jacquard mode selector      always on, ungated
  ─────────────────────────────────────────────────────
  L1 … L7   feedback loops         gated by L8
  ─────────────────────────────────────────────────────
  L0   the Stadium engine          always on, ungated

L1L7 are gated: L8 switches them on and off, 128 configurations over seven bits.

The two bookends are ungated, and for symmetric reasons:

  • L8 cannot be gated because something has to decide the gates. A selector that could deselect itself has no defined behaviour.
  • L0 cannot be gated because it is what holds the patrons the other loops operate on. Switch it off and nothing is reaped, the Stadium fills and stays full, and K stops being conserved. That is not a mode, it is a failure state.

This is the same argument §6 makes about boot order. The thing that manages existence cannot be a participant in what it manages — not for VMs, and not for loops.

DECIDED.

18.2 The Jacquard accounting is an exclusion, not an extension

The obvious reading of "add L0" is that the selector grows a bit: 7 bits becomes 8, 128 configurations become 256.

That is the wrong move, and §18.1 is why. L0 is not gateable, so it has no bit. The gate word stays seven bits wide and the selector stays at 128 states.

This is worth stating explicitly because the alternative is expensive: widening the gate word would invalidate the 128-configuration L8 table, the DoE campaign already run against it, and the existing results. There is no reason to pay that, and the design does not ask us to.

L0 is accounted for in Jacquard by being deliberately absent from it.

DECIDED.

18.3 Dispatch: enumerate behaviours, not kinds

§13 already requires a closed enumeration:

The set of code-field behaviours must be a closed enumeration, fixed at build time… Model it as a datatype of behaviour tags plus a dispatch function and the whole thing stays first-order and tractable.

So a fixed enum with fixed dispatch is mandatory, not a concession to practicality. But there are two things one could enumerate, and only one of them preserves §3:

Enumerate Engine asks Cost of a new patron kind
patron kindsBLOCK, WORD, ACL, MESSAGE, VM "what are you?" touch the engine
behavioursMIGRATE, DELIVER, EXPIRE, COOL nothing; calls dispatch(tag) none

This was not hypothetical. VMs were added as a patron kind by §20 after this section was written, and cost the engine nothing — a VM's behaviour tag is COOL, the same tag a word carries. Under the rejected column it would have been an engine change.

Both are closed, both are fixed at build time, both are equally provable. Only the second keeps the engine ignorant of its contents, which is the property §3 exists to protect. Two patrons may share a tag; a new patron that migrates costs zero engine changes.

The branching Captain Bob is right to want is real and it is allowed — it lives in the dispatch function over a closed tag set, not in the engine asking patrons what they are.

DECIDED.

18.4 One tick

L0 advances on the virtual tick — a deterministic function of the execution stream, per the §16.4 ruling. Everything derived from time is derived from that one counter:

  • TTL decrements per tick (messages, ACLs)
  • Heat decays per tick (blocks, words)

Two measures, one clock — see §17.1. §16.4 as ruled forces this: patron state must advance deterministically for the same input to reproduce the same dictionary hash, and the hardware heartbeat cannot supply that, because its interleaving with the instruction stream is wall-clock-dependent. The heartbeat's roles are the TIME-TRUST instrument and the idle wake source; when the system idles, the REPL poll loop pumps the virtual tick.

18.5 CLOSED — the adaptive rate does not break determinism, and here is why

The concern: the heartbeat is adaptive — faster, slower, window wider, narrower. If it adapts off timing measurements, the adaptation is machine-dependent and §16.4 fails. If it adapts off execution-derived state, tick ordinals still map deterministically to work and parity survives.

Traced end to end on 2026-08-03. The dictionary-parity chain is clean. Resolution (1) — adaptation inputs are execution-derived, TIME-TRUST stays diagnostic — is already the de-facto design.

Evidence, in the order it decides the question:

  1. TIME-TRUST is computed and never consumed. heartbeat_trust() has zero callers in the entire tree. m5_time_trust and m5_variance (include/vm.h:315-316) are declared and never read or written. The only consumer of ts->trust is starkernel/doe_log.c:98, which writes it to a CSV column. It is measured and reported, never fed back.

  2. The intent is already documented. include/starkernel/timer.h:70"TIME-TRUST thresholds in Q48.16 (for diagnostics, NOT for gating)."

  3. Every inference-engine input is execution-derived. vm_runtime.c:626-640 populates InferenceInputs from: the rolling window, trajectory_length (from window_pos / total_executions), prefetch_hits / prefetch_attempts, hot_word_count, stale_word_count, total_heat, word_count, and the previous check's baselines. No timing input of any kind. The outputs it applies — adaptive_window_width and adaptive_decay_slope — therefore depend only on execution history.

  4. Decay is tick-based, and deliberately so. vm_tick_apply_background_decay() is handed vm_monotonic_ns(vm) but computes elapsed_ticks = tick_count - last_decay_tick (vm_runtime.c:375). The now_ns argument only writes last_decay_ns. The comment at :373-374 says so explicitly: "Tick-based, not wall-clock… now_ns is kept only to refresh last_decay_ns for diagnostics." Someone already defended this exact boundary.

  5. The parity hash contains nothing time-derived. capsule_dict_hash_hook() (capsule/capsule_vm_hooks.c:60-70) walks the dictionary hashing exactly two things per entry: the word name and execution_heat. Not last_decay_ns, not any timestamp. So even the diagnostic wall-clock field from (4) cannot reach the hash.

Conclusion: §16.4 holds today, and holds by construction rather than by luck.

One real exception, and it is not in the parity path

vm_physics_touch() (capsule/capsule_vm_physics.c:250-313) is wall-clock dependent: it computes elapsed_us = (now_ns - last_active_ns) / 1000 (:272) and the header comment at :122 confirms the transfer amount scales with elapsed time. So fleet-level VM heat is not reproducible run to run the way dictionary heat is.

Scope of that, precisely:

  • It touches node->physics in the VM registry, not DictEntry.execution_heat, so it does not reach the parity hash and does not invalidate the existing claim.
  • vm_physics_tick() (:366) explicitly discards its now_ns argument ((void)now_ns;), so only the touch path is affected.
  • With Hera alone this is nearly inert. It becomes live again when Hermes and Artemis return.

This is a pre-existing condition, not something the Stadium introduces. But it is exactly the pattern L0 must not inherit, and it is worth knowing that fleet K figures and dictionary parity have different reproducibility guarantees today.

The invariant this should become

Determinism currently survives on convention plus one good comment. That is too thin for something load-bearing. L0 should make it explicit:

Anything that influences patron state advances on tick count. Wall-clock time may be recorded for diagnostics and must never be an input to a decision.

DECIDED, and it supersedes the "leaning (1)" in the earlier draft of this section.


19. Mass, density, and what K actually is

§4 is marked LEANING with the note that "the density formulation needs a concrete definition." This section supplies it. It is the keystone: §4 claims ranking is read rather than decided, and that claim is empty until the thing being read is a number.

The objection that forced this section is the right one. Density is quantity per unit volume, so it implies a mass and a volume. Neither had been named.

19.1 K is already defined, and it is not an occupancy ratio

This has to come first, because the obvious definition of K contradicts working code.

vm_physics_conserved() (capsule/capsule_vm_physics.c:456-461) sums execution_heat_q48 across live VMs and tests that total against Q48_ONE:

uint64_t sum  = vm_physics_fleet_heat_sum();
uint64_t diff = (sum > Q48_ONE) ? (sum - Q48_ONE) : (Q48_ONE - sum);
return diff < VM_PHYSICS_EPSILON_Q48;

So:

K is a conserved, normalised heat share. Total heat is always 1.0. Traffic transfers heat to a patron from the others; it does not create it.

K is not occupancy, and defining it as Σmass / capacity would contradict an implemented, tested mechanism. It stays exactly as it is.

19.2 Three quantities, not one

Quantity What it is Range Status
Heat conserved share, moved by traffic Σ = 1.0 always already implemented
Mass cells the patron occupies — its footprint integer ≥ 1 new
Density heat ÷ mass — heat per cell derived new

Heat is the conserved quantity. Mass is an independent axis and never enters K. Density is the ratio, and it is density in the literal sense at last: quantity per unit volume, where the volume is a patron's own footprint inside the bounded capacity §2 requires.

A patron holding a large share of the fleet's heat in a single cell is dense. A patron squatting on four cells with a negligible share is sparse, and belongs back in the warehouse.

DECIDED.

19.3 Everything else reads off it

The point of §4 is that no policy exists. With density defined, none is needed:

  • Ranking — order by density. Read, not computed by a scheduler. §4's first bullet is now true rather than aspirational.
  • Admission when full — admit the newcomer if it is denser than the least dense resident, and evict that one. This is a comparison of two intrinsic numbers, not a policy, and it closes the "what happens when the Stadium is full" gap.
  • Hysteresis — falls out unpaid-for. A heavy patron needs a proportionally larger heat share to hold its floor space, so a block sitting near the threshold does not oscillate on and off. No damping constant to pick, which is what §4 wanted and could not previously deliver.
  • Migration cost is not a separate quantity. An earlier draft of this reasoning treated cost-to-move as its own axis. It is not needed: footprint and cost correlate, because a patron is expensive to move precisely because it is large. Deriving cost from mass avoids introducing a second tunable, which §11 would rightly call speculative generality.

19.4 Correction to §4 — the self-limiting claim has the wrong mechanism

§4's third bullet states:

Popularity is self-limiting. A crowded entry is harder to reach, which throttles traffic to it, which cools it. The governor is local and emergent — no global damping constant to pick.

The conclusion is right and the mechanism is wrong. In a hall, a crowd physically blocks access to the car. In a computer the inverse is true — a hot entry is easier to reach, since that is the entire purpose of a cache. The metaphor does not survive translation, and no mechanism in this design reproduces the blocking effect because the effect is not real in this substrate.

The real governor is conservation. Heat is zero-sum: total heat is 1.0, so a patron heating up necessarily cools every other patron, and nothing can exceed the ceiling. Popularity is self-limiting because there is a fixed amount of popularity to go around.

This is §4's second bullet — "K constrains the total, so ordering is forced by conservation rather than by tuned parameters" — which was the correct answer already. The third bullet should be struck, not repaired. Designing a mechanism to make the crowd metaphor come true would be fitting the system to the analogy, which §14 already warns against in the other direction.

DECIDED. §4's third bullet is superseded by this section.

19.5 Correction to §4 — "density generates heat" reverses the causality

§4 says "Density generates heat; nobody computes it." Under §19.2 that is backwards, and the confusion is that one word was carrying two meanings:

  • Traffic generates heat — activity concentrated on a patron transfers heat share to it. §4's causality is correct with this word substituted.
  • Density is heat per cell — derived from heat, downstream of it, and it is the quantity that gets read when ranking.

The corrected statement:

Traffic confers heat. Heat is conserved at 1.0. Density is heat per cell. Ranking reads density.

Nobody decides what matters at any step in that chain. §4's spirit is intact; only the noun was overloaded.

19.6 Open

  1. What is mass, exactly, for each patron? RESOLVED by §23.1 — the by-reference loophole is closed: if the payload is in the Stadium it counts toward mass, and what is not in the Stadium is not resident. The one residue — whether continuation cells are contiguous or linked, which shifts every large patron's mass — is §23.4 #4, scheduled as item 1.12. (D2)
  2. Is mass constant for a patron's lifetime? RESOLVED by §24.3 — mass changes only through an arbitrated transfer, never through traffic, so density is stable between transfers and §13 gets a mass function that changes at enumerable points. (D2)
  3. How does traffic transfer heat between patrons, concretely? vm_physics_touch() does this today for VMs, but it scales the transfer by wall-clock elapsed time (capsule_vm_physics.c:272), which §18.5 forbids for anything influencing patron state. The transfer rule must be restated on tick count before L0 can use it. This is the single most concrete piece of work this section implies.

20. VMs are patrons

§17 named four patrons: blocks, words, ACLs, messages. That list is incomplete, and the omission matters because the missing kind is the only one already implemented.

§9's admission table has always included VM — heat means runs often, reap is death by cooling — and §6 states it directly: "Hera becomes the first entry in it." Those cannot be reconciled with a four-patron taxonomy. VMs are patrons. Chronologically they are the first ones.

DECIDED.

20.1 This is a finding, not a proposal

The outer Stadium already exists in working code:

  • vm_physics_fleet_heat_sum() sums execution_heat_q48 across live VMs, and vm_physics_conserved() tests that total against Q48_ONE (capsule/capsule_vm_physics.c:456-461).
  • That is a Stadium's K, computed over VM patrons. §19.1's definition of K was derived from it.
  • Hera already reaps VMs; TRIPOD.md makes governing existence her defining contract.

So the mechanism §19 describes is not novel at the VM level. It is running now.

20.2 The outer level is unbounded — but fleet K is a real conservation law

This subsection previously claimed fleet K was "bookkeeping" that could not fail. That was wrong, and it was wrong on a point of fact rather than of interpretation. It is replaced here rather than annotated. The error: it asserted heat is renormalised after population changes, without reading the paths where renormalisation would have to occur.

Heat is transferred, not renormalised

Read end to end in capsule/capsule_vm_physics.c:

  • The primitive (:147-154). vm_physics_transfer() subtracts from one patron and adds the same amount to another, clamped at zero. Its own comment: "The one conservative primitive everything else is a special case of… Nothing is created or destroyed: sum(execution_heat for all LIVE VMs) is invariant across any call."
  • Birth (:156-185). Hera (vm_id 0) is seeded with Q48_ONE; every other VM starts at zero, described as "cold mass added to a closed system." Population growth rescales nothing.
  • Death (:225-247). The dying VM's entire heat is transferred to the root it chains up to before being zeroed.
  • Touch (:250-311). Pulls from other live VMs proportionally, clamped to what they actually hold so it "can never manufacture heat."

There is no renormalisation anywhere. vm_physics_conserved() tests a genuine invariant.

It is therefore falsifiable — and there are two ways it can drift

  1. A documented leak (:240-244). If a dying VM is itself the root, or its parent chain is broken, there is nowhere conservation-preserving to send the remainder and it is dropped. Both cases are guarded and described as "shouldn't happen," but the path exists.
  2. Truncation (:304-305). The proportional fan-out computes (moved_total * heat) / others_total per VM in integer arithmetic. The shares sum to less than moved_total. Every multi-VM touch loses a little heat, so the sum drifts downward monotonically. VM_PHYSICS_EPSILON_Q48 is 3277 — 5% of Q48_ONE — so given enough touches this would eventually trip.

What this means for the bound, and for the campaign

Bounding the VM population does not make conservation falsifiable — it already is. The two are unrelated, and §2 has been corrected accordingly. The bound is still needed, for finite state (§13) and because density requires a capacity to be dense within (§19.2).

It also changes the reading of the Artemis campaign's K-invariance arm. That arm was not measuring an identity. It was measuring a quantity that genuinely could drift, and which did not drift far enough to trip a 5% epsilon over the run. That is a real result about the system, not an artefact of the check.

Reported, not scheduled: the truncation leak at :304-305 is a live defect in a conservation law the project makes claims about. It is small per touch and may be entirely tolerable, but it is monotonic, and nobody has measured how far it drifts over a long run.

20.3 Nesting — §12 Q6 is less open than it looks

If VMs are patrons, the structure follows without further invention:

  Outer Stadium        patrons: VMs                  ← exists today (unbounded)
    └── per-VM Stadium patrons: words, blocks,
                                ACLs, messages       ← to be built

K conserved at each level, with messages as the only thing crossing a boundary. That is precisely §12 Q6's nested option — "K conserved at each level with messages as the only thing crossing a boundary, which would mean no shared-memory atomicity is ever needed" — and the outer level is already there.

This does not close Q6 by itself, but it changes the question. The choice is no longer between two greenfield designs; it is whether to formalise a nesting that is already half built, or to collapse it into a single region and discard the level that works.

LEANING nested. DECIDED nested in §21, written immediately after this section (D3). See §20.5 for what still had to be settled.

20.4 A VM's mass is the capacity share Hera allocated it — RESOLVED by item 1.6

RESOLVED 2026-08-04. What follows was written as a proposal; it is confirmed here rather than rewritten, because everything since has already been treating it as decided. §22's elasticity mechanism (DECIDED) only means something if a VM's mass is its variable quota — "capacity flows down the density gradient" (§22.1) is vacuous if every VM's mass were pinned at one cell. §24.3 already states outright that "a VM's mass is elastic by §22." Items 1.2 through 1.5 (the resting floor, the transfer trigger, the timescale ratio, the outer bound) all already read mass-as-quota as given. §20.5 #2's own framing settles it independently: the one-cell alternative "throws away the distinction" in the table below, which is the entire reason for doing this. A VM's mass is the capacity share — the quota — Hera allocated it, not a fixed one-cell footprint regardless of size.

Decided on paper, not yet built: VMPhysics currently holds only execution_heat_q48, last_active_ns and is_live (capsule_vm_physics.c:59-63). There is no share field yet — adding one is implementation work for a later phase, not this item.

§7 says Hera's job is Stadium distribution, and that allocating a VM's share is birthing it. If that share is the VM's mass, §19's density definition applies unchanged at the outer level, and §7 stops being abstract.

The payoff is that Hera gets a strictly better lifecycle signal than heat alone:

VM Heat Mass Density Reading
small, quiet low low moderate healthy — dense enough, merely small
big, idle low high low sparse — reap or shrink
small, busy high low high dense — a candidate to grow

Heat alone cannot distinguish starved from small. Density can. TRIPOD.md states that Hera uses the fleet K view for exactly this question — "Is a child VM healthy? Is a child VM starved?" — and density is the quantity that actually answers it.

Note this stays within TRIPOD.md's constraint that fleet K is lifecycle telemetry, not a dispatch mechanism. Density informs whether a VM should exist or change size. It never decides where work goes; that remains capability-based routing.

20.5 Open

  1. Bounding the VM population. RESOLVED by item 1.5 (§25.2), 2026-08-04. What is the outer Stadium's capacity, and what happens at the bound — birth refused, or coldest VM reaped?

    Correction first: "coldest reaped, consistent with §19.3" does not survive §20.2. §19.3's admission rule is admit if denser than the least dense resident. §20.2 already decided every VM but Hera is born at heat zero. A newborn can never be denser than an existing warm VM, so applying §19.3 literally at the outer level means births at the bound would fail regardless — just silently, via a comparison that can never succeed, instead of by an explicit refusal. Treating "coldest reaped" as automatic would also require a bespoke, non-density rule that exists for VMs alone, which is exactly the per-kind special case §11 warns against.

    Resolution: birth is refused at the bound. Making room is Hera's own deliberate act — she already reaps VMs (§20.1) — never an automatic side effect of someone else's birth request. This is the explicit, stated behaviour the original text asked for.

    The bound itself: 4, Kconfig-tunable, explicitly a placeholder. 4 matches Tripod's own currently-known topology (Hera + two Hermes instances + Artemis) — the smallest number that doesn't already contradict what this system is known to need, not a padded estimate. It is expected to be too small for real workloads. The right way to find an idealized default is empirical — a DoE campaign, the same discipline already used elsewhere in this project (experiments/bare_metal/) — not a second guess made on paper. Tracked as future work, not invented here. The constant is a Kconfig symbol (e.g. STADIUM_MAX_VM_COUNT, named at implementation time in item 3.1 alongside the other new symbols this phase introduces), not hardcoded.

    Fixed for the machine's lifetime once set at build — resolves §22.5 #4 below in the same stroke. The outer total does not itself flex at runtime; only per-VM quotas do (§22). An outer bound that could grow or shrink live would mean §2's "inescapable wall" is not actually inescapable.

  2. Is a VM's mass its allocated share, or one cell? RESOLVED by item 1.6 — the allocated share. See §20.4.

  3. What is Hera's own mass? RESOLVED — Hera is pinned, and her eviction is a panic.

    She is the first patron and she governs the rest, so she is subject to §3's pin wire: invariance, not longevity. That is the correct use of pin rather than an exception to the rules.

    But pinning alone is a silent guarantee, and a silent guarantee that fails under load is worse than none. If the engine ever selects Hera for eviction, that is a kernel panic, not a skipped iteration and not a logged warning. The condition is unreachable by construction; reaching it means the invariant is already broken and continuing would run the system without a governor.

    State it as an assertion at the eviction site, not as a filter on the candidate set — filtering hides the bug, asserting reports it.

    Her mass is still whatever §20.4 resolves for VMs generally. Pinning governs whether she can depart, not how much room she takes.

  4. Does the nesting recurse further? RESOLVED by item 1.7 (§25.2), 2026-08-04. A VM's Stadium holds patrons; if one of those patrons were itself a VM, the structure is a tree rather than two levels. Nothing currently requires this, and §11 would call it speculative generality — but it should be bounded deliberately, since the boot order in §6 does not forbid it.

    Not the same question as §8's contains chains (item 1.1) — those are same-Stadium patron-holds-patron relationships, bounded to depth 5, and do not create a second Stadium. This is specifically about a patron being a VM with its own nested Stadium underneath it.

    Resolution: bounded by a Kconfig-tunable cap, default 2 — not a hard "never." Consistent with how item 1.1 treated its own depth question rather than declaring a permanent architectural prohibition. 2 matches what §21 already decided and what already exists: the outer Stadium (patrons: VMs) and each VM's own inner Stadium (patrons: words, blocks, ACLs, messages). Nothing today drives a third level, so 2 is the honest default, not a padded estimate. The cap is enforced explicitly at VM-birth time — birthing a VM whose own Stadium would sit at a depth beyond the configured cap is refused, the same "explicit refusal over silent/emergent behaviour" discipline item 1.5 used for the outer bound — rather than left as an unstated assumption nothing checks.


21. §12 Q6 resolved — nested

Q6: Whether the arena is one region for the whole system or nested per VM. Nested implies K conserved at each level with messages as the only thing crossing a boundary, which would mean no shared-memory atomicity is ever needed. Single region is simpler but reintroduces locking — the one mechanism this architecture has otherwise never wanted.

Resolved: nested. The conclusion Q6 leaned toward is right; the reason it gives is not.

DECIDED.

21.1 The locking premise is false — locking is already free

Every mutex in the kernel build is a no-op. src/starkernel/vm/host/shim.c:415:

void sf_mutex_lock(sf_mutex_t *mutex) {
    (void)mutex;
}

dict_lock and tuning_lock (include/vm.h:410,507) are real pthread_mutex_t in the hosted build (platform_lock.h:58-63), but the kernel compiles with -DSTARFORTH_MINIMAL=1 (Makefile.starkernel:253) and the shim stubs them out. The stated rationale is accurate: "Single-threaded kernel: no contention is possible at the VM level."

So the cost Q6 weighs against the single-region option is currently zero. The architecture has not avoided locking; it has locking, inert. Q6 cannot be decided on this basis.

21.2 Step one introduces real concurrency — and locks are the wrong answer for it

This belongs in §16's substrate work, not here, but it surfaced while resolving Q6 and it lands sooner than anything the Stadium needs.

Once the timer interrupt fires on all three ISAs (§16.1), the ISR preempts the mainline. That is genuine concurrency between two contexts sharing state on a single hart. It does not exist today, which is precisely why the no-op stub is currently safe.

Making the mutexes real would not fix it and would actively break it: on a single hart, an ISR spinning on a lock the mainline holds deadlocks outright, because the mainline can never run to release it. This is a well-known failure and it is easy to introduce by reflex.

The correct answer is already in the design — §18.4's top-half / bottom-half split:

  • ISR (top half) touches only a word-sized counter and a flag. Single writer.
  • Mainline (bottom half) is the only context that mutates Stadium structure.

No lock, no deadlock, and no reliance on atomicity beyond aligned word access. This is a constraint on the L0 implementation, not a preference.

Nothing in interrupt context may mutate Stadium structure. Ever.

21.3 What actually decides Q6

With locking removed from the argument, six discriminators remain:

Nested Single region
Matches what exists hotwords_cache, rolling_window, dictionary are already per-VM; the physics registry is already outer collapses a working two-level structure into one
Fault containment a VM cannot corrupt another's Stadium one bad patron reaches everything
Capacity transfer (§7) meaningful — VMs have shares to trade no per-VM share exists to transfer
K semantics conserved per level; existing fleet K survives unchanged fleet K needs re-deriving
Verification (§13) prove the engine once, instantiate at both levels — demonstrates genericity one region, marginally simpler
If SMP ever happens messages are the only boundary-crossers → no shared memory, still no locks needs real locks, and the no-op stubs become a live correctness hole

The last row is the strongest, and it is what Q6 was reaching for. Nested does not avoid locking today — nothing needs locking today. Nested avoids locking permanently, including in a multi-hart future where the current stubs would silently stop being correct.

The first row is the most practical: §20.1 established that the outer level already exists and works. Single-region means discarding a working structure to build a simpler one, which is a poor trade at this stage.

21.4 The shape this fixes

  Outer Stadium          patrons: VMs
    │                    K conserved here
    │                    bounded — see §20.5 #1
    │
    ├── Hera's Stadium   patrons: words, blocks, ACLs, messages
    │                    K conserved here, independently
    │
    └── (future VMs)     same shape, no special cases

Messages are the only patrons that cross a boundary. Everything else is confined to the level it was born on.

21.5 Consequences and open items

  1. The no-op mutexes are now load-bearing in a way they were not before. They are correct today and correct under nesting, but only while the top/bottom discipline in §21.2 holds. That discipline should be stated in the code at the stub site, so the next reader does not "fix" the no-op into a spinlock and deadlock the kernel.
  2. Two capacities to size, not one. §20.5 #1 (outer bound) and §17.6 (per-VM bound) are now distinct questions with distinct answers.
  3. §12 Q4 / §7 / §17.6(c) elasticity becomes the live question. Nesting is what makes capacity transfer between VMs meaningful, so the hard-versus-elastic decision can no longer be deferred as an abstraction — it is the next real fork.
  4. §20.5 #4 remains open. Nesting is two levels here. Whether a patron may itself contain a Stadium — a tree rather than two tiers — is still deliberately unruled. Nothing requires it; it should be excluded on purpose rather than by omission.

22. Elasticity resolved — elastic, via quota over a single cell pool

§7, §12 Q4 and §17.6(c) are one question asked at three scales: is a VM's share of capacity a hard bound, or elastic under pressure with transfer arbitrated by Hera?

Resolved: elastic. And the layout that makes it cheap is a single global cell pool with per-VM quotas, not separate physical regions.

DECIDED.

22.1 Why elastic — §19 turns it into a feedback loop

Under §19's definition, elasticity stops being a feature to implement and becomes a negative feedback loop that runs itself:

  VM gets busy  →  heat share rises  →  density rises
                →  capacity flows toward it  →  mass rises
                →  density falls back

Capacity flows down the density gradient — from sparse VMs toward dense ones. That is diffusion. There is no threshold to choose, no damping constant, and nothing decides: it is §4's read, not decided applied one level up.

A hard bound offers none of this. It offers a number that had to be guessed correctly at birth and stays wrong.

§7's own argument is the practical half, and it holds:

Under elasticity, birth sizes the rest volume rather than a cap — a more forgiving thing to have to guess right.

Predicting a VM's resting size is far easier than predicting its peak, and being wrong self-corrects instead of persisting.

22.2 The connection to §14

Heat concentrates where work happens — §14's driven-dissipative inversion, order sustained by throughput. Capacity then follows heat. So the two distributions move in opposite directions: heat concentrates while density equalises.

That makes the flatness of the density distribution a real, measurable signal of a settled system, distinct from the heat distribution's entropy that §14 already identifies as the instrument worth having. Two signals, not one, and they say different things.

22.3 The layout decision, which matters more than hard-versus-elastic

Framing this as hard-versus-elastic obscures the real choice. Elastic is cheap or expensive entirely according to how the Stadium is laid out, and §21's nesting decision does not settle that.

Layout Elastic cost Isolation §13 verification
Separate physical regions expensive — transferring capacity means moving memory, and regions fragment against each other physical two index spaces
One cell pool, per-VM quota trivial — arithmetic on two integers logical (disjoint index sets) one index space, one total function
Separate regions, hard bounds n/a physical two index spaces

Chosen: one global array of cells, one global index space. Nesting becomes a partition of that index set rather than separate allocations. A VM's quota is a count, not a contiguous range, so there is no adjacency requirement, no fragmentation, and index links keep working because indices are global.

Free lists are per-VM, not shared

An earlier draft of this section said cells are drawn from a shared free list. That was wrong, and it quietly undercut the argument that decided §21.

§21.3's decisive discriminator is the SMP row: messages are the only boundary-crossers, so no shared memory and still no locks. A shared free list is shared mutable state, touched by every VM on every admission and every reap. Under SMP it would need a lock or atomics — exactly what that row claims nesting avoids permanently. The defence offered there, that "VMs never touch each other's cells," does not reach it: the free list is nobody's cell, and allocation touches it.

The fix costs essentially nothing:

Each VM holds its own free-list head index into the global array. Hera hands a VM its cells when she grants quota; the VM allocates and frees only within what it holds.

One head index per VM instead of one global head. One index space is preserved, one datatype is preserved, §13 is unaffected — and disjointness becomes total rather than nearly total. No mutable structure is shared between VMs at all, which is what §21.3 actually promised.

Transfer of capacity is then Hera moving cells from one VM's free list to another's, which is still arithmetic plus a list splice, and still arbitrated at a known point (§22.5 #2).

Two reasons this is the right trade:

  • §13 gets simpler rather than harder. One array, one datatype, one total function over one finite index set. A partition of a finite set is trivial in HOL. Separate regions would mean two of everything and a cross-region invariant to maintain.
  • §21's reasoning survives intact. Its argument for nesting was K conserved per level with messages as the only boundary-crossers — both preserved. SMP-safety also survives: what matters is that VMs never touch each other's cells, and disjoint index sets give that provided quota changes are arbitrated by Hera, which §7 already requires.

What is given up is physical fault containment — a corrupt index could reach another VM's patrons where separate regions would fault instead. That was one of §21.3's six discriminators and not the decisive one. It is a real cost, recorded here rather than glossed.

22.4 Capacity moves slower than heat — required, not preferred

Two conserved quantities in motion can oscillate. Heat moves on traffic; capacity moves on density. At comparable rates they chase each other and the ratio never settles.

Heat responds tick by tick. Capacity responds to sustained density across many ticks.

This is §12 Q5's separation-of-timescales discipline — "keep nested loop periods an order of magnitude apart" — arriving as a concrete instance rather than general advice, and it partly answers Q5.

The exact ratio is a tuning question, but the ordering is not: capacity must be the slower loop. Getting this backwards produces a system that thrashes while every individual rule looks correct.

RESOLVED by item 1.4 (§25.2), 2026-08-04 — 1000:1, grounded in an existing precedent, not picked from nothing. capsule_vm_physics.c:434-441's vm_physics_heartbeat_tick() already runs a fleet-level slow loop at HEARTBEAT_INFERENCE_FREQUENCY virtual ticks (default 1000, starforth_config.h:72) to recalibrate fleet_transfer_slope_q48 — the rate vm_physics_touch() uses for heat transfers. Different mechanism (heat-transfer-rate recalibration, not capacity/mass transfer), but the identical shape item 1.3's capacity-tick needs: a coarse, fleet-level reassessment layered over the fine virtual tick, in the same file, same subsystem.

The capacity-tick gets its own named constant rather than literally sharing HEARTBEAT_INFERENCE_FREQUENCY — they are conceptually separate concerns (inference-engine window/decay tuning versus capacity arbitration), and coupling them would mean retuning one silently retunes the other. But its default is 1000, matching this precedent rather than inventing an unrelated number. 1000:1 against the virtual tick is comfortably past §12 Q5's "order of magnitude apart" minimum. Named and made a Kconfig symbol at implementation time (item 3.1), same tunable-knob convention as item 1.1's containment-depth cap.

22.5 Open

  1. The resting floor. RESOLVED by item 1.2 (§25.2), 2026-08-04. A VM that goes quiet loses capacity; if it wakes it may not regain it fast enough. The obvious guard is a floor below which a quota cannot fall — but that is a tuned number, which this design otherwise avoids.

    Floor = max(mass of pinned patrons, one message-sized cell). The first term is the principled alternative this section already named: derived, not tuned. The second term closes a gap the first term leaves open on its own — a VM with zero pinned patrons would otherwise get a floor of zero, and a VM with zero quota cannot receive anything, including the message that would be the reason for it to wake up and regrow via §22.1's density-gradient feedback. That is a deadlock: no capacity to receive, no way to ever regain capacity. One message-sized cell is itself derived, from §23.3's cell sizing rule ("size the cell so a typical message is exactly one cell"), not a second tuned constant — so the discipline this section wanted to preserve still holds with both terms in place.

  2. What arbitrates a transfer, concretely? RESOLVED by item 1.3 (§25.2), 2026-08-04. §7 says Hera. Under §22.3 a transfer is arithmetic on two integers, so the mechanism is trivial — but when she does it, and on what signal, is not yet stated.

    The cadence is the slow part, not a threshold. Hera evaluates the density gradient once per capacity-tick — a coarser, derived multiple of the virtual tick (§18.4). The exact multiple is item 1.4's job, not fixed here. This is what gives "sustained density" (§22.4) its actual meaning: anything shorter-lived than one capacity-tick interval cannot trigger a transfer, without needing a magnitude threshold layered on top.

    Whether to act, once she looks, is a pure comparison — no tuned threshold. At each capacity-tick, Hera finds the single densest and single least-dense live VM. If they differ at all, a transfer is eligible. This is the same shape as §19.3's admission rule ("denser than the least dense resident") — a comparison of two intrinsic numbers, not a policy, so nothing needs inventing here.

    Both halves satisfy the tick-expressibility constraint item 1.3 states: the cadence is a tick multiple, and the decision itself reads only heat and mass, never wall time.

    What this does not resolve: how much capacity moves per eligible transfer. §22.3 only says the mechanism is "arithmetic on two integers"; neither this section nor item 1.3 pins down the amount. Reported rather than invented — it can become its own item if warranted, but is out of this item's scope.

  3. The exact timescale ratio RESOLVED by item 1.4 — 1000:1. See §22.4.

  4. Does the outer Stadium's own capacity ever change? RESOLVED by item 1.5 — no. §22 makes per-VM quotas elastic within a fixed total; the total itself is fixed for the machine's lifetime, set once at build via the Kconfig bound §20.5 #1 introduces. See §20.5 #1 for the full argument.


23. §12 Q1 dissolved, §12 Q2 sized

23.1 Q1 — the inline/by-reference threshold should not exist

Q1: Payload threshold — what size goes inline versus by reference.

§3's motivation is sound: a cell sized for a 1024-byte block would be grotesque for a patron that carries twelve bytes. But §19 supplies a better answer than a threshold.

If cells are small and uniform, a large patron occupies more of them, chained by index. That is exactly what mass already means. A block is not "by reference" — a block is heavy.

This closes the loophole recorded in §19.6 #1 without introducing a rule:

If the payload is in the Stadium, it counts toward mass. If it is not in the Stadium, the patron is not resident — it is a handle to the warehouse.

A 1 MB block cannot occupy one cell and read as dense, because its bytes are on the floor and the floor is what mass measures.

This is also what gives §19's hysteresis its teeth. Blocks should be expensive to keep resident — that is the entire reason migration back to Artemis is their reap event (§17.2). A threshold that let big patrons masquerade as light ones would have quietly disabled the mechanism.

Nothing in §3 is violated: cells stay fixed-size, links stay indices, the Stadium stays an array. Multi-cell patrons are consistent with all of it. By-reference is reserved for things genuinely outside the Stadium, and those are not patrons.

DECIDED — Q1 is dissolved rather than answered.

23.2 Q2's premise moved, and an unsettled question sits under it

Q2: Arena entry header size… The header must be sized for the worst case, and that case is the screen.

§17.5 removed the screen grid from the Stadium, so that premise no longer holds. What replaces it depends on something §17.5 established only halfway: it decided the dirty event is the patron, but not what one event covers.

Granularity 80×25 full redraw Consequence
per cell 2,000 simultaneous patrons floods the Stadium; starves every other patron
per line span / region ~25 patrons negligible

A two-order-of-magnitude swing, currently undefined.

Recommend region-based. Framebuffer updates are naturally regional — a scroll dirties everything, a print dirties one span — overlapping regions coalesce for free, and per-cell events would make the console the numerically dominant patron kind in the entire system. That is absurd for something §17.5 correctly classified as a utility rather than an occupant.

LEANING region-based. It is a console-design decision as much as a Stadium one, so it should be confirmed when the console work happens rather than fixed here.

With that settled, the worst case for cardinality becomes messages — numerous, individually small. Which yields the sizing rule:

Size the cell so that a typical message is exactly one cell.

23.3 Concrete sizing — proposal, to be validated

These are numbers to check against a real build, not derived truths.

Value Reasoning
Cell size 64 bytes one cache line; keeps density-ranking scans cache-friendly
Header — used 28 bytes identity 8, heat 8, TTL 4, link 4, mass 2, flags + behaviour tag 2
Header — reserved 4 bytes deliberate slack; see below
Header — total 32 bytes
Inline payload 32 bytes a small message fits in one cell — mass 1
Per-VM Stadium ~4096 cells = 256 KB hundreds of hot words and blocks, ACLs, messages in flight
Continuation cell undetermined see below — depends on an unsettled encoding

The four reserved bytes are deliberate rather than a rounding artefact. The fields above sum to 28; padding to 32 keeps the header a clean half-cell and gives the contains wire item 1.1 resolved to somewhere to live — a 4-byte index, the same width as link. (The header/continuation discriminator does not compete for this space: item 3.1 ruled it an external side bitmap, not a header field — see §3's amendment.) Whether 4 bytes is the final byte count item 3.1 settles on for contains, or whether it can shrink, is exactly the kind of thing item 3.1's real byte count settles, not this section — flagged here rather than assumed. Reserved space in a header that is expected to grow is cheaper than repacking one later.

256 KB per VM is comfortable against QEMU's -m 1024, and the outer Stadium's capacity (§20.5 #1) then follows from how many VMs the machine is willing to host.

The continuation cell — RESOLVED by item 1.12, linked

RESOLVED 2026-08-04. An earlier draft stated a 1024-byte block is "17 cells: 1 header + 16 payload." That figure assumed continuation cells are contiguous and carry nothing but bytes — an assumption this section settles rather than leaves open.

Linked, not contiguous — forced by what §22.3 already decided, not a fresh 50/50 choice. §22.3's per-VM free list (DECIDED) draws cells individually, with no adjacency guarantee. Guaranteeing contiguous runs for multi-cell patrons would mean changing that allocator to find runs rather than pop a free-list head, which reintroduces exactly the fragmentation §3 and §13 already ruled out by choosing fixed-size, index-linked cells in the first place. The allocator that exists says linked; contiguous would require an allocator that does not.

Consequence for sizing: each continuation cell carries a 4-byte next-index alongside its payload, leaving 60 bytes usable out of the 64-byte cell. A 1024-byte block needs 18 continuation cells at 60 usable bytes each, not 16 at a full 64 — the same figure this section's earlier draft flagged as the linked-form cost without yet choosing it. §23.3's sizing table is now complete on this row.

Firmness of each figure:

  • Heat at 8 bytes is fixed, not chosen — Q48.16 in a uint64_t, matching execution_heat_q48 in the existing implementation.
  • Link at 4 bytes caps the Stadium at ~4 billion cells, far past anything plausible. It could shrink to 3 or even 2 bytes if the header gets tight.
  • TTL at 4 bytes gives ~4 billion ticks — over a year at 100 Hz. Almost certainly oversized; 2 bytes may do.
  • Cell size 64 is the one to validate first, because everything else is expressed relative to it.

LEANING. The structure is decided; the constants are not.

23.4 Open

  1. Dirty-event granularity (§23.2) — confirm region-based when console work begins.

  2. Cell size validation. Build the header for real, count the bytes, and check that a typical message still fits in one cell with the behaviour tag and flags included.

  3. Is identity needed at all for every patron kind? RESOLVED by item 1.10 (§25.2), 2026-08-04 — no, it cannot be elided. For a word it is a name; for a block a handle (§24.4); for a message possibly nothing — its identity could be its index. If identity can be elided for some kinds, 8 bytes of a 32-byte header is a large saving. This must not become a per-kind branch (§18.3), so it is only worth doing if it can be expressed uniformly.

    It cannot be, and the reason is a genuine conflict, not just difficulty. Two ways to elide it, both blocked:

    • Elide it only for kinds that don't need it (messages) while keeping it for kinds that do (words, blocks). This is exactly the per-kind branch §18.3 forbids — the engine, or something reading the header, would have to know a message's header is shaped differently than a word's, which reintroduces the type-field problem §3 exists to prevent.
    • Elide it everywhere, uniformly. This breaks the kinds that genuinely need it: a word is resolved by name, not by Stadium position — vm_dict_resolve_in_bucket() looks up by name — and a block is resolved by LBN, not by Stadium position either. The Stadium index is not a substitute for either; they are different addressing schemes serving different lookups.

    So the saving is not reachable without violating either §18.3's uniformity requirement or a lookup mechanism a patron kind already depends on outside the Stadium. identity stays a fixed, always-present 8-byte header field for every kind, whether or not a given kind's own logic makes use of it.

    Larger than it first appeared. §3 now declares cells a closed two-valued union — header or continuation. Whatever distinguishes the two occupies header space and interacts directly with any identity elision: a scheme that reuses the identity field as the discriminator, for instance, would couple the two decisions. Settle the header/continuation encoding first; identity elision is downstream of it. (Settled by item 1.12 — see §23.4 #4 — reinforcing that identity stays untouched by that encoding.)

  4. The continuation-cell encoding. RESOLVED by item 1.12 — linked. See §23.3's "The continuation cell" subsection: forced by §22.3's already-decided disjoint free list, not a fresh choice. 4-byte next-index, 60 usable bytes per continuation cell, 18 continuation cells for a 1024-byte block. Item 3.1 is unblocked on this item.


24. Mutation, identity, and mass stability

§17.4 #4 and §19.6 #2 ask whether patrons mutate in place. The question as posed does not survive contact with the patron kinds, and the version that does is cheaper.

24.1 Full immutability is not available

FORTH-79 blocks are mutable by definition: BLOCK returns a buffer, writes go into it, UPDATE marks it dirty, FLUSH writes it back. In a block editor that is a mutation per keystroke. A rule that every write produces a new patron would mean a new patron per keystroke.

Words already behave the opposite way. Redefinition creates a new dictionary entry rather than editing the existing one — which is why vm_dict_resolve_in_bucket() resolves in reverse-insertion order, newest visible definition winning.

The kinds genuinely disagree. Forcing them to agree would be §11's exception trap approached from the other side.

24.2 The concern was never payload — it was mass and identity

§19.6 #2 asks this for density stability: if mass changes underfoot, density changes and ranking is meaningless. §13 asks it because in-place mutation is what makes proofs expensive.

Neither concern is about payload bytes. A block's contents can change entirely and it is still 1024 bytes at the same handle.

So the invariant is narrower than immutability and costs almost nothing:

Tier Rule
Identity never changes for the life of the residency
Mass never changes as a side effect of use
Header — heat, TTL, flags, link mutates freely; this is the engine's work
Payload contents may mutate in place, provided size and identity do not

DECIDED.

24.3 VMs appear to violate the mass rule, and do not

A VM's mass is elastic by §22 — that is the point of elasticity. But it changes only through Hera's arbitrated transfer, on the slow loop of §22.4. So the rule is not that mass is constant:

Mass changes only through an arbitrated transfer, never through traffic.

Traffic moves heat and nothing else. Density is therefore stable between transfers, which is what ranking requires, and §13 gets a mass function that changes at known, enumerable points rather than continuously.

24.4 A resident patron's identity is its handle, not its content hash

This follows from tier 1 and is worth stating because it is easy to get backwards.

A block's identity while resident is its handle — its LBN. Its content hash is computed at migration, for the warehouse. If identity were the content hash, editing a resident block would change its identity mid-residency and break tier 1 immediately.

§3 already permits this: identity is "handle or name". It never said hash.

Content-addressing therefore stays where it belongs — at the warehouse boundary, which is already how Artemis and the capsule model behave. The Stadium does not do content addressing; the warehouse does.

24.5 The rule this reduces to

No patron may grow or shrink while resident. If it needs to be a different size, it is a different patron.

No per-kind branching, no exception, and it holds for all five patron kinds.

24.6 Open

  1. What happens to a resident block whose content changes, at migration time? RESOLVED by item 1.8 (§25.2), 2026-08-04. Its new content hash differs from the one it arrived with. The warehouse sees a new block; the Stadium saw one continuous residency. That is coherent, and the hand-off is: the new hash is computed exactly once, at the migration boundary, as part of the block's MIGRATE code field — consistent with §24.4 (a resident block's identity is its handle/LBN, never its content hash) and §17.2 (migration is the block's departure event, not destruction). Nothing about a resident block's identity changes mid-residency regardless of how many times its content mutates; the hash is a warehouse-side fact computed only when the block actually leaves.

    Whether the old hash is retained anywhere for audit is out of scope here. §5 draws this boundary already: the warehouse is beneath the Stadium, and the Stadium does not do content addressing — the warehouse does. Audit retention is an Artemis-layer policy question, not a Stadium one, and inventing an answer for it here would cross that boundary rather than respect it.

  2. Does redefining a word while its old definition is resident leave two patrons? RESOLVED by item 1.9 (§25.2), 2026-08-04 — confirmed, yes. Not a design choice, a factual check: vm_dict_resolve_in_bucket() (dictionary_management.c:257) walks a bucket chain and returns the newest match — "the newest visible definition wins (FORTH-79 shadowing)" (:266). Nothing in the redefinition path unlinks or frees the superseded DictEntry; it stays in the bucket, merely shadowed. So if both the old and new definitions are hot, both are correctly on the floor, both have mass, both are ranked independently. This is the right behaviour, not an artefact to work around — they are genuinely two different words with two different execution histories.


25. The punch list

This section is authoritative for what is done and what is not. Sections 124 are the design. This is the work.


25.0 How to implement this punch list

Read this subsection every time before touching an item. Do not skip it because it was read earlier in the session.

The rules

  1. One item at a time. Take the lowest-numbered unchecked item whose prerequisites are met. Finish it completely. Do not begin a second item while one is in progress.

  2. Do not jump ahead. Do not start a later item because it seems easy, related, or convenient. Do not do "while I'm in here" work. If a later item looks like it should be reordered, say so and wait for an answer — do not reorder unilaterally.

  3. Do not increase scope. Do exactly what the item says. If the item says "write the trap entry," write the trap entry — not the trap entry plus a refactor of the file it lives in. Anything you notice that is not in the item gets reported, not fixed. This includes obvious bugs. Report them; they get their own item if they warrant one.

  4. Do not fabricate, confabulate, or conflate. If you do not know how something works, read it. If you cannot determine it by reading, stop and say so. Never invent a function, a register name, a constant, a FORTH word, or an API that you have not verified exists in this tree or in the relevant hardware manual. Never guess at a value and present it as known. Never merge two things that are similar into one thing that is neither. A wrong answer stated confidently has cost this project git resets before.

  5. When blocked, stop. Report exactly what is blocking, what was tried, and what is needed. Do not work around it silently. Do not substitute a different approach and carry on.

  6. Acceptance is not optional and not negotiable. Each item states Done when. An item is not done until that exact condition is met and observed. Not "should work," not "compiles cleanly" unless that is what the item says. If acceptance requires the three-architecture QEMU boot, then all three have booted and their logs exist.

  7. Report failures honestly. If a test fails, say it failed and show the output. If a step was skipped, say it was skipped and why. Never describe partial work as complete.

The commit discipline

Every checked-off item gets its own commit, and that commit contains:

  • the code or document change for that item, and
  • this file, with that item's checkbox changed from [ ] to [x].

Nothing else. One item, one commit. The punch list and the tree move together, so the document is never a claim about work that is not in the branch.

Commit message format:

<area>: <what the item did>            e.g.  riscv64: real trap entry with SRET return

Punch list §25 item <id> complete.
<one or two lines on what was actually verified, not what was intended>

Co-Authored-By: <the implementing model's attribution line, per its harness>

Standing constraints from .claude/CLAUDE.md

These override anything convenient:

  • Never create a branch without explicit permission. Work on the branch you are on.
  • Never stash. If the tree is dirty, report it and wait.
  • Never apply a fix that was not requested. Report it instead.
  • Acceptance for any kernel change is the three-architecture QEMU boot. There is no other test. The hosted make build is compile-sanity only.
  • One QEMU instance at a time, foreground, clean before qemu. Concurrent runs corrupt the timing signal.
  • Read experiments/bare_metal/README.md in full before editing any .4th file, and verify capsule edits with mkcapsule --lint rather than counting bytes by hand.

When an item is genuinely wrong

The design is not sacred. If implementing an item shows the design is wrong, stop, report what the code demonstrated, and propose the amendment. Amend the relevant section of this document first, get agreement, then continue. Do not implement something you believe is wrong because it is written down, and do not silently implement something different.


25.1 Phase 0 — Substrate

Nothing in later phases can start until Phase 0 is complete. The engine has nothing to run on until there is a tick on all three architectures (§16.1, §16.5).

  • 0.1 — Prune capsules/init.4th to Hera alone. Delete blocks 2051, 2052, 2053, 2054, 2055, 2056, 2058, 2059 — the readiness handshake, broadcast test, TRIPOD-TEST, HERMES-E2E, and fleet-DoE scaffolding. Edit the three surviving blocks: 2057 (BOOT-BANNER — drop the Tripod lines), 2049 (remove the Artemis and Hermes births with their CD-INIT calls and the common:msg.4th / process.4th loads; keep lib.4th; adjust VM-TREE / VM-CHILDREN), and 2050 (keep the BOOT-BANNER call; remove the READINESS-HANDSHAKE and BROADCAST-TEST calls). Leave capsules/hermes/ and capsules/artemis/ untouched on disk. An earlier draft of this item said "remove blocks 20502059," which contradicted its own Refs line — 2050 survives, edited (C1). Done when: all three architectures boot to the prompt with Hera alone, no Hermes or Artemis in the banner, and the three logs exist under logs/. Refs: the surviving blocks are 2057, 2049, 2050. mkcapsule --lint before building.

  • 0.2 — riscv64: real trap entry. Replace the one-way riscv64_trap_entry in arch/riscv64/isr.S with save / dispatch / restore / sret. Route scause bit 63 + cause 5 to the timer path; everything else keeps falling through to the existing fatal handler. FP state is not optional (B2 verified): the kernel builds -march=rv64gc -mabi=lp64d (Makefile.starkernel:162) — hard-float ABI, and kernel code genuinely uses doubles (hotwords_stats_print). The trap entry must save the ABI's caller-saved FP registers plus fcsr alongside the integer set; verify the exact register list against the RISC-V psABI, not this document. Do not "fix" this by switching to soft-float — that breaks existing code and is a build-system decision nobody has made. Done when: riscv64 boots to the prompt with no regression, and exceptions still halt with the same diagnostic as before. No trap source exists yet at this item — the timer arms in 0.3, whose tick-advance acceptance is what proves this entry path took and returned an interrupt (C2). Do not arm the timer early to manufacture evidence here.

  • 0.3 — riscv64: SBI timer and real time base. First, the prerequisite this item silently assumed (B1 verified it absent): the kernel has no DTB access — BootInfo (uefi.h:624-639) carries no FDT pointer and no FDT code exists in the tree. Capture the DTB pointer from the EFI configuration table (DTB table GUID) into a new BootInfo field in the shared loader. This also serves 0.6. Then: arm the timer via the SBI TIME extension, enable sie.STIE, and re-arm inside the handler on every tick — the SBI timer is one-shot by nature, and a missed re-arm stops the heartbeat forever with no error. That is the single most likely silent failure of this item (C3). Switch the time base from rdcycle to the time CSR and take its frequency from the device tree timebase-frequency, with a named fallback constant — not a bare magic number (§16.2). Done when: heartbeat_ticks() advances on riscv64 and the tick interval matches the configured rate within measurement noise. Verify the SBI extension is present before relying on it; if it is absent, stop and report rather than falling back silently.

  • 0.4 — aarch64: determine the exception level at runtime. Read CurrentEL once, early, and let it govern everything EL-dependent, not just the timer (B3): the vector base register (VBAR_EL1 vs VBAR_EL2 — today's isr.S writes VBAR_EL1 unconditionally, which is never consulted for exceptions taken at EL2), the saved-state pair (ELR_ELx/SPSR_ELx), and the timer register set (CNTP_*_EL0 vs CNTHP_*_EL2). Do not hardcode either level anywhere. Done when: the boot log states which EL was detected, on real QEMU output.

  • 0.5 — aarch64: IRQ vector split. Split irq_spx out of the shared fatal handler in arch/aarch64/isr.S: save x0x30 plus the saved-state registers (see B3 note below), call a C handler, restore, eret. The other fifteen vectors are unchanged. Note the 128-byte slot limit — the save sequence will not fit inline and must branch to a trampoline. FP state is not optional (B2 verified): the kernel builds without -mgeneral-regs-only (Makefile.starkernel:146), so the compiler may use SIMD registers anywhere. Save the ABI's caller-saved SIMD set plus FPSR/FPCR alongside the integer set; verify the exact list against the AAPCS64, not this document. EL governs the whole path (B3): this item previously hardcoded ELR_EL1/SPSR_EL1, while 0.4 refuses to hardcode the EL — and today's isr.S installs VBAR_EL1, which is never consulted for exceptions taken at EL2. The EL detected in 0.4 must select the vector base register (VBAR_ELx), the saved-state pair (ELR_ELx/SPSR_ELx), and the eret target state, not just the timer registers. Done when: aarch64 boots to the prompt with no regression. No IRQ source exists yet at this item — the GIC lands in 0.6 and the timer arms in 0.7, whose tick-advance acceptance is what proves this path took and returned an IRQ (C2). Do not pull 0.6/0.7 work forward to manufacture evidence here.

  • 0.6 — aarch64: minimal GICv2. Enable the distributor and CPU interface, set the priority mask, enable the timer PPI, acknowledge via IAR / EOIR. Read the base addresses and the PPI INTID from the device tree — do not take them from memory or from this document. The DTB pointer comes from the BootInfo field added in 0.3 (B1 verified no such field existed). If the DTB turns out to be unreachable on aarch64 EDK2, stop and report — deciding between loader work and named QEMU-virt constants with a recorded caveat is Captain Bob's call, not the implementer's.

    DTB confirmed unreachable (checked live, 2026-08-03) — same finding as riscv64. fdt_valid(boot_info->dtb) fails on this system's aarch64 build too (installed firmware: qemu-efi-aarch64 2025.11-3ubuntu7, no alternate available). Ruling: named QEMU-virt constants, verified rather than recalled — qemu-system-aarch64 -machine virt,dumpdtb=... was used to dump QEMU's own internal devicetree (the one EDK2 fails to forward) and decoded with this tree's own fdt.c reader, giving GICD 0x08000000, GICC 0x08010000 (both confirmed for this exact QEMU 10.2.1 build, not assumed stable across versions) and PPI 30 for the non-secure EL1 physical timer (bonus finding: PPI 26 for the EL2 hypervisor timer, for item 0.7's EL2 path). Register offsets (GICD_CTLR, GICC_IAR, etc.) are architectural, not board-specific, and were cross-checked against /usr/src/linux-headers-*/include/linux/irqchip/arm-gic.h rather than recalled.

    Acceptance corrected — same defect C2 already fixed for items 0.2 and 0.5, missed here. "The timer interrupt is delivered and acknowledged" cannot be observed within this item's own scope: nothing arms the timer until item 0.7's apic_timer_start(). As written this item could never be marked done on its own evidence. Acceptance is now the same shape as 0.2/0.5: GIC initialises without fault, IAR/EOIR path is wired into aarch64_irq_handler() and ready, boots with no regression. Item 0.7's tick-advance acceptance is what proves this path actually delivers and acknowledges an interrupt, exactly as 0.5 already defers to 0.7 for the same reason.

    Done when: GIC distributor and CPU interface initialise without fault; the timer PPI is enabled; aarch64_irq_handler() reads IAR, dispatches, and writes EOIR; boots to the prompt with no regression. Scope is one interrupt; a general GIC driver is out of scope and must not be written.

  • 0.7 — aarch64: arm the generic timer. apic_timer_start() / apic_timer_stop() using the register set chosen in 0.4, re-armed each tick. Done when: heartbeat_ticks() advances on aarch64 at the configured rate.

  • 0.8 — Converge the three architectures on one tick path, and make the physical heartbeat adaptive. One heartbeat_tick() call site per architecture; the ISR does counter, timestamp and flag only. Per the GAP-A1 ruling, the hardware tick drives instrumentation only: the bottom half services TIME-TRUST bookkeeping, and the engine (vm_tick(), decay, inference) stays on the virtual tick — execution-paced, exactly as today. Nothing that feeds patron state reads the hardware counter.

    Per §26 (ruled): the physical re-arm period is no longer a fixed 100 Hz constant. heartbeat.c owns the current adaptive period (heartbeat_set_adaptive_period_ns() / heartbeat_next_period_ns()); Loop #7's existing site in vm_runtime.c calls the setter with its stable/volatile-derived value, rescaled to the 10 ms kernel base per §26.3 (not the 10 µs hosted HEARTBEAT_TICK_NS); each architecture's re-arm function reads the getter and converts to its own raw counter units instead of using a hardcoded period. No new concurrency primitive — single writer (mainline), single reader (ISR), same shape §21.1 already found free on one hart. Done when: all three architectures drive the same TIME-TRUST bottom half; no loop math runs in interrupt context; vm_tick()'s call sites are unchanged.

    Live variation not directly observed. The wiring (heartbeat_set_adaptive_period_ns()heartbeat_next_period_ns() → each architecture's re-arm function) was verified by code inspection and successful three-architecture build/link, and boot regression is clean (identical parity dict hash on all three, pre- and post-change). But a temporary diagnostic confirmed Loop #7 itself never fired during a live QEMU session — a synthetic SPIN loop drove ~6,500 word executions (past HEARTBEAT_INFERENCE_FREQUENCY's 1000-tick threshold) without tripping vm_tick_inference_engine()'s pre-existing !vm->rolling_window.is_warm gate (vm_runtime.c:583). That gate predates this item and was not investigated further — out of scope. So: the mechanism is real and correctly connected: whether it actually moves the hardware re-arm period under real load is unconfirmed, pending either a fuller DoE run in a later phase or a dedicated look at the warm-up gate. Refs: §16.4 (as ruled), §18.4, §21.2, §26.

  • 0.9 — Write the concurrency constraint at the mutex stub. Add a comment at src/starkernel/vm/host/shim.c:415 stating that the no-op is correct only while nothing in interrupt context mutates shared structure, and that making it a real spinlock would deadlock a single hart. Done when: the comment is in place. This is a documentation item; no behaviour changes. Refs: §21.2, §21.5 #1.

  • 0.10 — Phase 0 acceptance. Full three-architecture QEMU run. Confirm: boots to prompt on all three; tick count non-zero on all three; on riscv64 after 0.3, trust near Q48_ONE and variance small relative to the new expected_delta — not merely "sane", which is unfalsifiable (C6); amd64 output unchanged from its pre-branch behaviour (a valid control under the GAP-A1 ruling, since 0.8 no longer touches engine plumbing). Then boot one architecture twice and confirm the parity dict hash is identical across runs. If it drifts, something is firing on wall time and Phase 0 is not complete. Done when: all of the above observed, logs committed.

    Observed 2026-08-04. All three boot to ok>. Tick count at the point just before sk_repl() (a bounded wait for 3 real ticks was added at kernel_main.c — with none, the count landed on 1 (amd64) and 0 (riscv64) purely from how little wall time elapses between arming the timer and this print, which is not the same claim as "the heartbeat doesn't tick" and would have been a false negative to report as one): amd64 4, riscv64 3, aarch64 3. riscv64: trust=0x00010000 (exactly Q48_ONE), variance=0x0. amd64: dict_hash=0x3d4e1daf289da94f, identical to the pre-item-0.8 baseline (logs/20260803-231322) — unchanged output, as the GAP-A1 control requires. Two consecutive amd64 boots (logs/20260804-001948, logs/20260804-002021) both produced dict_hash=0x3d4e1daf289da94f — reproducible, no wall-clock leakage into patron state. Logs committed: logs/20260804-001727 (amd64), logs/20260804-001805 (riscv64), logs/20260804-001850 (aarch64), logs/20260804-001948 / logs/20260804-002021 (amd64 double-boot pair). Refs: §16.4, §18.5.


25.2 Phase 1 — Design questions to settle on paper

These need answers, not code. Each one is settled by amending the relevant section of this document and committing that amendment as its own item.

  • 1.1 — Exclusive access ("sitting in a car"). §8 asserts a per-patron exclusivity primitive that is not a global lock. Nothing defines it. Decide what it is, what it blocks, and what happens if a patron is selected for reaping while held. Refs: §8. This is the largest unresolved design question.

    Hard prerequisite of item 3.1, and its outcome may amend §3. This item is filed in Phase 1 alongside questions that have no structural effect, and it is not in that class. A per-patron exclusivity primitive plausibly needs a held flag or a holder index — a ninth wire in §3's table, in the header item 3.1 builds. Resolve 1.1 after 3.1 and the cell header gets rebuilt.

    §25.4 already blocks Phase 3 on items 1.11.7, so the ordering is right. What was missing is why 1.1 specifically — which is the kind of omission that gets an item quietly reordered later by someone who does not know what it was holding up. §23.3 reserves 4 header bytes partly against this outcome.

    RESOLVED 2026-08-04 — containment, not a lock. A ninth wire, contains (§3): an index to the patron currently held inside this one, or none. Reap is gated, not density-derived — a patron with a non-none contains link cannot be reaped, full stop. Chains up to a depth cap, default 5, exposed as a Kconfig symbol (named at implementation time in item 3.1) rather than hardcoded — this project's existing tunable-knob convention. Unwinding is innermost-first, forced by the chain's own topology, not a policy choice — no FIFO/LIFO decision exists to make. Single occupant per level. Full argument in §8. Same-Stadium relationship — distinct from item 1.7's VM-tree-recursion question (§20.5 #4), which this does not resolve and remains open. Item 3.1 is now unblocked on this item; the ninth wire and the reserved header bytes (§23.3) are the concrete carry-forward.

  • 1.2 — The resting floor. Whether a VM's quota has a floor, and whether it is the mass of its pinned patrons (derived) or a constant (tuned). Refs: §22.5 #1.

    RESOLVED 2026-08-04. Floor = max(mass of pinned patrons, one message-sized cell). Both terms derived, neither tuned — the second closes a reachability gap the first term leaves open for a VM with nothing pinned (zero quota means it can never receive the message that would let it regrow). Full argument in §22.5 #1.

  • 1.3 — What triggers a capacity transfer. Hera arbitrates; on what signal, and how often. Should read the density gradient, not a schedule. Constraint, not optional: arbitration mutates patron mass, so §18.5's invariant binds it directly — anything that influences patron state advances on tick count; wall-clock time may be recorded for diagnostics and must never be an input to a decision. Pacing arbitration off a wall-clock interval would reintroduce exactly the defect item 2.1 exists to remove, in a new place. Whatever 1.3 decides must be expressible in ticks. Refs: §22.5 #2, §18.5, §22.4.

    RESOLVED 2026-08-04. Hera evaluates once per capacity-tick (a coarser, derived multiple of the virtual tick — the multiple itself is item 1.4). At each capacity-tick she finds the single densest and single least-dense live VM; if they differ at all, a transfer is eligible — a pure comparison, no tuned threshold, same shape as §19.3's admission rule. Cadence carries the "sustained density" requirement; the decision itself is a comparison. Both are tick-expressible, never wall-clock. Not resolved: how much capacity moves per transfer — reported, not invented, out of this item's scope. Full argument in §22.5 #2.

  • 1.4 — The heat/capacity timescale ratio. The ordering is fixed (capacity slower); the ratio is not. Refs: §22.4, §22.5 #3.

    RESOLVED 2026-08-04 — 1000:1. The capacity-tick gets its own named constant, defaulted to 1000 virtual ticks, matching the existing precedent at capsule_vm_physics.c:434-441 (vm_physics_heartbeat_tick()'s HEARTBEAT_INFERENCE_FREQUENCY-gated fleet recalibration loop) rather than an invented number. Comfortably past §12 Q5's order-of-magnitude minimum. Kconfig-tunable at implementation (item 3.1). Full argument in §22.4.

  • 1.5 — The outer bound. The outer Stadium's capacity, and the behaviour at the bound: birth refused, or coldest VM reaped. Refs: §20.5 #1, §22.5 #4.

    RESOLVED 2026-08-04. Bound = 4 (matches Tripod's known topology: Hera + 2 Hermes + Artemis), Kconfig-tunable, explicitly a placeholder pending a later DoE campaign to find an idealized default rather than a second guess made on paper. Fixed for the machine's lifetime once set at build. At the bound, birth is refused — not coldest-VM-reaped, which the original text called "consistent with §19.3" but which does not survive §20.2's cold-start birth rule (a newborn can never out-density an existing warm VM). Making room stays Hera's own deliberate act. Full argument in §20.5 #1.

  • 1.6 — A VM's mass: allocated share, or one cell. Refs: §20.4, §20.5 #2.

    RESOLVED 2026-08-04 — allocated share. Less a fresh choice than a confirmation of what §22's elasticity mechanism and §24.3 already treated as decided, and what items 1.21.5 already assumed. The one-cell alternative would make outer-level density collapse to heat alone, discarding the starved-vs-small diagnostic that's the entire point of §20.4. Full argument in §20.4.

  • 1.7 — Rule out recursion beyond two levels — deliberately, not by omission. Refs: §20.5 #4.

    RESOLVED 2026-08-04 — Kconfig-tunable cap, default 2. Not a hard prohibition: bounded, same treatment as item 1.1's containment depth. 2 matches §21's already-decided two-level structure (outer VM Stadium, per-VM inner Stadium). Enforced explicitly at VM-birth time — a birth that would exceed the configured depth is refused. Full argument in §20.5 #4.

  • 1.8 — Block content change at migration. A resident block whose content changed has a different hash on the way out. State the hand-off. Refs: §24.6 #1.

    RESOLVED 2026-08-04. New hash computed exactly once, at the migration boundary, as part of the block's MIGRATE code field — the resident identity (handle/LBN) never changes mid-residency. Whether the old hash is retained for audit is an Artemis-layer question, out of scope for the Stadium per §5's boundary. Full argument in §24.6 #1.

  • 1.9 — Redefined words as two resident patrons. Confirm both may be on the floor. Refs: §24.6 #2.

    RESOLVED 2026-08-04 — confirmed, yes. Factual, not a design choice: vm_dict_resolve_in_bucket() keeps both entries resident with newest-wins shadowing, no GC on redefinition. Correct behaviour, not an artefact. Full argument in §24.6 #2.

  • 1.10 — Identity elision. Whether identity can be dropped for some kinds without a per-kind branch. Optimisation; may be closed as "no". Refs: §23.4 #3.

    RESOLVED 2026-08-04 — closed as no. Eliding it per-kind reintroduces the type-field branch §18.3 forbids; eliding it uniformly breaks lookups words and blocks already depend on outside the Stadium (name, LBN). Neither path is reachable without violating an existing constraint. identity stays a fixed, always-present 8-byte field for every kind. Full argument in §23.4 #3.

  • 1.11 — Dirty-event granularity. Leaning region-based. Blocked on item 4.3 — it is settled as part of the console migration, not speculatively before it (C5). Refs: §17.5, §23.2, §23.4 #1.

  • 1.12 — The continuation-cell encoding. Contiguous (continuation cells are pure payload; allocation must find runs, reintroducing fragmentation) or linked (each continuation cell carries a next-index, costing 4 bytes of payload and changing every large patron's mass). §22.3's per-VM free list guarantees no adjacency, so linked is the default unless allocation changes. This was §23.4 #4 — a stated blocker of item 3.1 that was never a schedulable item until now (C4). Settling it completes §23.3's sizing table. Refs: §23.4 #4, §23.3, §22.3. Prerequisite of 3.1.

    RESOLVED 2026-08-04 — linked. Forced, not chosen: §22.3's disjoint per-VM free list gives no adjacency guarantee, and guaranteeing contiguity would reintroduce the fragmentation §3/§13 already ruled out. 4-byte next-index, 60 usable bytes per continuation cell, 18 continuation cells for a 1024-byte block. §23.3's sizing table is complete; item 3.1 is unblocked on this item. Full argument in §23.3's "The continuation cell" subsection.


25.3 Phase 2 — Prepare the existing physics

  • 2.1 — Restate heat transfer on the virtual tick. vm_physics_touch() scales transfers by wall-clock elapsed time (capsule_vm_physics.c:272). Restate it on the virtual tick — the execution-derived counter of §16.4 as ruled, not the hardware heartbeat, whose interleaving with execution is wall-clock-dependent and would leave the acceptance below unachievable (§25.7.1 GAP-A1). Done when: no wall-clock value influences heat, and the same capsule booted twice produces an identical fleet heat sum across the two runs — achievable now that both the touch points and the elapsed-tick values are deterministic functions of execution. Refs: §16.4 (as ruled), §18.5, §19.6 #3.

    Acceptance corrected. This item previously accepted on the dictionary-hash double-boot check from 0.10. That cannot detect this work: §18.5 establishes that vm_physics_touch() writes node->physics, not DictEntry.execution_heat, and therefore never reaches the parity hash. The dict hash would be identical whether 2.1 succeeded, failed, or was skipped. Fleet heat is the quantity this item changes, so fleet heat is what has to be compared. Run 0.10's dict-hash check as well, as a regression guard — but it is not evidence for 2.1.

    DONE 2026-08-04. vm_physics_touch() no longer takes a now_ns parameter at all — it reads fleet_heartbeat_tick_count internally, which vm_runtime.c:143 confirms is execution-paced (advanced once per vm_tick() call), not wall-clock. VMPhysics.last_active_nslast_active_tick; VMFleetTouchSample.elapsed_uselapsed_ticks; a new explicit touched flag replaces the old > 0 sentinel, which doesn't safely carry over to tick counts (a genuine first touch can land on tick 0). The three call sites (VM-EXEC, VM-CALL, VM-STEP in mama_forth_words.c) dropped vm_monotonic_ns(vm) accordingly. vm_physics_heartbeat_tick()/vm_physics_tick()'s own dead now_ns parameters were left alone — already unused, already documented as such, out of this item's scope.

    Regression: clean. All three architectures boot to ok> with identical dict_hash=0x3d4e1daf289da94f, matching the item-0.10 baseline, on amd64 (×2), aarch64, and riscv64. Logs: logs/20260804-113146, logs/20260804-113233 (amd64 pair), logs/20260804-131020 (aarch64), logs/20260804-131119 (riscv64). No new compiler warnings in the touched files.

    Honest limitation on the fleet-heat-sum acceptance criterion. With Tripod pruned to Hera alone (item 0.1), vm_physics_touch()'s fan-out has no other live VM to pull heat from — others_total is always 0, so the fleet heat sum is trivially Q48_ONE on every boot regardless of whether the tick logic is correct. The double-boot dict-hash match is a valid regression guard (as the acceptance note above already says), but it does not actually stress-test this item's new code path. A real check needs at least one other live VM to touch, which returns in Phase 4 (Hermes/Artemis) — not fabricated here.

    Unresolved, flagged not fixed: fleet_transfer_slope_q48's seed (65536/3) was calibrated against elapsed wall-clock microseconds; elapsed ticks between touches is a different quantity at a different scale, and the seed has not been re-fit against it. Left as-is per §25.0 rule 4 (no invented numbers) — a real re-tune is DoE work (item 5.1), and can only be meaningfully measured once Phase 4 restores a multi-VM fleet anyway, per the limitation just above.

  • 2.2 — Bound the VM registry. The registry is a kmalloc-backed unbounded list (capsule_vm_physics.c:71-72). Give it the hard bound decided in 1.5. Done when: the population is bounded, birth at the bound behaves as 1.5 specifies, and the three-architecture boot is unaffected. Refs: §2, §13, §19.2, §20.2.

    Justification corrected. This item previously read that the registry "makes fleet K an identity that cannot fail" and accepted on VM-CONSERVED? becoming able to fail. Both were wrong, and the reason is now in §20.2: heat is transferred, not renormalised, so conservation is already a real invariant and already falsifiable — by the dropped-remainder path at :240-244 and by integer truncation at :304-305. Bounding the population changes neither.

    The bound is still needed, on the two grounds §2 now states: finite state for §13's induction and model checking, and density requires a capacity to be dense within (§19.2), without which §19.3's admission rule has nothing to compare against. Those are the honest justifications and this item now rests on them.

    Making conservation more falsifiable is a different and larger piece of work — fixing the truncation leak — and is recorded in §25.7 rather than folded in here.

    DONE 2026-08-04. STADIUM_MAX_VM_COUNT Kconfig symbol (default 4, per item 1.5), wired through Makefile.starkernel and given the missing starforth_config.h fallback default (STARFORTH_CONFIG_STADIUM_MAX_VM_COUNT_DEFAULT) that the earlier WIP commit omitted — without it the macro was only ever defined when a Kconfig .config was active, and this build has none, so the first compile attempt failed with STADIUM_MAX_VM_COUNT undeclared. vm_registry_live_count() (added in the prior WIP commit, counts only VM_STATE_LIVE nodes) is now called in capsule_birth_baby() (src/starkernel/capsule/capsule_birth.c), between capsule validation and vm_registry_alloc(), so a full fleet is refused — returning the new CAPSULE_RUN_ERR_FLEET_FULL and logging via capsule_parity_log_birth_failed() with vm_id=0 (no VM is allocated on this path) — before any EMBRYO registry slot is consumed.

    Regression: clean. All three architectures boot to ok> with identical dict_hash=0x3d4e1daf289da94f, matching the item-0.10/2.1 baseline: amd64 (logs/20260804-134914), aarch64 (logs/20260804-134957), riscv64 (logs/20260804-135102).

    Honest limitation. With Tripod pruned to Hera alone (item 0.1), the fleet never reaches STADIUM_MAX_VM_COUNT during boot, so this run exercises the bound-check code path only in the trivially-not-full case — the actual refusal branch is unexercised until Phase 4 restores a multi-VM fleet. Same caveat item 2.1 already recorded for the same reason.


25.4 Phase 3 — Stadium core

Blocked on Phase 0 complete, and on items 1.11.7 and 1.12.

  • 3.1 — Cell and header. Define the entry with all eight wires (§3) — nine if item 1.1 resolves to a holder index. Define both members of §3's closed two-valued union: patron header and continuation cell. Validate the 64-byte cell by counting real bytes; adjust and record if it does not fit. Blocked on: item 1.1 (may add a wire) and item 1.12 (the continuation-cell encoding — contiguous or linked — which sets the mass of every large patron and cannot be guessed). Refs: §3, §23.3, §23.4 #4.

    DONE 2026-08-04. StadiumPatronHeader and StadiumContinuationCell defined in the new include/starkernel/vm/stadium.h, unioned as StadiumCell; translation unit src/starkernel/vm/stadium.c added to Makefile.starkernel's LOADER_EXTRA_SRCS / KERNEL_EXTRA_SRCS so the header actually gets compiled, not merely included by something that never builds.

    Discriminator ruling, made before this item's code was written (Captain Bob's call): the header/continuation discriminator is an external side bitmap, one bit per cell, kept outside the 64-byte cell array — not a header field. Amended into §3 and §23.3 accordingly. Item 3.1 declares the bitmap's purpose and indexing contract in a comment; it does not allocate it — that is item 3.2's scope, since sizing depends on the memory budget item 3.2 works from.

    Byte count, both counted for real, both exactly 64 with zero compiler-inserted padding (verified via three C99-portable negative-array-size assertions, no _Static_assert — this project targets C99, not C11):

    • StadiumPatronHeader: identity u64(8) + heat u64(8) + ttl u32(4) + link u32(4)
      • contains u32(4) + mass u16(2) + flags u8(1) + behaviour u8(1) + payload u8[32] = 64. pin lives as bit 0 of flags, not its own field, matching §3/§23.3. Fields ordered largest-to-smallest so every offset is already a multiple of its own alignment and the 64-byte total is a multiple of the struct's 8-byte max alignment — no padding, without resorting to a packed attribute (a GNU extension, forbidden by CLAUDE.md's strict-ANSI-C99 rule). Because the discriminator moved outside the cell, this matches §23.3's original 32-byte-header / 32-byte-inline-payload proposal exactly — no adjustment needed here, unlike the continuation cell below.
    • StadiumContinuationCell: next u32(4) + payload u8[60] = 64, unchanged from item 1.12's figure — the discriminator living outside the cell means neither variant's byte budget was disturbed by it.

    Assertion proven live, not just present: temporarily changed the header check's expected size to 63, recompiled stadium.c standalone (cc -std=c99 -Wall -Werror -Wextra -D__STARKERNEL__), confirmed the build failed with error: size of array 'stadium_header_size_check' is negative, then restored it and confirmed a clean compile.

    Regression: clean, and stadium.o confirmed present. All three architectures boot to ok> with identical dict_hash=0x3d4e1daf289da94f, matching the item-2.2 baseline — amd64 (logs/20260804-145132), aarch64 (logs/20260804-145232), riscv64 (logs/20260804-145329). find build/<arch> -iname stadium* confirmed obj/loader/vm/stadium.o and obj/kernel/vm/stadium.o both exist post-build, closing the gap the WIP on item 2.2 exposed: a header nothing compiles proves nothing.

    Open, deferred honestly: §23.4 #2 ("does a typical message still fit in one cell") remains unanswered — there is no message patron struct anywhere in this tree yet (messages are undesigned future work), so there is nothing concrete to check the 32-byte inline payload against. Not fabricated a number to close this; left open.

    REOPENED 2026-08-04. While reading context for item 3.2, found that two earlier resolutions had explicitly named this item as where their Kconfig symbols would be implemented — item 1.1 (line ~2400, "exposed as a Kconfig symbol, named at implementation time in item 3.1", the contains-chain depth cap, default 5) and item 1.4 (§22.4 and its own resolution, "Kconfig-tunable at implementation (item 3.1)", the capacity-tick constant, default 1000). Neither made it into the work above, because 3.1's own stated scope ("cell and header") never mentioned them — the promise lived only in items 1.1 and 1.4's text. Captain Bob ruled: reopen, add both here (Phase 3 is implementation, unlike Phase 1's paper-only items — item 1.7's own nesting-depth Kconfig symbol was correctly left undone at Phase 1, by contrast). Declaration only, matching how STADIUM_MAX_VM_COUNT was introduced in item 2.2's WIP commit before its consuming logic existed: STADIUM_CONTAINS_DEPTH_MAX (default 5) has no consumer yet — reap-gating on contains is item 3.5's scope. STADIUM_CAPACITY_TICK (default 1000) has no consumer yet either — capacity arbitration isn't on the punch list at all yet. Not inventing that logic here; only the two symbols.

    RE-CLOSED 2026-08-04. Both symbols added following STADIUM_MAX_VM_COUNT's exact pattern: Kconfig.kernel entry, Makefile.starkernel kconfig_int + VM_FEATURE_FLAG_VARS forwarding, starforth_config.h fallback default (needed for this no-.config build, same gap the original STADIUM_MAX_VM_COUNT WIP commit hit and item 2.2 fixed). stadium.h now includes starforth_config.h and carries two more C99-portable compile-time checks (> 0, not byte-count) proving both symbols are defined and sane in the same translation unit as the cell checks. contains's field comment now references STADIUM_CONTAINS_DEPTH_MAX by name. Recompiled stadium.c standalone (clean) before the full run.

    Regression: clean, re-run after reopening. All three architectures boot to ok> with identical dict_hash=0x3d4e1daf289da94f -- amd64 (logs/20260804-151032), aarch64 (logs/20260804-151115), riscv64 (logs/20260804-151222).

  • 3.2 — Boot-time allocation. One global cell array, sized from the memory budget, before any VM exists. Refs: §6, §17.6, §22.3.

    DONE 2026-08-04. Sizing ruled by Captain Bob among three options (flat Kconfig constant / runtime PMM-derived / STADIUM_MAX_VM_COUNT × STADIUM_CELLS_PER_VM): runtime PMM-derived, matching §17.6 position (b) literally rather than position (a), the "arbitrary bound" that section argues against. New STADIUM_MEMORY_PERCENT Kconfig symbol (default 1%, ruled by Captain Bob) — stadium_boot_init() in stadium.c reads pmm_get_stats().free_bytes at the point of allocation, takes that percent, rounds down to whole STADIUM_CELL_BYTES cells. Both the cell array and the discriminator bitmap item 3.1 declared but did not allocate are kmalloc'd here and explicitly zero-filled (kmalloc does not zero — checked kmalloc.c, no memset). Called from kernel_main.c, immediately before sk_vm_bootstrap_parity() — before any VM exists, per §6. Failure is soft (logs, returns -1, does not halt boot): nothing downstream consumes the Stadium yet, matching the existing precedent one line below it (sk_vm_bootstrap_parity()'s own failure path also just logs and continues).

    Made observable, by agreement before writing code (same blind spot the item-3.1 uncompiled-header gap exposed): a Stadium: N cells (M KB) console line at the allocation site, so the three-arch boot's serial logs are evidence the array was actually allocated at the size intended, not just that the kernel still boots.

    Regression: clean, and the boot line confirmed present on all three. All three architectures boot to ok> with identical dict_hash=0x3d4e1daf289da94f, matching the item-3.1 baseline. Observed sizes (1% of free memory at the allocation point, this session's QEMU configuration): amd64 74234 cells (4639 KB, logs/20260804-164332), aarch64 161329 cells (10083 KB, logs/20260804-164432), riscv64 76122 cells (4757 KB, logs/20260804-164537). Reported, not evaluated against §23.3's ~4096-cells-per-VM illustrative figure — that figure is itself labeled a proposal to validate, not a target this item is scored against.

    Explicitly not built here, reported per §25.0 rule 3: per-VM free lists (§22.3, "each VM holds its own free-list head index into the global array") — those get granted when Hera assigns a VM its quota, which is not this item's scope.

  • 3.3 — Behaviour enumeration and dispatch. Closed tag set fixed at build time. Enumerate behaviours, never patron kinds. Refs: §13, §18.3.

    DONE 2026-08-04. StadiumBehaviour (stadium.h) enumerates exactly the four tags §18.3 already names — MIGRATE, DELIVER, EXPIRE, COOL — mapped from §17.1's patron table: blocks→MIGRATE, messages→DELIVER, ACLs→EXPIRE, words and VMs both→COOL (§18.3 explicitly: "a VM's behaviour tag is COOL, the same tag a word carries"). Nothing invented — the tag set and mapping were already in the document.

    stadium_dispatch(cell_index, behaviour) (stadium.c) dispatches on the tag only — never asks what kind of patron departed, per §3/§18.3. Handlers are stubs (console log only): the real migrate/deliver/expire/cool actions belong to subsystems not yet migrated onto the Stadium (Phase 4, §25.5). Nothing calls stadium_dispatch() yet either — item 3.5 is its first consumer.

    The closedness requirement is now a compiler-enforced property, not just prose: the switch in stadium_dispatch() is exhaustive with no default case. Verified this is real, not decorative — temporarily deleted the COOL case, rebuilt, got error: enumeration value 'STADIUM_BEHAVIOUR_COOL' not handled in switch [-Werror=switch], restored it, confirmed clean again. Under this project's -Wall -Werror, a fifth behaviour tag added without updating dispatch is now a build failure, not a silent gap — the strongest available reading of §13's "closed enumeration, fixed at build time."

    The header's behaviour field stays uint8_t, not the enum type itself: C does not guarantee an enum's underlying type, and that field's offset is load-bearing for the exact 64-byte layout item 3.1 validated. Documented as holding StadiumBehaviour values cast to uint8_t. No new Kconfig symbol — this is a closed code set, not a tunable number.

    Regression: clean. All three architectures boot to ok> with identical dict_hash=0x3d4e1daf289da94f, matching the item-3.2 baseline — amd64 (logs/20260804-170226), aarch64 (logs/20260804-170309), riscv64 (logs/20260804-170407).

  • 3.4 — Density ranking. Heat ÷ mass, read not computed. Refs: §19.2, §19.3.

    DONE 2026-08-04. stadium_density(cell_index) (stadium.c) reads a header's heat and mass and returns heat / mass — a division on demand from fields already stored in the cell, matching §19.3's "read, not computed by a scheduler" literally: no background process maintains this value. Stays valid Q48.16 without any special fixed-point routine, since heat is already Q48.16 and mass is a plain integer divisor.

    mass == 0 and an out-of-range cell_index both return 0 rather than dividing by zero — an empty or never-admitted slot (everything is zero-initialized by item 3.2's stadium_boot_init(), and nothing yet births a patron into the Stadium) has no footprint to be dense within.

    Deliberately not built here, per the item's own wording: finding the densest or least-dense resident (§19.3's admission/eviction comparison) is item 3.5's scope — this function supplies the per-cell value that comparison will read, not the ranking/min-max machinery itself. Nothing calls stadium_density() yet either.

    Regression: clean. All three architectures boot to ok> with identical dict_hash=0x3d4e1daf289da94f, matching the item-3.3 baseline — amd64 (logs/20260804-171332), aarch64 (logs/20260804-171414), riscv64 (logs/20260804-171513).

  • 3.5 — Admission and eviction. Admit if denser than the least dense resident. Refs: §19.3.

    DONE 2026-08-04. stadium_admit(candidate) and stadium_evict(cell_index) in stadium.c. Admission scans for an unused cell first (bitmap bit clear, mass == 0) and places there directly, no comparison needed — §19.3's density rule only governs the full case. Otherwise finds the least-dense resident, skipping pinned patrons (flags bit 0, §3) and contains-gated ones (item 1.1: a patron holding another cannot be reaped), and evicts it only if the candidate is strictly denser ("denser than," not "at least as dense as," per §19.3's own wording). Eviction dispatches the departing patron's behaviour (§18.3) before clearing its slot, per §17.2 ("reap means leaves the floor, not destroyed").

    A real bug caught before this ever ran: the first draft used contains == 0 to mean "holds nothing." Cell index 0 is a valid index — Hera, item 3.6's patron zero — so that conflated "contains Hera" with "contains nothing." Fixed with a proper sentinel, STADIUM_CONTAINS_NONE (UINT32_MAX), distinct from every valid index. Caught by re-reading before compiling, not by any test.

    A second-pass review (before the boot run) found one blocking gap, fixed, and two non-blocking ones, recorded rather than fixed:

    • Blocking, fixed: neither function accounted for mass. Admission placed exactly one cell and set exactly one bit regardless of the candidate's stated mass; eviction symmetrically freed one cell and orphaned the rest. For mass > 1 (§23.3: a 1024-byte block is mass 19) this breaks capacity conservation — cells leak on every eviction of a multi-cell patron, and the "Stadium is full" test becomes wrong since occupancy was never correctly accounted. The fix is refusal, not implementation: stadium_admit() now refuses any candidate with mass != 1. A multi-cell patron needs its continuation chain allocated through the per-VM free lists (§22.3) — item 3.2's own DONE note already deferred those as out of scope, granted only when Hera assigns a VM its quota. This item does not build them; it refuses what it can't yet do correctly rather than doing it wrong.
    • Not fixed, documented as a live latent gap: the discriminator bitmap can only say header-vs-not-header, not free-vs-continuation. The free-cell scan (!bitmap_get(i) && mass == 0) reads offsets 2829 of whatever cell is actually there under the header struct layout; for a real continuation cell those offsets are payload bytes, and if they happen to read as zero the scan would treat a live continuation cell as free and overwrite it. Latent, not live: nothing creates continuation cells yet, and the mass != 1 refusal above keeps this provably latent for as long as that refusal stands. The real fix is the free list itself — a cell is free iff it is on one, no union-punning needed — which supersedes this scan when built.
    • Not fixed, minor: stadium_admit()'s two full-array scans are O(N) each, and candidate_density duplicates stadium_density()'s arithmetic inline because the candidate is not yet in the array to call it on. Both go away with the free list; not worth a workaround for code with no caller yet.

    Unexercised at runtime, stated plainly rather than implied by a passing boot: nothing calls stadium_admit() or stadium_evict() yet (no real patron kind is wired to the Stadium — Phase 4 migration work). No self-test was added: filling ~74,000+ cells to actually reach the eviction-on-full branch in a boot run was judged impractical for the value it would add, following the same honesty precedent item 2.2 recorded for its own unexercised fleet-full path. The free-cell placement branch, the pin/contains skip logic, and the density-comparison branch have never executed against real data.

    Regression: clean. All three architectures boot to ok> with identical dict_hash=0x3d4e1daf289da94f, matching the item-3.4 baseline — amd64 (logs/20260804-172516), aarch64 (logs/20260804-172556), riscv64 (logs/20260804-172651).

    Amended by item 3.7, 2026-08-04, same day. stadium_admit()'s signature changed — it now takes a vm_id parameter and scopes both free-cell placement and eviction-search to that VM's own quota, per §22.3's per-VM free lists (built in 3.7, not this item). The two full-array O(N) scans this item shipped are gone in the O(1)-free-list-pop common case; the "not fixed, minor" note above about them is superseded. The mass != 1 refusal and the pin/contains logic described above are otherwise unchanged.

  • 3.6 — Hera as patron zero, pinned. Assert at the eviction site; selecting Hera is a panic, not a filtered candidate. Refs: §20.5 #3.

    DONE 2026-08-04. STADIUM_HERA_CELL_INDEX (0) documented as a positional invariant from §6's boot order (Hera is the first patron admitted), not a runtime identity check — nothing births anything yet, Hera included, so the index is never actually occupied today. stadium_evict() now panics via sk_hal_panic() (already noreturn, matching arena.c's existing use) if a resident cell 0 is ever selected.

    Placement matters and was deliberate: the assertion runs before the pin and contains refusal checks, not after. §20.5 #3 asks for a check independent of pin holding — if it ran after the pin check, a wrongly-cleared pin would let the ordinary refusal path quietly return -1 instead of ever reaching the panic, silently swallowing exactly the failure this item exists to surface.

    Per the item's own wording, not implemented as a filter: stadium_admit()'s least-dense search is unchanged — it still relies on the general pin skip (item 3.5) to avoid selecting a pinned Hera in the first place. §20.5 #3 explicitly: "state it as an assertion at the eviction site, not as a filter on the candidate set — filtering hides the bug, asserting reports it." Adding a second, redundant filter in the search loop would have done exactly what that line warns against.

    The panic path itself is, and will remain, unexercised by the acceptance mechanism. sk_hal_panic() halts the machine — triggering it deliberately would mean the kernel cannot reach ok>, which is incompatible with the three-arch boot being this project's sole acceptance test. Nothing calls stadium_evict() yet regardless (same as items 3.4 and 3.5), so this boot run does not exercise the check either way. Correctness rests on the placement argument above, not a test.

    Regression: clean. All three architectures boot to ok> with identical dict_hash=0x3d4e1daf289da94f, matching the item-3.5 baseline — amd64 (logs/20260804-173311), aarch64 (logs/20260804-173350), riscv64 (logs/20260804-173446).

    Correction, 2026-08-04, same day: the line originally here claimed "Phase 3 core complete" with items 3.13.6. That was premature — starting work on item 4.1 surfaced that its own prerequisite (the per-VM free lists §22.3 describes) doesn't exist yet. §25.4 gained a seventh item, 3.7, below. Phase 3 core is not complete until it is.

  • 3.7 — Per-VM free lists. Each VM holds its own free-list head index into the global array (§22.3); cells are drawn by popping that head, granted by Hera. Added 2026-08-04 after starting item 4.1 surfaced this as an unbuilt prerequisite — see item 3.6's correction note above. Refs: §22.3.

    DONE 2026-08-04. StadiumVMQuota table (stadium.c, size STADIUM_MAX_VM_COUNT, linearly searched by vm_id): capsule_birth.c's vm_id is monotonic and never reused (next_vm_id only increments, even across VM death — verified by reading, not assumed), so it cannot index a table directly; a linear scan over 4 entries costs nothing.

    A new per-cell stadium_owner byte array (one byte per cell, same pattern as item 3.1's discriminator bitmap) records which quota slot a cell belongs to — needed because eviction must return a freed cell to the correct VM's list, and because eviction's least-dense search must stay scoped to the evicting VM's own residents (quota isolation: one VM's admission can never evict another VM's patron). A compile-time check (STADIUM_MAX_VM_COUNT <= 255) confirms the quota-slot index fits the byte.

    Free-list linkage reuses each cell's own link field as a "next free cell" pointer while unresident — link is documented only as generic "index into the Stadium, not a pointer," so this is a repurposing of already-permitted, previously-unspecified storage, not a header change. It does not answer the separate, still-open question of which field would carry a multi-cell patron's first continuation-cell index — item 3.5's mass != 1 refusal stands exactly as it was.

    At stadium_boot_init(), every cell is chained into one list in ascending index order and granted in full to vm_id 0 (Hera) — the only VM that exists (item 0.1). Ascending order guarantees the first-ever pop returns cell 0, preserving item 3.6's "Hera is patron zero" invariant once real birth-wiring calls stadium_admit().

    stadium_admit()'s signature changed to stadium_admit(vm_id, candidate) — a change to code shipped in item 3.5, amended there (see above). Pops the calling VM's free-list head first (O(1)); only falls back to a same-VM-scoped eviction search if that list is empty.

    A real bug caught before the boot run, by a second review pass: the zero-fill that clears a header on eviction (and the initial free-list build) both leave contains == 0 — but 0 is Hera's valid index (item 3.1's earlier STADIUM_CONTAINS_NONE fix was about exactly this collision), so every cell on a free list was silently readable as "contains Hera." Fixed by explicitly setting contains = STADIUM_CONTAINS_NONE at both sites (the boot-time chain-build loop, and stadium_evict()'s free-list-return step) rather than leaving it to the zero-fill's incidental value.

    Explicitly out of scope, reported not invented:

    • Granting quota to any VM other than Hera, and transferring capacity between VMs, is capacity arbitration — item 1.3 left "how much capacity moves per eligible transfer" explicitly open, so this item does not invent it. Only the boot-time all-to-Hera grant exists; stadium_owner is set once at boot and never written again, so quota_slot_for_vm() returns refusal for every vm_id != 0, permanently, until something else writes to it. Item 4.2 ("Hermes native on the Stadium") will need both the grant path and the owner-array writes — flagging now so it isn't a surprise there.
    • Multi-cell continuation-chain attachment remains unresolved (see above); item 3.5's refusal is untouched.

    Unexercised at runtime, same as items 3.43.6: nothing calls stadium_admit() or stadium_evict() yet. The free-list pop path, the quota-scoped eviction fallback, and the boot-time chain-build are all unexercised against real data.

    Regression: clean. All three architectures boot to ok> with identical dict_hash=0x3d4e1daf289da94f, matching the item-3.6 baseline, and the Stadium: N cells (M KB) boot line is unaffected in format — amd64 (logs/20260804-180453, 74234 cells (4639 KB)), aarch64 (logs/20260804-180541), riscv64 (logs/20260804-180637).

    Correction, 2026-08-04, same day: starting work on item 4.1 surfaced a further prerequisite — see item 3.8 below. §25.4 gained an eighth item.

  • 3.8 — VM identifiers as UUID/GUID. Replaces capsule_birth.c's monotonic uint32_t vm_id with a wider, RFC-4122-shaped 128-bit identifier. Added 2026-08-04 after starting item 4.1 surfaced the need to thread a vm_id into stadium_admit()'s new quota parameter, and Captain Bob ruled UUID/GUID rather than keeping the narrower type. Refs: §22.3 (item 3.7's quota table), capsule_birth.c's VM registry.

    DONE 2026-08-04. New VMUuid type (include/starkernel/vm_uuid.h, src/starkernel/capsule/vm_uuid.c): two uint64_t halves, formatted RFC-4122-shaped (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) for logging.

    Not real randomness — checked directly, not assumed. This kernel has no RNG source at all. Verified empirically against QEMU 10.2.1 rather than guessed: amd64's RDRAND and riscv64's Zkr entropy extension are both real, available CPU features here (QEMU accepts -cpu qemu64,+rdrand and -cpu rv64,zkr=true without error); aarch64 has no RNG/RNDR property on any CPU model including max — checked exhaustively via QMP's query-cpu-model-expansion against all 23 of max's exposed properties, none RNG-related. Captain Bob ruled a uniform fallback across all three ISAs rather than a per-architecture split (real RNG on two, something else on the third).

    The fallback is a deterministic PRNG (splitmix64 — public-domain, minimal), seeded from the Mama capsule's content hash, pre-filling a 16-entry FIFO pool at boot (vm_uuid_pool_init(), called from capsule_birth_mama() right after the capsule hash is known), refilled with another batch continuing the same stream when exhausted (vm_uuid_next()) — exactly the shape Captain Bob asked for. Same capsule booted twice produces the same id sequence, preserving the run-to-run reproducibility this session's dict_hash regression check has relied on for every prior item.

    Hera keeps a fixed, reserved id — all-zero — not drawn from the pool. capsule_birth.c uses vm_id == 0 as a load-bearing sentinel in three places, found by reading before writing any code: "Hera cannot be killed" (capsule_vm_kill), the same check in capsule_vm_kill_all_nonmama, and the fleet heat-fanout parent-chain terminator (capsule_run.h's parent_vm_id comment: "self-referential, parent_vm_id == vm_id == 0"). vm_uuid_hera() (all-zero) preserves all three with a cheap equality check (vm_uuid_is_hera()).

    Two real sentinel-collision bugs caught before they shipped, both the same class of mistake STADIUM_CONTAINS_NONE was already fixed for once this session:

    • vm_uuid_none() (all-ones) is deliberately not all-zero, since all-zero is now Hera's reserved value — used for "not yet assigned" placeholders (vm_registry_alloc()'s embryo vm_id before birth completes) and "no VM" logging (item 2.2's FLEET_FULL refusal, which happens before any VM is allocated).
    • StadiumVMQuota's "slot empty" state was already tracked by an in_use boolean (item 3.7), not a vm_id sentinel value — so no second collision was actually possible there; confirmed by re-reading item 3.7's own code rather than assumed, and the dead, never-referenced STADIUM_QUOTA_SLOT_EMPTY macro item 3.7 defined "just in case" was removed as part of this item's cleanup.

    Blast radius, larger than first scoped — flagged mid-work rather than silently absorbed: beyond the originally-flagged capsule_run.*/capsule_birth.*/stadium.*, compiling surfaced that capsule_vm_physics.c/.h (the fleet heat-transfer layer item 2.1 modified earlier this session) has its own vm_id-keyed node table and walks parent_vm_id chains via capsule_vm_registry_get() — the same identity space, so it had to change too (vm_physics_init/_retire/_touch/_heat_of/_find/ _find_root_id), plus its callers in mama_forth_words.c and sk_vm_bootstrap.c.

    One live FORTH word contract changed, by explicit ruling: CAPSULE-BIRTH was ( capsule-id -- vm-id ), a single cell — can't hold 128 bits. Options were two cells, a silent 64-bit truncation, or a separate small FORTH-only handle; Captain Bob picked two cells ("there is doubles support in the FORTH std word set anyway"). New contract: ( capsule-id -- vm-id-hi vm-id-lo ), high cell on top, vm_uuid_none()'s hi/lo (both all-ones) on any failure path including the early bounds-check return. MAMA-VM-ID changed the same way: ( -- 0 0 ), both cells zero since Hera's id is all-zero.

    Regression: clean, across a genuinely large diff. All three architectures boot to ok> with identical dict_hash=0x3d4e1daf289da94f, matching the item-3.7 baseline — amd64 (logs/20260804-194631), aarch64 (logs/20260804-194711), riscv64 (logs/20260804-194810). A full (not standalone-file) kernel rebuild was used to catch cross-file breakage before the boot run, given the size of this change; it surfaced the capsule_vm_physics.c blast radius above that a narrower compile check would have missed.

    Phase 3 core complete. Items 3.13.8 close out §25.4.


25.5 Phase 4 — Migrate the subsystems

One subsystem at a time, converted completely. Never two live heat mechanisms at once (§11).

  • 4.1 — Hot words onto the Stadium. Replaces the round-robin eviction with density ranking, via the reservoir mechanism (§17.7) and a kernel-side word_id → cell_index map (no DictEntry change, decided 2026-08-05). Refs: §17.3, §17.7.

    Unblocked 2026-08-05 — §17.7 reads DECIDED. Acceptance restated below now that the mechanism itself changed; the original "measurable via stats.evictions/ stats.promotions" presumed reusing the old cache's HotwordsStats, which this item retires on the kernel side rather than extends.

    Done, 2026-08-05. Two rulings made mid-implementation (§17.7's addendum): Hera is now actually birthed into cell 0 (stadium_birth_hera(), a deliberate scope addition, not silently folded in) closing the cell-0 panic hazard the original design left open; the quantum/cool-rate got two new Kconfig knobs (STADIUM_WORD_HEAT_QUANTUM, STADIUM_WORD_COOL_RATE_Q48) with derived-not-fabricated defaults, flagged untuned. A third addition, required for the item's own correctness rather than scope creep: a FORGET coherence hook (stadium_word_forget(), called from vm_dictionary_untrack_entry()) reclaims a resident word's cell before its word_id is recycled, closing an aliasing gap of the same shape as the 2026-08-02 block_words.c bug.

    Acceptance, verified:

    • Kernel builds only: all five hotwords_cache_* call sites in dictionary_management.c (not just the two originally named) are bypassed under __STARKERNEL__.
    • Word dispatch feeds the Stadium at all three of vm_core.c's physics_execution_heat_increment() call sites — an already-resident word gets the reservoir-quantum touch; a non-resident word attempts Option B starter-grant admission.
    • New Stadium-side promotion/eviction counters (stadium_words_stats()) and a conservation check are printed to the boot console (stadium_words_print_boot_diagnostics()) before the REPL starts.
    • Hosted builds unaffected — confirmed via a clean hosted make.
    • All three architectures booted to ok> with logs under logs/20260805-130800/amd64/, logs/20260805-130919/aarch64/, logs/20260805-131030/riscv64/. dict_hash is identical across all three (0x3d4e1daf289da94f) and identical in shape to pre-4.1 parity output — untouched, as designed. Stadium diagnostics also identical across all three: promotions=354 evictions=0, resident_sum=65536 reservoir=0 sum=65536 (Q48_ONE=65536) — the conservation invariant closes exactly.
  • 4.1a — Quota granting: Hermes's birth grant. New prerequisite item, inserted 2026-08-05 while scoping 4.2 — found that no quota-granting mechanism exists at all. quota_slot_for_vm() refuses every vm_id != 0 today, permanently, by design (item 3.5's note); stadium_admit()'s own doc and item 3.2's DONE note both defer per-VM free lists to "when Hera assigns a VM its quota," which nothing builds. Item 1.3 (§25.2) resolved when Hera arbitrates a capacity transfer between VMs that already hold quotas, but explicitly left "how much capacity moves" unresolved and out of scope (§22.5 #2) — that is the recurring mechanism, and stays open; this item is narrower: Hermes's one-time initial grant at birth, the same shape as Hera's own whole-pool grant at stadium_boot_init(), not an instance of the still-open recurring loop. Refs: §1.3, §22.3, §22.5 #2.

    Ruled, 2026-08-05:

    1. Reservoir is not part of this. stadium.c's own comment states the conservation invariant per-VM — Σ(resident heat) + reservoir == Q48_ONE for that VM's own quota — not a shared pool split across VMs. Hermes gets her own fresh Q48_ONE reservoir at birth, the same pattern as Hera's boot grant, not a fraction of Hera's. Only cell count is actually open.
    2. Cell split: even. At Hermes's birth, half of whatever cells are currently on Hera's free list move to a new quota slot for Hermes. Hera's residents — including pinned cell 0 — are never touched; only her free list is split. No tuned constant: an even split needs no threshold, consistent with §22's "no tuned threshold" elsewhere in this design.

    Done, verified 2026-08-05. stadium_grant_quota(VMUuid new_vm_id, VMUuid from_vm_id) (stadium.c/stadium.h) exists as designed: counts from_vm_id's free list, splits the first half (by list-walk order) into a new quota slot for new_vm_id with stadium_owner reassigned per moved cell, terminates both lists correctly, and grants a fresh Q48_ONE reservoir. Refuses without crashing if new_vm_id already holds a quota, from_vm_id holds none, the free list has fewer than 2 cells, or no empty quota slot remains. Wired into every baby VM's birth (capsule_birth.c, right after stadium_vm_id is set, before IDENTITY exec) — failure is non-fatal to birth itself, same as having no quota is today's status quo for every VM.

    Verified via a boot-time self-test (kernel_main.c, right after item 4.1's diagnostic print) using a synthetic identity — deliberately not vm_uuid_next()'s real birth pool (would perturb the deterministic ID stream) and not a real capsule birth (item 0.1 pruned automatic Hermes birth from init.4th; restoring that is item 4.2's job, not this one's). All three architectures booted to ok> with logs under logs/20260805-145714/amd64/, logs/20260805-145806/aarch64/, logs/20260805-145902/riscv64/, each printing identically: Stadium quota grant self-test: OK, Hera reservoir=0 (already fully committed to resident words by item 4.1's own self-test, unchanged by this grant — correct, since this item never touches reservoir on the donor side), test-vm reservoir=65536 (a fresh Q48_ONE, as ruled). dict_hash identical across all three and unchanged from item 4.1's baseline (0x3d4e1daf289da94f), confirming this item added no dictionary word.

  • 4.2 — Hermes native on the Stadium. The proving ground; produces the effort number. Refs: §10. Unblocked 2026-08-05 — item 4.1a closed; stadium_grant_quota() exists and is wired into every baby VM's birth. Complete 2026-08-07 — all Done when bullets satisfied; see the effort number and MBR-scoping ruling below.

    Two rulings taken before work starts, 2026-08-05:

    1. stadium_owner[idx] fix folded into this item's scope, by explicit Captain Bob authorization (not a §25.0-rule-3 violation — this is the same "required for the item's own correctness" precedent as 4.1's FORGET hook). stadium_admit() writes stadium_owner[idx] on neither the free-list-pop nor the eviction-fallback path; harmless while Hera is the only VM with a quota, but this item puts a second VM (Hermes) on the Stadium, and without the fix a resident's evict-credit flows to the wrong VM's reservoir. Fix ships as part of this item's commit, called out separately in the acceptance below so it doesn't hide inside the migration diff.
    2. C/FORTH boundary: new thin FORTH-callable primitives, registered in C exactly like BIRTH/RUN/USE (kernel-only, not shared/vendored), justified under .claude/CLAUDE.md's "raw hardware access, atomics, syscalls, freestanding kernel ops" exception to "compose in FORTH first." Candidate surface — confirmed, not yet implemented:
      STADIUM-ADMIT     ( identity heat behaviour -- cell | -1 )
      STADIUM-EVICT      ( cell -- flag )
      STADIUM-RES@       ( vm-id -- heat )
      STADIUM-RES-PULL   ( vm-id qty -- heat )
      STADIUM-RES-PUSH   ( vm-id heat -- )
      
      Exact stack signatures and error handling to be finalized during implementation, not invented here. HERMES.md's non-negotiable — all Hermes-side logic in StarForth, zero new C beyond this primitive layer — still applies; these five words are the entire C surface this item may add.
    3. vm_core.c's hardcoded vm_uuid_hera() — Option A, add a VMUuid field to VM. Found while scoping this item, not a new bug: all three stadium_word_dispatch() call sites in vm_core.c (item 4.1) hardcode vm_uuid_hera(), with an inline comment already naming this as 4.2's job ("Tripod is pruned to Hera alone; revisit at item 4.2"). Fixing it requires a running VM* to know its own identity, which nothing today provides — VMUuid exists only on VMRegistryEntry (capsule_run.h), never on VM (include/vm.h) itself. Ruled: add a VMUuid field to VM, guarded #ifdef __STARKERNEL__ in the same block as the existing VMCallState lifecycle fields (include/vm.h ~line 523) — not Option B (threading vm_id through the call chain without touching the struct). VM is shared/vendored, same as DictEntry, so the hosted build's layout must stay untouched outside the __STARKERNEL__ guard. Set once, at VM creation, from the same VMRegistryEntry.vm_id the birth path already assigns (capsule_birth.c) — not invented at the dispatch call sites.
    4. Primitive surface grows from five to seven. Found while reading Hermes's actual implementation (capsules/hermes/init.4th): MSG-COOL-ALL/CH-COOL-ALL (blocks 4108/4114) mutate each live node's own heat field in place every HERMES-TICK, and MSG-TOTAL-HEAT/CH-TOTAL-HEAT sum it. None of the original five primitives expose a resident cell's own heat field at all — only admission, eviction, and the calling VM's reservoir. Ruled: two more primitives, same implicit-self discipline as the original five (cell must belong to the calling VM's own quota):
      STADIUM-HEAT@  ( cell -- heat )
      STADIUM-HEAT!  ( new-heat cell -- )
      
      STADIUM-HEAT! reconciles the reservoir delta atomically in C — pulls from the calling VM's reservoir if new-heat is higher than current (refusing, leaving heat unchanged, if the reservoir can't cover it), pushes back if lower — the same shape as stadium_word_dispatch()'s own cooling code. Conservation is never left to FORTH to get right by remembering to call STADIUM-RES-PULL/-PUSH itself; a single call is both the write and the correct accounting. Serves two callers: a cooling tick (heat × Q-DECAY, replacing MSG-COOL-ONE's in-place multiply) and a floor-refresh (COMMON-INIT/HERMES-TICK reset COMMON-CH's heat to a fixed Q.1/3 unconditionally, not a decay — needs the same delta-reconciling write, just with a different target value). STADIUM-HEAT@ alone serves MSG-TOTAL-HEAT/CH-TOTAL-HEAT's summation and MSG-REAP/CH-REAP-SAFE's heat-reached-zero check. Cooling cadence stays entirely in Hermes's own FORTH HERMES-TICK loop (rewritten to call these, not a new C-side per-tick sweep) — matches HERMES.md's language constraint and the fact that HERMES-TICK already owns this loop; nothing about moving the heat storage changes who decides when to cool.

    Open, surfaced not resolved: mapping Hermes's message/channel lifecycle onto the closed STADIUM_BEHAVIOUR_* set (MIGRATE/DELIVER/EXPIRE/COOL) — DELIVER and EXPIRE currently have dispatch cases in stadium.c but no consumer, and were apparently reserved for exactly this. Which tag maps to a message and which (if either) to a channel is implementation work for this item, not decided here. Also open: whether migrating message/channel heat into the Stadium's conserved 1.0 dissolves or changes HERMES.md's G8 note (HERMES-K/K-FLEET integration deferred pending cross-VM return values) — the reservoir mechanism (stadium_reservoir_pull/push) already crosses VM boundaries, so this may no longer be blocked the way G8 describes. Raise during implementation; do not resolve by assumption.

    Bug found and fixed during implementation, 2026-08-06 — amd64-only dictionary corruption, root cause was a missing -fno-pic, not Stadium logic. While exercising Hermes's migrated words in this item's self-test (kernel_main.c), MSG-COOL-ALL became unreachable via vm_find_word() immediately after MSG-DELIVER-ALL ran — amd64 only; aarch64 and riscv64 never showed it. Initial hypotheses (capsule-loader forward-reference retry interaction, GDB-perturbed timing, arena exhaustion, a stray dict_reorganize_buckets_by_heat() race) were each tested and ruled out by direct print-based bisection (GDB is unusable on this kernel — see below). Root cause, confirmed by disassembling the actual booted starkernel_loader.efi: dict_find_word_heat_aware() (dictionary_heat_optimization.c) and vm_find_word() (dictionary_management.c) both reference the same extern globals (sf_fc_list/sf_fc_count/sf_fc_cap, the dictionary's first-character lookup index), but GCC compiled the two files' references differently: vm_find_word(), in the same translation unit as the arrays' definition, got direct lea sym(%rip), %reg addressing; dict_find_word_heat_aware(), a genuine cross-TU extern reference, got GOT-indirect mov sym@GOTPCREL(%rip), %reg addressing (R_X86_64_REX_GOTPCRELX). The latter requires a populated Global Offset Table slot — normally a dynamic linker's job. This kernel is a freestanding, statically-linked UEFI PE image with no dynamic linker and no .got section; the "GOT slot" GCC emitted the reference against is just an ordinary zero-initialized .bss cell that nothing ever writes. The load silently returns NULL instead of the array's real address, vm_find_word()'s !bucket || n==0 guard reads it as "empty," and the word reports UNKNOWN WORD even though its DictEntry is fully intact (verified by a manual vm->latestlink chain walk). Whether a given reference gets the safe or unsafe addressing mode is a per-call-site GCC codegen heuristic sensitive to surrounding code size — which is why the symptom appeared and disappeared across unrelated one-line changes (even hitting an unrelated symbol, BIRTH's own dictionary entry, once), and why it looked for a long time like a timing-sensitive memory-corruption bug rather than a static codegen/build-flag one.

    Fix: Makefile.starkernel's amd64 ARCH_CFLAGS now appends -fno-pic -fno-pie, overriding COMMON_CFLAGS's -fPIC for amd64 only (GCC takes the last flag on the command line; ARCH_CFLAGS is appended after -fPIC in COMMON_CFLAGS's definition). amd64 is a fixed-base, statically-linked image with no dynamic-linker use for PIC in the first place, so this is a correctness fix, not a workaround. aarch64/riscv64 keep -fPIC — riscv64's loader link step (ld -shared -Bsymbolic) genuinely requires it and fails to link without it; aarch64 was never observed to hit this bug (different toolchain, clang+lld-link, different codegen heuristics). Also removed -DPLATFORM_TIME_NO_INLINE from COMMON_CFLAGS (and its now-redundant explanatory comment) — a prior one-off workaround for the identical bug class, applied specifically to sf_monotonic_ns()'s access to sf_time_backend, made unnecessary once amd64 got the real fix. Confirmed no regression on any architecture: all three still boot to ok> and pass the full self-test with the flag removed. shim.c/ physics_hotwords_cache.c's own local #define PLATFORM_TIME_NO_INLINE (their concrete, non-inline implementations of sf_monotonic_ns() etc.) were left as-is — out of scope for this fix, and harmless either way.

    Also fixed as a side effect, kept though not the active bug: elf_apply_relocations() (src/starkernel/boot/elf_loader.c) didn't handle R_X86_64_PC32/R_X86_64_PLT32 either, discovered while chasing an earlier (wrong) theory that this was a runtime ELF relocation bug. That code path turned out to be dead for this build — uefi_loader.c calls kernel_main() as a direct function call under MONOLITHIC_BUILD (the default here), never invoking elf_load_kernel()/elf_apply_relocations() at all; the actual boot image is a standard PE32+ UEFI application, relocated by OVMF's own PE loader, not by this custom ELF loader. The relocation-type gap is real for the non-monolithic split-build path though (elf_load_kernel() returns 0 — hard failure — on any unhandled type, and the loop aborts the rest of that RELA section on the first one hit), so the handling was kept as a legitimate robustness fix rather than reverted.

    Process note: GDB+QEMU is confirmed unusable for debugging this kernel — the custom UEFI loader relocates/loads the image such that static-symbol software breakpoints never fire, and a hardware breakpoint (hbreak) not only never fired but its mere presence caused a different, more severe corruption (BIRTH itself became UNKNOWN WORD) before any breakpoint triggered — likely a parity/dict-hash boot-gate reacting to the debugger session, not "timing perturbation" as first guessed. Print-based bisection (console_puts/print_uint, plus log_message(LOG_ERROR, ...)LOG_INFO is below the active log threshold and never appears in the serial log, a separate dead end closed along the way) is the only viable method for this kernel today.

    Blocker found 2026-08-06, ruled and fixed 2026-08-07 — item 4.1 and item 4.2 silently share one finite per-VM reservoir, and word-execution admission alone can exhaust it before any application-level allocation runs. This failed the K≡1.0 Done when bullet below and was not a code bug to just patch — it was a design question spanning both items, reported for a ruling rather than resolved unilaterally (§25.0 rule 3). Captain Bob ruled option 4 below (reserve a floor); implementation and result are at the end of this note.

    After the Q.SLOT admission-heat fix (below) closed the original MSG-SEND/CH-ACCEPT over-admission bug, HERMES-K still read 0 instead of 65536. Three prints in one boot discriminated the cause: stadium_reservoir_peek(Hermes) reads 65536 immediately after BIRTH (the one-time grant, item 4.1a, is fine) but is already 0 — and COMMON-CH's own heat is already 0 — immediately after CD-INIT finishes, before HERMES-MSG-TEST/MSG-DELIVER-ALL/anything else in the self-test runs. So this is not a Stadium cell getting silently reassigned out from under COMMON-CH after the fact (aliasing); COMMON-INIT's own CH-ALLOC call, partway through CD-INIT, never got funded in the first place.

    Root cause: stadium_word_dispatch() (item 4.1, stadium_words.c:111) pulls STADIUM_WORD_HEAT_QUANTUM (2048) from the dispatching VM's reservoir on every single word dispatch, not just the first time a word is admitted — the "already resident" branch (line 142) does h->heat += stadium_reservoir_pull(vm_id, STADIUM_WORD_HEAT_QUANTUM) unconditionally, every call. CD-INIT's own MSG-INIT-FREE/CH-INIT-FREE/ MBR-INIT-FREE loops alone dispatch several hundred words (32 + 16 + 64 iterations, each several words deep) before COMMON-INIT ever runs. At 2048 per dispatch, a VM's entire 65536 reservoir is exhausted by roughly 32 total word dispatches — trivially reached within CD-INIT's first loop, let alone the rest of Hermes's boot. The boot log's own promotions=145 figure (Hermes's dict-check diagnostics) makes this arithmetic visible directly: 145 × 2048 = 296,960, about 4.5× her entire conserved share, from word-execution tracking alone. This applies to any VM doing non-trivial work, not something specific to Hermes or to messages/channels — Hera's own reservoir has read 0 in every log this entire session, for the same reason, just never surfaced as a problem because nothing previously tried to spend Hera's reservoir on anything else.

    Options, no ranking, not decided here:

    1. Separate reservoirs per VM — one for word-execution tracking (item 4.1), one for application-level use (item 4.2 and whatever comes after it). Most invasive: splits stadium_quotas[slot].reservoir or the one-time grant itself, touches item 4.1's already-shipped design and its recorded DoE baseline.
    2. Exempt certain VMs from word-execution admission entirely — e.g., only Hera (or only VMs with no item-4.2-style application economy) get word-heat tracking. Requires a new per-VM-class distinction that doesn't exist today.
    3. Re-scope STADIUM_WORD_HEAT_QUANTUM — smaller, or charged per-unique-word instead of per-dispatch. Touches a Kconfig default that already feeds item 4.1's recorded DoE measurements; re-tuning it here could invalidate that baseline.
    4. Reserve a floor within the shared reservoir that word-execution admission cannot dip below, mirroring COMMON-CH's own Q.1/3 floor pattern but at the reservoir level instead of a single resident. New mechanism, not yet designed.

    Ruling, 2026-08-07: option 4. Implemented as word_dispatch_pull() (stadium_words.c), a static helper wrapping stadium_reservoir_pull() for stadium_word_dispatch()'s two call sites only (both the already-resident re-heat pull and the not-yet-resident starter-grant pull) — clamped so a pull never takes the reservoir below Q48_ONE / 3, the same "VM-COUNT=3 fair share" figure COMMON-CH's own floor already uses, not a new invented number. Application-level pulls (stadium_reservoir_pull() called directly, e.g. via STADIUM-RES-PULL) are untouched — only word-execution admission respects the ceiling on its own consumption. Verified: the eviction-credit demo now shows a real transfer (resident_sum 1612, reservoir +1612, exactly, when COMMON-CH is evicted) instead of the prior 00 no-op, and the Stadium's own conservation line closes exactly on every boot, every architecture: resident_sum=43691 reservoir=21845 sum=65536.

    This alone brought HERMES-K from 0 to 43002 — real, but not exact, because HERMES-K's formula (MSG-TOTAL-HEAT CH-TOTAL-HEAT + STADIUM-RES@ +) has no term for word-execution residents' heat, which the floor now deliberately leaves nonzero. Second ruling, same date: add that term. New accessor stadium_words_resident_heat(vm_id) (stadium_words.c) sums heat over only a VM's own word-execution residents (walking its word_slots map, not stadium_resident_sum()'s full ownership scan, which would double- count messages/channels already in MSG-TOTAL-HEAT/CH-TOTAL-HEAT), exposed as an eighth STADIUM-* primitive, STADIUM-WORD-HEAT ( -- heat ), same implicit-self discipline as the other seven. HERMES-K becomes MSG-TOTAL-HEAT CH-TOTAL-HEAT + STADIUM-RES@ + STADIUM-WORD-HEAT + ;. Confirmed on all three architectures: HERMES-K prints exactly 65536, K≡1.0, closing the item's headline invariant.

    Done when:

    • The eight STADIUM-* FORTH primitives exist, are kernel-only (not in the shared/ vendored word set), and are exercised by at least one Hermes word each.
    • stadium_owner[idx] is written correctly on both the free-list-pop and eviction-fallback paths in stadium_admit(), verified by a resident cell's evict-credit landing in the correct VM's reservoir with two VMs holding quotas (Hera + Hermes) — not just asserted from reading the code.
    • VM.stadium_vm_id (or equivalent name chosen at implementation time) exists under __STARKERNEL__, is set correctly at Hermes's birth, and all three vm_core.c stadium_word_dispatch() call sites pass it instead of the hardcoded vm_uuid_hera() — verified by a Hermes-dispatched word's heat landing in Hermes's own reservoir, not Hera's, with both VMs' conservation checks closing independently.
    • Hermes's message and channel lifecycle (MSG-ALLOC/MSG-FREE-NODE, CH-ALLOC/ CH-FREE-NODE) run entirely through Stadium admission/eviction — no parallel free list, no parallel heat field. Per §11, this is atomic: MSG-HEAT@/!, MSG-COOL-ONE, MSG-COOL-ALL, CH-HEAT@/!, CH-COOL-ALL, CH-TOTAL-HEAT, MSG-TOTAL-HEAT either come out in this same change or are rewritten to read/write the Stadium cell instead of a local field — never both mechanisms live at once. MBR-ALLOC/MBR-FREE-NODE ruled out of scope, 2026-08-07 — see below.

    Ruling, 2026-08-07: MBR-ALLOC/MBR-FREE-NODE stay on their own free list, not migrated onto the Stadium. This bullet originally named them alongside MSG-*/CH-*. Checked the actual record layout (capsules/hermes/init.4th): an MBR record has exactly two fields, MBR-NEXT@ (link) and MBR-VM@ (owning VM id) — a pure channel-membership relationship, no heat field, never had one. The bullet's own stated purpose is "no parallel free list, no parallel heat field" — for MBR, "no parallel heat field" is already true vacuously, since none exists to be parallel to. Forcing MBR records through stadium_admit()/stadium_evict() would mean inventing a heat/mass/behaviour for something structurally without either, spending Stadium cells and reservoir budget on records the item's actual design goal (a conserved, evictable-under-pressure heat economy) has no reason to govern — "does VM X belong to channel Y" is not a quantity that cools, competes for capacity, or needs eviction pressure. Their original inclusion in this bullet reads as a completeness gesture written before the field layout was checked, not a deliberate requirement. MBR-ALLOC/MBR-FREE-NODE's own free list (capsules/hermes/init.4th, unchanged this item) is correct as-is.

    • Blocks 41104113 (Artemis) are untouched, per HERMES.md's block-map lock. Any new or changed Hermes block is verified with mkcapsule --lint before commit, per experiments/bare_metal/README.md.
    • The POST suite (regression gate per §10) passes.
    • The effort number is recorded explicitly — per §10, "what Hermes costs is the multiplier for everything else." Report at minimum: wall-clock/session time spent, lines changed (FORTH + the eight-primitive C surface, split out), and file count touched, so 4.3/4.4 can be estimated from a real data point rather than guessed.
    • All three architectures boot to ok>/zuse)ok> with logs under logs/, and Hermes's own conservation check (K≡1.0 across messages + channels + reservoir) closes exactly, reported the same way item 4.1 reported resident_sum/reservoir/sum.

    Effort number, reported 2026-08-07:

    • Session time. This conversation's own boot-log timestamps span roughly 10 hours elapsed (logs/20260806-153504 through logs/20260807-013712), covering: the amd64 GOT-indirect-addressing corruption investigation and fix (unrelated to Stadium logic, committed separately as 0a7f144), the item-4.2 acceptance-status survey against this punch-list entry, the Q.SLOT admission-heat fix, the word-execution reservoir-floor fix, and the STADIUM-WORD-HEAT addition that closed K≡1.0. This does not include whatever time the original seven-primitive implementation and capsule migration (already in place when this session's survey began) cost in an earlier session — no visibility into that, not estimated rather than guessed.
    • Lines changed, split FORTH vs. C surface (git diff --stat, this session's contribution only — the pre-existing implementation's own diff is included since it was still uncommitted when measured, but its authorship/timing is the caveat above):
      • FORTH (capsules/hermes/init.4th): +116 / 45 (161 changed), 1 file.
      • C, the eight-primitive STADIUM-* surface + Stadium core (mama_forth_words.c, stadium.c, stadium_words.c, stadium.h, stadium_words.h, vm.h): +511 / 69 (580 changed), 6 files.
      • C, other wiring (capsule_birth.c, sk_vm_bootstrap.c, vm_core.c, dictionary_management.c): +13 / 6 (19 changed), 4 files.
      • Self-test scaffolding (kernel_main.c, diagnostic-only, not production code): +119 / 0, 1 file.
      • Total: 12 implementation files, +759 / 120 (879 lines changed).
    • File count: 12 implementation files (13 including this write-up in FABRIC.md itself).
  • 4.3 — Console. Settles 1.11 as part of the work. Refs: §17.5.

    Note, 2026-08-05: Captain Bob wants a discussion before any work starts on this item. Do not begin 4.3 on an unblock-and-go basis the way 4.1 was — raise it and wait.

    Discussion held 2026-08-07. .claude/CONSOLE.md is a rough prior working draft, superseded — not edited further, not treated as authoritative. Console's design lives in this document from here on. First slice broken out below as 4.3.14.3.4. Explicitly out of scope for all four: raster image (PNG/JPEG) rendering as fabric backgrounds — real direction, raised 2026-08-07, deliberately deferred past this slice; also fonts, scrolling, cursor/VT100 semantics, the Hermes message protocol, and Console as a fleet VM under Hera's birth protocol — later 4.3.x items, scoped once this slice is reviewed.

  • 4.3.1 — Framebuffer sanity: draw a test pattern. Confirm framebuffer.c is wired to the real UEFI GOP BootInfo and draw a simple orientation-revealing test pattern, using the existing raw pixel primitives only — no coordinate/Z machinery yet. Refs: §27.1.

    Done, 2026-08-07. fb_draw_orientation_test() added to framebuffer.c/.h — fills the four raster corners RED/GREEN/BLUE/YELLOW via fb_fill_rect only. Wired into kernel_main.c calling fb_init() directly; console_fb_init()/vt100_init() removed from the boot path per Captain Bob's direction (vt100.c/console.c are obsolete, superseded by the fabric redesign, not to be exercised even incidentally).

    Bug found and fixed, not scope creep — the diagnostic did its job. First screendump (via 4.3.2) showed a clean R↔B channel swap (G correct, R and B corners exchanged) — spatial placement was correct, so this ruled out flip/rotation but caught a real color bug: framebuffer.c's pack_pixel() had its FB_PIXEL_RGBX32/FB_PIXEL_BGRX32 branches swapped relative to UEFI GOP's own byte-order naming convention (pre-existing bug, not introduced this item). Fixed by swapping the two pack_pixel return bodies to match framebuffer.h's already-correct doc comments; kernel_main.c's GOP-format switch needed no change. Re-verified via a second screendump: all four corners render correctly (fb/qemu-screenshot-20260807-113612.png).

    Not addressed, not in scope: the pre-existing UEFI loader boot-log text remains visible behind the corner blocks, since this diagnostic paints four small rectangles and does not clear the framebuffer — expected, not a bug.

  • 4.3.2 — QEMU screenshot capability. Add a monitor/QMP socket to the qemu targets (mirroring the existing serial-socket pattern) so screendump can be issued and the 4.3.1 test pattern actually inspected. None exists today — all three targets currently run with -display none and no monitor attached. Refs: §27.2.

    Done, 2026-08-07 — mechanism already existed, didn't need building. scripts/qemu_screenshot.sh was already a complete, working amd64 screendump path (monitor UNIX socket + socat + HMP screendump), just not wired into any Makefile.starkernel target and not previously exercised this session — 34 prior screenshots already sat in logs/ from earlier use. Changed: output PNG now goes to a new top-level fb/ directory (tracked in git, per Captain Bob — not logs/, not a gitignored temp dir); added a python3+PIL fallback for PPM→PNG conversion since imagemagick isn't installed on this machine. Left as a standalone script, not wired into a Makefile target, per direction — run directly for now. aarch64/riscv64 not covered by this script; not needed for 4.3.1's amd64-only diagnostic.

    Reversed same day. fb/ is now gitignored after all (Captain Bob: these are throwaway local verification images, never meant to be committed). Screenshot paths cited in this document's other acceptance notes (4.3.1, 4.3.3, 4.3.3b, 4.3.4) still describe what was actually seen at the time, but those files are local-only now, not retrievable from git history in their originally-committed form — the commits that added them are still in history, just superseded by the untrack commit that follows.

  • 4.3.3 — Cartesian coordinate machinery. Origin bottom-left (0, 0), Y-up, plus a new Z axis (depth-into-screen, not height) and a fixed orthographic projection as a placeholder — not the final projection, no perspective/camera work yet. Angle settled 2026-08-07: true 45° cavalier. New C primitives PLOT ( x y color -- ), FB-WIDTH, FB-HEIGHT (raw hardware boundary, no Cartesian awareness); new FORTH capsule capsules/fabric.4th (blocks 4900+) for PROJECT/CART-Y/CART-PLOT, per the compose-in-FORTH-first rule — the transform is policy, not hardware access. Refs: §27.3.

    Done, 2026-08-07. register_framebuffer_words() (Module 28) adds PLOT/FB-WIDTH/ FB-HEIGHT — kernel-only, no-op on hosted builds, same pattern as every other module. capsules/fabric.4th (blocks 49004902, lint-clean per mkcapsule --lint) defines COS45/Z->DELTA/PROJECT/CART-Y/CART-PLOT.

    A second real bug found and fixed, not scope creep. Live-tested CART-PLOT over the serial socket (same injection technique the DoE machinery uses) and hit a silent ERROR on the capsule's own VARIABLE ZD, while an identical VARIABLE typed live at the REPL worked fine. Traced to defining_word_variable() (defining_words.c:471): it captures vm->here as the variable's address with no alignment call first, and vm_load_cell/ vm_store_cell require 8-byte-aligned addresses. ZD landed at 945 (misaligned) purely because of what preceded it in the capsule; TESTV/ZD2 defined live happened to land on aligned addresses by luck. This is a real deviation from FORTH-83/ANS, which specifies VARIABLE reserves an aligned cell. Fixed with one line (vm_align(vm) before capturing addr) — ALIGN already existed as a word (dictionary_words.c) but VARIABLE wasn't calling it. Fixes every VARIABLE in the system, not just this capsule's — other capsules (doe.4th, init-4.4th) were landing aligned by luck, not by guarantee. Both hosted and kernel builds recompiled clean after the fix.

    Verified end-to-end, amd64, via the same manual-injection + screendump technique: plotted 4 marker points (origin, +100 X, +100 Y, +50 Z; a 3×3 cluster each for visibility) and confirmed all four landed at hand-calculated raster coordinates — including the diagonal up-right shift for the Z-axis point, confirming the 45° cavalier projection math is correct, not just non-crashing. Screenshot: fb/fabric-test-cart-plot.png. 4.3.1's corner diagnostic still renders correctly in the same shot — no regression.

    Not wired into init.4th's boot chain, per plan — Console isn't a fleet VM yet.

  • 4.3.3a — Q48.16 trigonometry. Q.SIN/Q.COS (radian input) added to q48_16.c, Taylor series after range-reducing into [-π, π] — same pattern as this file's existing Q.LOG/Q.EXP/Q.SQRT, not a new precedent. Raised 2026-08-07 while scoping 4.3.3: needed by 4.3.3b, does not exist anywhere in this codebase today (checked). Refs: §27.3.

    Done, 2026-08-07. q48_reduce_angle() (single integer division on the raw Q48.16 representations to strip full 2·PI_Q48 turns, then a bounded fix-up loop) plus q48_sin_approx/q48_cos_approx (Taylor series, terms n=3,5,7,9,11 / n=2,4,6,8,10, early exit below 10). PI_Q48 = 205887; TWO_PI_Q48 is derived as 2·PI_Q48 rather than independently rounded, so the ±π reduction boundary has no seam.

    A real duplication, not previously flagged: this codebase has two independent Q48.16 implementations — src/word_source/q48_16_words.c (vendored/hosted) and src/starkernel/math/q48_16.c (kernel-only; the kernel build does not compile the former at all). Found the hard way — the hosted build linked fine, the kernel build failed with undefined reference to q48_sin_approx until the same two functions were added to both files (plus both q48_16.h headers — include/q48_16.h and include/starkernel/q48_16.h, which also declare the same functions independently). Not fixed at the root (de-duplicating the two implementations is a much larger change than this item), just navigated correctly — Q.LOG/Q.EXP/Q.SQRT already had this same four-file duplication, unremarked until now.

    Verified live on amd64 via serial injection: sin(0)=0, cos(0)=65536 (exact), sin(π/2)=65536, cos(π/2)=0 (exact), sin(-π/2)=-65536 (exact, confirms the odd- function sign handling), sin(π)≈-27 (residual from PI_Q48 rounding, ~0.04%), cos(π)≈-65656 (Taylor truncation near the interval edge, ~0.18%), and sin(3π) reduces to the same -27 as sin(π), confirming range reduction across multiple turns. Both hosted and kernel (amd64) builds clean.

  • 4.3.3b — Geometry drawing primitive wordset. LINE, CIRCLE, ARC, ELLIPSE in capsules/fabric.4th, built on 4.3.3's PLOT/CART-PLOT and 4.3.3a's Q.SIN/Q.COS. Raised 2026-08-07. Q48.16 throughout; resolution-agnostic (48 integer bits comfortably covers 1080p and well beyond — no hardcoded viewport assumptions). Refs: §27.3.

    Done, 2026-08-07. Blocks 49034912. TO-RASTER factored out of CART-PLOT (same behavior, not a change) so LINE can project both endpoints once and Bresenham the straight line between them in raster space — valid because the cavalier projection is linear, so projecting endpoints and interpolating is equivalent to projecting every point along the line. LINE itself split across three helper words (LINE-SETUP, LINE-DONE?/LINE-STUCK?, LINE-STEP) — discovered mid-implementation that colon definitions cannot span block boundaries in this capsule loader (verified with a throwaway test capsule: the continuation lands in a [CAPSULE][DEFER] path that never resolves and errors out), so anything too long for one 16-line/64-char block has to be factored into separate, block-local word definitions instead. CIRCLE/ELLIPSE are 36-segment polygon approximations (LINE calls between consecutive Q.SIN/Q.COS points); ARC is the same at 18 segments over a caller-supplied [a0, a1] radian range.

    A fourth real bug, this one serious — found, fixed, verified with the recommended fix applied both times. CIRCLE's first live test rendered only its first quadrant, then a follow-up test call hung the VM for several minutes before being killed. Root cause: q48_to_u64() (include/q48_16.h and include/starkernel/q48_16.h, backing Q.TO-INT) did q >> 16 as an unsigned logical shift. For any negative q48_16_t — inevitable once Q.SIN/Q.COS leave the first quadrant — this produces a huge garbage integer instead of sign-extending. That garbage became a bogus LINE target, and LINE-STEP's Bresenham loop had no bound, so it churned for a very long time trying to converge on a point that was effectively unreachable. Fixed by shifting through a signed int64_t intermediate (bit-identical output for the non-negative case, which is all the inference engine's own caller ever produces). Independently, added LINE-STUCK? (LSTEPS counter vs. FB-WIDTH + FB-HEIGHT, the true worst case for any on-screen line) as a defense-in-depth cap, so a future bad target degrades to "stops drawing" rather than hanging the VM again.

    Verified live on amd64, fresh boot after both fixes: -65536 Q.TO-INT . now prints -1. LINE, CIRCLE, ARC (semicircle, 0 to π), and ELLIPSE all completed without hanging or erroring, and a combined screendump shows all four rendering correctly and distinctly — full circle, correct upper-half arc, properly proportioned ellipse (wider than tall, matching unequal radii), and the earlier diagonal LINE test. fb/amd64/geom-test-circle-fixed.png, fb/amd64/geom-test-full-wordset.png.

  • 4.3.4 — Checkpoint: draw a cube. First real exercise of the 4.3.3/4.3.3a/4.3.3b coordinate/projection/geometry machinery — cube edges use LINE. Stop and review here before scoping the next 4.3.x item — not expected to be fast. Refs: §27.4.

    Done, 2026-08-07. Block 49134915. Vertices are bit-coded: VERT ( n -- x y z ) reads bits 0/1/2 of n as the sign of the X/Y/Z offset from center (±CS), so all 8 corners come from one word instead of 8 hand-written coordinate triples. EDGE ( n1 n2 color -- ) resolves both corners via VERT and calls LINE. CUBE ( cx cy cz s color -- ) is 12 EDGE calls — 4 bottom, 4 top, 4 vertical — grouped by face for readability, not because the grouping means anything to the machinery.

    No new bugs this item — first time in the 4.3.3.x sequence that's been true, which is itself a small signal that Q.TO-INT/the VARIABLE alignment fix/the LINE-STUCK? cap were the real gaps, not something still lurking in LINE/PROJECT/CART-Y.

    Verified live on amd64: 640 400 0 100 16777215 CUBE (white, half-size 100, centered mid-screen) completed cleanly, no errors, no LINE-STUCK? trips. Screendump (fb/amd64/cube-4.3.4.png) shows a correct wireframe cube — front face square, back face square offset diagonally up-right by exactly the 45° cavalier projection's depth term, all 12 edges connecting at the right corners, no crossed or broken lines.

    Correction, same day: the first pass above was amd64-only, and that wasn't flagged. Asked for cube screenshots on all three ISAs surfaced that aarch64 and riscv64 had no framebuffer device at all — both booted with GOP: protocol not found. The "all three architectures boot clean" acceptance checks run all session were REPL/dict_hash parity, a different axis entirely from GOP/framebuffer presence; conflating the two was an error. Fixed by adding -device ramfb (EDK2's firmware-only GOP framebuffer, no guest driver needed) to both architectures' qemu/qemu-esp targets in Makefile.starkernel. Both now report GOP: linear framebuffer found at 800×600 (vs. amd64's 1280×800 via q35's implicit default device — no code assumes a fixed resolution, so this required no changes elsewhere). Re-ran the same cube test on both: fb/aarch64/cube-4.3.4.png and fb/riscv64/cube-4.3.4.png, both correct. Re-ran the standard three-arch acceptance boot afterward — all still clean, dict_hash identical across all three and unchanged from before this fix (a QEMU device flag, not a kernel change).

    This is the checkpoint — 4.3.x groundwork stops here for review per this item's own acceptance criterion, before scoping whatever comes next.

  • 4.3.5 — amd64: I/O APIC bring-up + i8042 keyboard, interrupt-driven. No I/O APIC driver exists in this tree today (checked: apic.c is Local-APIC-only, and the timer needs no routing because it self-interrupts) and pic_disable() masks the legacy 8259 permanently — so no legacy IRQ, IRQ1 included, currently has any path to the CPU. This item stands up a minimal I/O APIC driver (MMIO base, redirection table entry for IRQ1 targeting a chosen vector and the boot CPU's LAPIC ID), programs the i8042 controller for interrupt mode, and installs an ISR that reads the scancode from port 0x60 and issues apic_eoi(). No polling of the i8042 status port (0x64) anywhere in this path — that is the whole point of doing this before the REPL keyboard work, not after. Done when: a keypress in QEMU on amd64 produces a captured scancode via the interrupt path with no polling loop in the code, three-arch QEMU boot unaffected, log committed. Refs: §27.5.

    Done, 2026-08-08. New src/starkernel/arch/amd64/ioapic.c/include/starkernel/ ioapic.h: parses the real ACPI MADT (RSDP → XSDT → APIC table, same walk pci.c already uses for MCFG, not shared code but the same technique) for the I/O APIC's MMIO base and any Interrupt Source Override entries — nothing hardcoded. New i8042.c/ i8042.h: minimal controller init, a trivial ISR (read 0x60, push to a small ring buffer, nothing else), and i8042_pop_scancode() for non-interrupt-context draining. New diagnostic word KBD-SCAN ( -- c -1 | 0 ) in src/word_source/keyboard_words.c (raw hardware-boundary primitive, same relationship to future Console policy that PLOT has to capsules/fabric.4th) plus a standing KBD-DEBUG ( -- isr_count spurious_count ) diagnostic. apic_id() added to apic.h/apic.c (LAPIC ID getter, needed for the redirection entry's destination field).

    Three real bugs found and fixed, all blocking this item's own acceptance test, not scope creep:

    1. LAPIC spurious-vector interrupts (0xFF) were previously fatal. pic_disable() means the I/O APIC is the first real external-interrupt source this kernel has ever driven — the timer self-interrupts and never needed one. The very first live keypress test crashed with Vector : 255 (0xff) / "Fault: Unhandled vector". Per Intel SDM Vol.3 §10.9, a spurious-vector interrupt is a normal occasional hardware race, not a fault, and must be silently ignored with no EOI. Fixed with a dedicated case in isr_common_handler() (interrupts.c) plus a permanent g_spurious_count diagnostic counter — cheap enough to keep, and this item is exactly why it's worth having.

    2. OVMF's own PS/2 driver leaves the keyboard device (not just the controller) with scanning disabled. After the crash was fixed, KBD-SCAN still came back empty on every keypress. QEMU's own PS/2 tracepoints (-trace enable='ps2_*,pckbd_*') showed the device correctly generating and queuing the real make/break scancode sequence on sendkey, so the device model itself was never the problem. i8042_init() only programmed the controller's config byte (IRQ1-enable); it never told the device to resume scanning. Fixed by sending 0xF4 (enable scanning) to the device via the data port after the controller config write, with a bounded wait for the 0xFA ACK — a one-time init handshake, not the forbidden steady-state polling. (Ancillary, reported not fixed: the controller's IRQ1-enable bit was already set before this kernel ever touched it on this QEMU/OVMF combination, per the trace — that write is a no-op here but stays correct for hardware that doesn't pre-enable it.)

    3. The real bug, found via -d int + a live LAPIC ISR/PPR register dump: isr.S's stub table only ever had individually-numbered stubs for vectors 032. Vectors 33255 all shared isr_stub_default, which unconditionally pushes 255 as "the vector" regardless of which IDT slot actually fired — nothing before this item had ever needed a real interrupt source above vector 32. The I/O APIC correctly delivered IRQ1 to vector 33 (0x21) — confirmed directly by reading the LAPIC's own ISR register at the moment of the spurious firing, which showed vector 33 genuinely in-service — but isr_common_handler() only ever saw vector 255, treated a real keyboard IRQ as spurious, and (correctly, per finding 1) skipped its EOI, permanently stranding vector 33's ISR bit and silently blocking that interrupt class forever after. A priority-class theory (moving the keyboard vector to 0x31, a different class from the timer's 0x20) was tested and ruled out first — the direct ISR-register readback is what actually found it. Fixed by adding a dedicated isr_stub33 (same ISR_NOERR macro pattern as every other named stub) and pointing isr_stub_table[33] at it instead of the shared default.

    Verified live on amd64 via QEMU's HMP sendkey monitor command against a real interactive serial session (same manual-injection technique used throughout 4.3.x): sendkey aKBD-SCAN .S showed <2> 30 -1 (30 = 0x1E, the correct XT Set-1 make code for 'A'); sendkey b → two KBD-SCAN calls showed 48 -1 then 176 -1 (0x30 make / 0x30|0x80 break for 'B', confirming the release-code bit-7 pattern). KBD-DEBUG read 2 0 (two real IRQs serviced, zero spurious) after each keypress. Three-architecture acceptance boot clean on all three (logs/20260808-001508 amd64, logs/20260808-001546 aarch64, logs/20260808-001637 riscv64) — the feature is amd64-only this item, so aarch64/riscv64 are a regression check, not a keyboard test.

  • 4.3.5a — riscv64: minimal paging/addressing bring-up (prerequisite, blocks 4.3.5b). Discovered live 2026-08-08 while implementing 4.3.5b, not anticipated when 4.3.5a-4.3.5f were originally scoped. riscv64 has no software-controlled paging at all. arch/riscv64/arch.c's own comment is explicit: "Full Sv39/Sv48 page-table setup (SATP, PMP, etc.) is deferred to a later milestone" — it is a stub, satp is never written by this kernel. Separately, memory/vmm.c (header comment: "4-level paging Virtual Memory Manager (x86_64)") runs its vmm_init() unconditionally on all three architectures and builds a real x86-64-style page table in memory, but its activation step (load_cr3()) compiles to a no-op outside __x86_64__ — so on riscv64 that table is dead, unused data. Whatever satp firmware (EDK2 RISC-V) left active at ExitBootServices() is what's live for the kernel's entire lifetime, and there is no documented or safe way for us to add entries to firmware's own page table post-handoff. This surfaced because 4.3.5b's PLIC threshold register (PLIC_BASE + 0x201000, deep into the PLIC's 0x600000-byte MMIO window) is not covered by firmware's mapping — confirmed via a live store-page-fault (scause=0xF) at exactly that address, not inferred. Not fixed as part of surfacing it — Captain Bob's ruling 2026-08-08: stop, scope as its own item, decide the approach (Bare-mode satp=0 vs. real Sv39 bring-up) in a future session with fresh context, rather than deciding it embedded inside a PLIC item. 4.3.5b's driver code (plic.c/plic.h, interrupts.c/apic.c wiring, Makefile.starkernel) exists, written and reviewed, but uncommitted — it cannot be verified until this item unblocks it, and 4.3.5b's own acceptance ("three-arch QEMU boot unaffected") is currently violated (riscv64 hangs in the fatal exception handler). Done when: riscv64 has some working address-translation story (either genuinely activated Sv39 paging with a kernel-owned page table, or an explicit, deliberate switch to Bare mode) sufficient for plic_init()'s existing MMIO writes to succeed without faulting, three-arch QEMU boot clean, log committed. Refs: §27.5.

    Done, 2026-08-08. Chose Bare mode over Sv39 bring-up: this kernel builds no riscv64 page table of its own (vmm.c's is x86-64-shaped and never activated here) and has no present use for virtual memory on this ISA, so there was no reason to build one just to patch the one confirmed hole. Verified live before touching satp, not assumed: added a read-only diagnostic to arch_early_init() (arch/riscv64/arch.c, already called at kernel_main.c:401, ahead of both pmm_init() and vmm_init()) printing satp's MODE/PPN and __kernel_start's address. Result — satp.MODE = 0xa: firmware (EDK2 RISC-V) leaves Sv57 active at kernel entry, not Sv39/Sv48 as this file's own stub comment assumed (that comment was stale; corrected in the same edit). __kernel_start (0xbdd56982) landed inside the UEFI-reported total physical RAM window (0x800000000x80000000+1020MB), near the top past the PMM-free region — consistent with the kernel's own running range being identity-mapped, the load-bearing assumption for the fix's safety. Fix: csrw satp, x0 + sfence.vma in one asm volatile block (RISC-V Privileged Spec §4.2.1 ordering — must not be separated by a compiler-scheduled memory access), placed in arch_early_init() right after the diagnostic prints, which are now kept as a permanent boot record rather than reverted.

    Two-boot verification, per §25.0 rule 6 (acceptance is not "should work"): first a diagnostic-only riscv64 boot confirmed the satp/identity-mapping facts above (logs/20260808-101931/riscv64/, still hits the original scause=0xF fault since the fix wasn't written yet); then, with the fix added, a second riscv64 boot confirmed plic_init()'s PLIC_THRESHOLD write at 0x0c201000 now succeeds with zero exceptions (logs/20260808-110152/riscv64/) — this is the already-written, still-uncommitted 4.3.5b PLIC driver code in the working tree, exercised live but not part of this commit. Three-architecture acceptance boot clean, zero exceptions on any: amd64 (logs/20260808-110252/amd64/), aarch64 (logs/20260808-110345/aarch64/), riscv64 (logs/20260808-110500/riscv64/, PLIC: init line present, no fault).

    Reported, not fixed (§25.0 rule 3): arch_mmu_init() is declared and defined on all three architectures but kernel_main.c never calls it anywhere — dead code tree-wide, pre-existing, unrelated to today's fix (which lives in arch_early_init() instead, specifically to avoid routing a riscv64-only change through a shared call site that would newly execute on amd64/aarch64 too). Also: the uncommitted 4.3.5b driver code's own comments still label itself "item 4.3.5a" — stale from before this item was inserted ahead of it; needs a rename to "4.3.5b" before that commit lands, not this one's job.

  • 4.3.5b — riscv64: PLIC bring-up (external interrupt controller). Does not exist anywhere in this tree — Phase 0 (0.2/0.3) only ever enabled the S-mode timer interrupt (sie.STIE); external interrupts (sie.SEIE, bit 9) were never touched, and interrupts.c's own comment ("the PLIC replaces both") describes the architecture, not anything implemented. This item enables sie.SEIE, programs the PLIC's per-source priority, the hart context's threshold and enable bits, and wires the trap handler's external-interrupt case to PLIC claim → dispatch → PLIC complete. Prerequisite for 4.3.5c; no keyboard code in this item. Blocked on 4.3.5a (see that item) — driver code already written, not yet verifiable. Done when: a synthetic/known external interrupt source claims and completes correctly through the PLIC on riscv64, three-arch QEMU boot unaffected, log committed. Refs: §27.5.

    Done, 2026-08-08. Unblocked by 4.3.5a. The driver code itself (plic.c/plic.h, apic.c/interrupts.c wiring) predates this item's own insertion point in the sequence — its comments still said "item 4.3.5a"; corrected to "4.3.5b" (and the two forward-references to "a future consumer, 4.3.5b" corrected to "4.3.5c", since 4.3.5b is this item, not the consumer) as part of this commit. PLIC itself has no software set-pending register (SiFive PLIC-1.0.0, unlike GICv2's ISPENDR — pending bits are hardware-line-driven only), so "synthetic/known" here meant a real, already-present hardware source: the board's UART (PLIC source 10, per plic.c's own DTB-verified header comment), driven through its standard NS16550 loopback mode (UART_MCR_LOOP, register offsets/bits confirmed against this build host's /usr/include/linux/serial_reg.h, not recalled) so TX loops back to RX internally — self-contained, deterministic, no external host-side synchronization, and no race against repl.c's later console_getc() polling since the whole test ran and fully restored UART state before arch_enable_interrupts()'s one real, permanent enable (kernel_main.c:664).

    Verified live, not assumed: pre-test MCR read back as 0x03, not 0 — confirms reading and restoring actual state mattered rather than assuming firmware left it clear. Result: claim_count=1, last_irq=0xa (10, the UART), iir=0xc4 (masks to 0x04UART_IIR_RDI, confirming the genuine cause was receive-data, not a fluke or a different source), byte=0x55 (exact match to the byte written) — PASS (logs/20260808-111711/riscv64/). Per Captain Bob's ruling 2026-08-08, the self-test itself (the loopback toggle, the temporary g_plic_last_irq/IIR/byte-capture globals, and its call site in apic_init()) was written, run once to capture this evidence, then fully reverted — same treatment as the aarch64 GICD_ITARGETSR probe (item 4.3.5d's note). Only the permanent substrate remains: plic_init() (threshold=0, sie.SEIE enabled, no source enabled — apic.c's own comment already stated that boundary) and the standing g_plic_claim_count diagnostic that predates this item. Three-architecture acceptance boot clean, zero exceptions on any, in this final reverted state: amd64 (logs/20260808-112015/amd64/), aarch64 (logs/20260808-112108/aarch64/), riscv64 (logs/20260808-112235/riscv64/, PLIC: init line present, no fault).

  • 4.3.5c — riscv64: virtio-keyboard-pci, interrupt-driven. New driver under src/starkernel/virtio/ for the virtio-input device class — decode EV_KEY events off the input event virtqueue. Unlike virtio_blk.c's synchronous poll-the-used-ring pattern, this device must be interrupt-driven end to end: the used-ring notification arrives via the PLIC path from 4.3.5b, not a poll loop. Built on 4.3.5b.

    Amended 2026-08-08 — transport was wrong. Originally scoped as "MMIO virtio, matching the existing MMIO virtio-blk pattern on this board" — checked, not assumed, and that pattern doesn't exist: the Makefile's own riscv64 qemu target comment says "virtio-blk-pci GPT disk" and uses -device virtio-blk-pci,...,addr=0x1/addr=0x2 — PCI transport, not MMIO. virtio-mmio/VIRTIO_MMIO appear nowhere in this tree. pci.c already supports riscv64 via ECAM with its own fallback base (g_ecam_base = 0x30000000ULL). Corrected to PCI transport (virtio-keyboard-pci) — the same device class 4.3.5e uses on aarch64, which means this item and 4.3.5e now share most of the actual virtio-input driver logic (capability walk, feature negotiation, event-queue handling, ISR-status read), differing only in interrupt routing (PLIC vs. GIC).

    Concrete steps:

    1. Add -device virtio-keyboard-pci,addr=0x3 to the riscv64 qemu/qemu-esp targets in Makefile.starkernel — explicit addr=, matching this target's existing precedent for the two virtio-blk-pci drives (addr=0x1/addr=0x2), so the PCI slot (and therefore the PLIC source, see below) is deterministic rather than left to QEMU's auto-assignment.
    2. New include/starkernel/virtio_input.h / src/starkernel/virtio/virtio_input.c — same device identity, event structure, and design questions as 4.3.5e's own plan (vendor 0x1AF4, device ID 0x1052, struct virtio_input_event, EV_KEY = 0x01, all from this build host's /usr/include/linux/virtio_ids.h/virtio_input.h/ input-event-codes.h). Resolved here, carries forward to 4.3.5e: don't refactor virtio_blk.c's static walk_virtio_caps() into shared code — duplicate a small version into virtio_input.c instead. Keeps this item's diff to new files only and leaves the working, tested virtio_blk.c untouched.
    3. Compute the target PLIC source at runtime: pin = pci_read8(dev, PCI_CFG_INT_PIN) (needs the same new PCI_CFG_INT_PIN = 0x3D constant 4.3.5d adds to pci.h — add it here if 4.3.5d hasn't landed first), slot = dev->device, source = 32 + ((slot + pin - 1) % 4) → PLIC sources 3235. Derived live from this host's QEMU 10.2.1 riscv64 virt DTB, not recalled — full decode in §27.5.2. plic_set_priority()/ plic_enable() take this source directly (PLIC's interrupt specifier is one bare cell, no separate type/flags field the way GIC's SPI encoding needs).
    4. Same pci_enable() interrupt-disable-bit gap 4.3.5e flags (pci.c:363 never clears PCI COMMAND bit 10) — check it here too, first, since this item lands before 4.3.5e.
    5. Same mandatory ISR-status read as 4.3.5e (VIRTIO_PCI_CAP_ISR_CFG, level-triggered per the decoded interrupt-map — see §27.5.2) — skipping it leaves the PLIC source's pending condition latched.
    6. Wire riscv64_interrupt_handler()'s SCAUSE_S_EXTERNAL branch (interrupts.c) to dispatch the computed PLIC source to the new driver's ISR, extending the generic claim/complete dispatch 4.3.5b built — same shape as i8042's branch in amd64's isr_common_handler(), per that dispatch function's own doc comment.

    Done when: a keypress in QEMU on riscv64 (via sendkey, same manual-injection technique as 4.3.5's amd64 verification) produces a captured EV_KEY event through the interrupt path with no polling loop anywhere in the path, three-arch QEMU boot unaffected, log committed. Refs: §27.5.

    Done, 2026-08-08. New include/starkernel/virtio_input.h / src/starkernel/virtio/ virtio_input.c: capability walk duplicated from virtio_blk.c (per the resolved design question above), status/feature negotiation mirroring vblk_init_device(), eventq (queue 0) with EVENTQ_SIZE=8 pre-posted device-writable buffers re-posted after each drain — no synchronous wait anywhere, unlike vblk_io(). enable_interrupt_route() is arch-guarded (#if defined(ARCH_RISCV64) computes and enables the PLIC source per §27.5.2's formula; the #else path reports rather than silently no-ops, so a future arch using this file without adding its own routing fails loudly instead of a permanently- pending, never-enabled source). PCI_CFG_INT_PIN (0x3D) and PCI_CMD_INTX_DISABLE (bit 10) added to pci.h — the latter checked and cleared explicitly, since pci_enable() never touches it. virtio_input_isr() reads VIRTIO_PCI_CAP_ISR_CFG before draining (mandatory, not optional — the routed source is level-triggered). arch/riscv64/interrupts.c's SCAUSE_S_EXTERNAL branch now dispatches to it when the PLIC claim matches g_virtio_input_plic_source, the first real per-source consumer of 4.3.5b's substrate. kernel_main.c calls virtio_input_find_keyboard() unconditionally right after the Artemis virtio_blk_find_artemis() call, same pattern.

    New FORTH words in src/word_source/keyboard_words.c (extending the same file 4.3.5's KBD-SCAN/KBD-DEBUG already use, riscv64-branch added alongside the existing amd64 one, no-op elsewhere): VKBD-EVENT ( -- code value -1 | 0 ) pops one decoded EV_KEY event off the interrupt-fed ring; VKBD-DEBUG ( -- isr_count ) is the standing diagnostic. Distinct words from KBD-SCAN/KBD-DEBUG rather than shared ones — different device, different event shape; convergence is 4.3.5f's explicit job, not this item's.

    Verified live with a real keypress, not synthetic: QEMU HMP sendkey a (temporary monitor socket added to the Makefile for this test, reverted after — same treatment as every other one-shot verification harness this session) while a second connection typed VKBD-EVENT . . . CR VKBD-DEBUG . CR over the serial socket. Result: -1 1 30 (flag, value, code — code=30 is KEY_A exactly, value=1 is a press, per this build host's /usr/include/linux/input-event-codes.h) then 2 (VKBD-DEBUG, confirming two real interrupts serviced, not a poll artifact). Zero exceptions (logs/20260808-114907/riscv64/qemu-riscv64-20260808-114907-sendkey-verify.log).

    Three-architecture acceptance boot clean (standard make qemu, no monitor socket, no regression from the new dispatch/driver code): amd64 (logs/20260808-114435/amd64/), aarch64 (logs/20260808-114603/aarch64/), riscv64 (logs/20260808-114720/riscv64/, virtio-input: found device / driver ready present, no fault).

  • 4.3.5d — aarch64: GIC SPI wiring for virtio-input. Item 0.6 scoped the GIC to "one interrupt" (the timer PPI) on purpose and explicitly called a general GIC driver out of scope. A PCI-attached virtio-keyboard-pci device signals via legacy INTx, which is an SPI, not a PPI — a different GIC distributor path (SPI target-CPU and priority registers) that today's minimal init never touches. This item extends the GIC init from 0.6 just far enough to enable and route one SPI, still not a general driver — scope stays as narrow as 0.6's did.

    Amended 2026-08-08 — original acceptance was circular. "Take and acknowledge an SPI from the virtio-input PCI device" can't close as its own commit: that device doesn't exist until 4.3.5e, so this item could never be verified in isolation, breaking §25.0's one-item-one-commit rule. Fixed the same way 4.3.5b already fixed the identical problem for the PLIC — verify against a synthetic/known interrupt, not the real device.

    Concrete steps:

    1. Add PCI_CFG_INT_PIN (offset 0x3D, byte — the Interrupt Pin register) to include/starkernel/pci.h, alongside the existing PCI_CFG_INT_LINE (0x3C).
    2. Compute the target SPI at runtime, not a hardcoded constant: pin = pci_read8(dev, PCI_CFG_INT_PIN) (1=INTA…4=INTD), slot = dev->device (already populated by pci_find_first()), spi = 3 + ((slot + pin - 1) % 4) → INTID 32 + spi. Derived live from this host's QEMU 10.2.1 virt DTB, not recalled — full decode and the table it produced are in §27.5.
    3. Program, for the computed intid: GICD_IPRIORITYR at 0x400 + intid (value 0x80, matching TIMER_PRIORITY); GICD_ISENABLER at 0x100 + 4*(intid/32) (0x104 for every candidate INTID 3538), bit intid % 32; GICD_ITARGETSR at 0x800 + intid (0x8230x826), value 0x01 (single vCPU — this target has no -smp). GICD_ICFGR: read back first; §27.5's decode says this SPI should already default level-triggered, write only if the readback disagrees — keep item 0.6's "don't touch ICFGR unless forced to" posture.
    4. Verify with no device present: software-pend the computed SPI via GICD_ISPENDR (offset 0x200 + 4*(intid/32), bit intid % 32 — standard GICv2 architecture register, ARM IHI 0048B), confirm it is taken, read via GICC_IAR, and completed via GICC_EOIR through aarch64_irq_handler(). This is the actual acceptance below.

    Done when: a software-pended SPI (via GICD_ISPENDR, no device present) on the computed target INTID is taken and acknowledged (IAR/EOIR), three-arch QEMU boot unaffected, log committed. Refs: §27.5.

    Done, 2026-08-08. New apic_spi_enable(uint32_t intid) in apic.c — generalises item 0.6's PPI-only priority/enable/target sequence to one explicit SPI: GICD_IPRIORITYR (byte-indexed, same pattern as the timer PPI), GICD_ISENABLER at 0x100 + 4*(intid/32), GICD_ITARGETSR (byte-indexed, CPU 0 only — no -smp on this target), and GICD_ICFGR read-then-write-only-if-needed (kept item 0.6's "don't touch ICFGR unless forced to" posture — the readback already matched level-triggered on this board, so no write occurred in practice). New GICD_ITARGETSR/GICD_ISPENDR offset constants added alongside the existing GICD_* block.

    Verified with a temporary self-test (aarch64_gic_spi_self_test() in interrupts.c, called once from apic_init()): software-pended target INTID 38 (GIC SPI 6 — the slot 3/INTA formula from §27.5.1, matching 4.3.5c's riscv64 addr=0x3 placement) via GICD_ISPENDR, confirmed taken and EOI'd through the existing generic aarch64_irq_handler() dispatch (which needed no changes — it already EOIs any non-spurious, non-timer INTID unconditionally, unlike riscv64's PLIC which needed an explicit per-source branch in 4.3.5c).

    One real bug found and fixed, not scope creep: first self-test run reported FAIL — target_intid=0x26 last_intid=0x00, no exception, boot continued normally past the bounded spin-wait (logs/20260808-115636/aarch64/). Root cause: PSTATE.I is still set at the point apic_init() (M4) runs — arch_enable_interrupts() (msr daifclr, #2) doesn't run until much later (M7 area, kernel_main.c), so the GIC correctly latched the pended SPI but the core never trapped to take it. Same class of fix riscv64's self-test needed for sstatus.SIE. Fixed by temporarily clearing PSTATE.I (msr daifclr, #2) around the pend-and-wait, restoring it after (msr daifset, #2) — matches the pre-test state exactly, arch_enable_interrupts()'s later call is unaffected. Rerun after the fix: target_intid=0x26 last_intid=0x26, PASS, zero exceptions (logs/20260808-115821/aarch64/).

    Self-test code (the function, its g_gic_last_intid capture in interrupts.c, and its call site in apic_init()) reverted after recording this result — interrupts.c has zero net diff from before this item; only apic.c's permanent apic_spi_enable() remains, unused/uncalled until 4.3.5e wires a real device to it. Three-architecture acceptance boot clean, zero exceptions: amd64 (logs/20260808-120048/amd64/), aarch64 (logs/20260808-120155/aarch64/), riscv64 (logs/20260808-120258/riscv64/) — riscv64 reboot required by §25.0 rule 6 / .claude/CLAUDE.md's acceptance rule even though this item touched only aarch64 files.

    Scoping check, 2026-08-08 — no landmine found (unlike 4.3.5a). Before starting this item, checked whether aarch64 carries the same class of gap that blocked riscv64: arch_mmu_init() (arch/aarch64/arch.c:169) is an identical stub — "page-table setup... deferred to a later milestone" — so aarch64's MMIO likewise depends entirely on whatever EDK2 left mapped at ExitBootServices(), an assumption apic.c's own header comment (lines 7679) already names. Live-tested rather than inferred, same rigor as 4.3.5a's discovery: added a temporary probe (read-modify-write to GICD_ITARGETSR[32], offset 0x820) immediately after 0.6's existing GICD_CTLR/ISENABLER0/IPRIORITYR writes, booted aarch64 alone in QEMU. Result: survived — GICv2 probe: ITARGETSR[32] read+write survived printed, boot proceeded clean to ok>, no exception (log: logs/20260808-093228/aarch64/). 0x820 isn't itself one of this item's real targets — those, per the DTB decode above, are INTID 3538 at 0x8230x826 — but all of them sit in the same 4KB page as 0x820 and as the offsets item 0.6 already proves reachable on every boot (0x000/0x100/0x400), so the probe validates the page, not a coincidence. Unlike riscv64's PLIC threshold register, which sat 2MB deep in a part of its MMIO window nothing had ever touched, that page-adjacency is why this item doesn't carry a 4.3.5a-shaped prerequisite. 4.3.5e's PCI-BAR MMIO transport is likewise already proven live (virtio-blk-pci uses it today on this same board). Probe code reverted after recording this result — not committed; this item remains unstarted.

  • 4.3.5e — aarch64: virtio-keyboard-pci, interrupt-driven. Add virtio-keyboard-pci to the aarch64 qemu/qemu-esp targets — the existing virtio-blk-pci device proves the PCI bus is already enumerated (pci.c) on this board, so this rides the same bus, a new device class. Same virtio-input driver shape as 4.3.5c (shared code where the transport allows — MMIO vs. PCI config-space discovery differs, the event-queue/EV_KEY decode should not). Built on 4.3.5d.

    Concrete steps:

    1. Add -device virtio-keyboard-pci to the aarch64 qemu/qemu-esp targets in Makefile.starkernel. Confirmed available in this host's QEMU 10.2.1 (qemu-system-aarch64 -device help lists it, bus PCI, alias virtio-keyboard).
    2. New include/starkernel/virtio_input.h / src/starkernel/virtio/virtio_input.c. Device identity: vendor 0x1AF4 (same as blk), device ID 0x1052 — the modern-ID formula 0x1040 + VIRTIO_ID_INPUT, VIRTIO_ID_INPUT = 18 per this build host's /usr/include/linux/virtio_ids.h. No legacy/transitional ID exists for virtio-input (unlike blk's 0x1001 fallback — input postdates the legacy 0.9.5 spec), so pci_find_first() only needs the one modern ID, no fallback branch.
    3. Event structure, from this host's /usr/include/linux/virtio_input.h and /usr/include/linux/input-event-codes.h (authoritative, not recalled): struct virtio_input_event { __le16 type; __le16 code; __le32 value; } (8 bytes), EV_KEY = 0x01.
    4. Resolved by 4.3.5c, 2026-08-08: duplicate walk_virtio_caps() into virtio_input.c rather than promoting it out of virtio_blk.c into shared code — decided there since 4.3.5c lands first and needs the identical walk. This item's virtio_input.c is riscv64/4.3.5c's file plus the GIC-specific interrupt routing below; no separate capability-walker decision needed here.
    5. pci_enable() (pci.c:363) sets only IO/MEM/BUS_MASTER in the PCI COMMAND register — it never clears bit 10 (Interrupt Disable). If that bit is set at enumeration, INTx never asserts and every earlier step passes while producing zero real interrupts — same failure shape as 4.3.5's OVMF-scanning-disabled bug (finding 2 in that item's done note). Read COMMAND back after pci_enable() and confirm/clear bit 10.
    6. Device init follows vblk_init_device()'s sequence (virtio_blk.c:361-499) almost exactly — reset → ACKNOWLEDGE → DRIVER → negotiate VIRTIO_F_VERSION_1 → FEATURES_OK → configure queue(s) → DRIVER_OK. Differences:
      • Two virtqueues (eventq index 0, statusq index 1) — read common->num_queues back from the device rather than hardcoding 2, same live-verification discipline as everything else in this item.
      • Unlike vblk_io()'s request/response pattern, the driver pre-posts N empty virtio_input_event buffers (device-writable) into the eventq's avail ring at init and does not wait synchronously — the device fills and posts a buffer to the used ring asynchronously, signalled by the interrupt wired in 4.3.5d, not a poll loop. Buffers must be re-posted after being drained in the ISR.
      • statusq (index 1) is for driver→device reports (LED state) — not needed to read keypresses; configure it (spec expects both queues set up) but leave it unused.
      • New for this tree, mandatory: virtio_blk.c defines VIRTIO_PCI_CAP_ISR_CFG = 3 but never reads it — vblk_io() polls and never handles a real interrupt, so it never needed to. This item is the first interrupt-driven virtio-pci device here. Because the routed SPI is level-triggered (§27.5's decoded interrupt-map flags = 0x4), the ISR handler must read the ISR-status capability byte (walk_virtio_caps(pci, VIRTIO_PCI_CAP_ISR_CFG, NULL)) — that read is what deasserts the line. Skipping it leaves INTx asserted after the first event: an interrupt storm or a permanent hang, not a subtle bug.
      • Report, don't fix: virtio_blk.c's wmb()/rmb() (virtio_blk.c:221-226) are compiler barriers only (asm volatile("" ::: "memory")), no real memory fence. Adequate for blk's synchronous polled loop; an interrupt-driven used-ring (device writes concurrently with the driver's re-post loop) leans on this ordering harder. Not this item's job to fix tree-wide — report if it manifests as a live symptom.

    Done when: a keypress in QEMU on aarch64 (via sendkey, same manual-injection technique as 4.3.5's amd64 verification) produces a captured EV_KEY event through the interrupt path with no polling loop anywhere in the path, three-arch QEMU boot unaffected, log committed. Refs: §27.5.

    Done, 2026-08-08. virtio_input.c's enable_interrupt_route() gained an #elif defined(ARCH_AARCH64) branch alongside 4.3.5c's riscv64 one — same slot/pin read (PCI_CFG_INT_PIN), the §27.5.1 formula (spi = 3 + ((slot+pin-1) % 4), INTID = 32 + spi) instead of riscv64's §27.5.2 one, calling apic_spi_enable() (item 4.3.5d) instead of plic_enable(). New g_virtio_input_gic_intid global, same "0 is a safe sentinel" reasoning as riscv64's g_virtio_input_plic_source (SPIs start at INTID 32, never 0). Everything else in the file — capability walk, feature negotiation, eventq pre-posting, the mandatory ISR-status read — is unchanged, exactly as §27.5.1's original amendment predicted ("this item and 4.3.5e now share most of the actual virtio-input driver logic"). aarch64_irq_handler() (interrupts.c) dispatches to virtio_input_isr() before its GICC_EOIR write, same claim-dispatch-complete ordering riscv64 uses — required here for a real reason: EOI'ing a still-asserted level-triggered line first would let the GIC immediately re-signal it. keyboard_words.c's VKBD-EVENT/VKBD-DEBUG guards extended to ARCH_AARCH64 (previously riscv64-only) — both words already used arch-agnostic virtio_input.c symbols, so this was a guard change only, no new logic. -device virtio-keyboard-pci,addr=0x3 added to the aarch64 qemu target (the item's text also named qemu-esp, but that target has no virtio-blk-pci/Artemis disk either — not part of §25.0's acceptance boot, skipped as a report-not-fix imprecision in the original item wording, not a deviation from anything load-bearing).

    Verified with a real keypress, same technique as 4.3.5c: temporary monitor socket (reverted after), QEMU HMP sendkey a, VKBD-EVENT . . . CR VKBD-DEBUG . CR over the serial socket. Result: -1 1 30 (code=30 = KEY_A exactly, value=1 = press) then 2 (two real interrupts serviced) — identical to riscv64's result, first try, no equivalent of 4.3.5d's DAIF bug (this path runs after arch_enable_interrupts(), unlike that item's self-test which deliberately ran before it) (logs/20260808-122317/aarch64/qemu-aarch64-20260808-122317-sendkey-verify.log).

    Found incidentally, reported not fixed: after the verification above completed cleanly, sending BYE to the same session produced *** EXCEPTION (aarch64) *** (ESR_EL1=0x02000000, EC=0/"unknown reason") in arch_cold_reset()'s cold-restart path — same log, lines after the passing test output. Confirmed unrelated to this item (arch.c's reset path, nothing this item touched) and confirmed not a regression this item introduced: riscv64's equivalent BYE-terminated session (logs/20260808-114907/riscv64/) shows no exception at the same point. Nobody had exercised BYE from a monitored serial session before either verification run this session, which is why this surfaced only now. Not this item's job to fix.

    Three-architecture acceptance boot clean (standard make qemu, no monitor socket, BYE never sent): amd64 (logs/20260808-122554/amd64/), aarch64 (logs/20260808-122640/ aarch64/, virtio-input: found device / driver ready present), riscv64 (logs/20260808-122743/riscv64/, unaffected — this item's riscv64-side changes were doc-comment-only).

  • 4.3.5f — Checkpoint: one keyboard abstraction, three architectures, no polling. Converge 4.3.5/4.3.5c/4.3.5e behind a single scancode/keycode interface so the REPL keyboard-input work (M8, outside Stadium) has one thing to call, not three. Stop and review here — same posture as 4.3.4 — before scoping REPL wiring. Done when: a keypress on all three architectures produces the same shape of event at the shared interface, confirmed with a live keypress test per architecture (not a synthetic/injected one — this is the first item in this slice where a real key matters), three-arch QEMU boot, logs committed. Refs: §27.5.

    Done, 2026-08-08. Keyboard-input slice (4.3.54.3.5f) complete. New KEY-EVENT ( -- keycode pressed -1 | 0 ) in keyboard_words.c, alongside (not replacing) the existing per-device diagnostics KBD-SCAN/VKBD-EVENT. keycode uses the Linux input keycode namespace; pressed is 1 or 0.

    The convergence needed almost no translation, for a reason worth recording rather than just asserting. Comparing this build host's own /usr/include/linux/input-event-codes.h against 4.3.5's already-documented live scancode observations (sendkey a → 30, sendkey b → 48) showed KEY_A=30/KEY_B=48 match those XT Set-1 make codes exactly — checked systematically across the full standard 84-key block (KEY_ESC=1 through KEY_F10=68), all identical to the historical XT/AT Set-1 numbering. Not a coincidence: documented historical property of how Linux's input keycode namespace was originally defined. So amd64's translation is two lines (keycode = sc & 0x7F, pressed = !(sc & 0x80)) covering the entire non-extended key range, not a lookup table — riscv64/aarch64 need no translation at all, virtio_input_pop_event()'s code/value already live in the same namespace. Explicitly out of scope, matching this checkpoint's "one shared shape proven live," not full coverage: extended (0xE0-prefixed) i8042 scancodes, virtio-input autorepeat (value=2, folded into "still pressed" here) — deferred to the REPL wiring item (M8) this checkpoint unblocks.

    Verified live on all three architectures with a real sendkey a, same technique as 4.3.5/4.3.5c/4.3.5e — identical output shape everywhere, -1 1 30 (flag, pressed, keycode): amd64 (logs/20260808-123405/amd64/qemu-amd64-20260808-123405-keyevent- verify.log), riscv64 (logs/20260808-124059/riscv64/qemu-riscv64-20260808-124059- keyevent-verify.log), aarch64 (logs/20260808-124207/aarch64/qemu-aarch64-20260808- 124207-keyevent-verify.log) — the exact "same shape of event... confirmed with a live keypress test per architecture" this item's acceptance requires. Three-architecture standard acceptance boot clean, zero exceptions: amd64 (logs/20260808-124331/amd64/), aarch64 (logs/20260808-124418/aarch64/), riscv64 (logs/20260808-124524/riscv64/).

    This closes the keyboard-input slice opened at 4.3.5. Next per the 2026-08-07 sequencing note below: glyph rendering (stroke-based font capsules), not yet scoped.

    Sequencing noted 2026-08-07, not yet scoped: keyboard input (4.3.5-4.3.5f) → glyph rendering (stroke-based font capsules) → REPL — in that order, before the 4.6 Artemis boundary (renumbered from 4.4, then from 4.5, 2026-08-11 — REPL now has its own 4.4 section, and Artemis moved down to make room for item 4.5's -O0 finding). Recorded so the order is not lost between sessions; neither glyph rendering nor REPL has Done when criteria yet. Per §25.0 rule 2, each gets scoped in detail at its own checkpoint, not now.

    Landing point noted 2026-08-08, not yet scoped: whatever 4.3.5f's shared interface produces has to eventually feed the FORTH-79 terminal I/O words, not just sit behind a new kernel API nothing calls. Checked, not assumed: KEY/?TERMINAL/EMIT/EXPECT are already registered (src/word_source/io_words.c:247-250, string_words.c:994) — this is the shared/vendored VM source, same words the hosted build has. In the kernel build both are dead today: io_word_key() calls libc getchar(), which src/starkernel/vm/host/shim.c:1206 hardcodes to always return -1 (EOF); ?TERMINAL (io_words.c:100-108) is its own literal stub — "Simple implementation - always return false for now." So there is currently no keyboard path into the VM on any architecture, confirmed by reading the code, not inferred from the milestone status. EMIT/putchar() already routes to console_putc() (shim.c:1100) — only the input side is stubbed. Not scoped now, per §25.0 rule 2 — recorded so it isn't lost before REPL wiring is scoped. Whether the fix lands in shim.c's getchar(), in io_words.c under #ifdef __STARKERNEL__, or elsewhere is an open question for that later item, not decided here.

    Now scoped, 2026-08-11 — resolved in docs/lithosananke/ROADMAP.md's M8 section, not here. M8 is "tracked outside Stadium" per §27.5, and its console-facing pieces don't change this document's own scope, so the resolution lives there rather than as new 4.3.x items. Short answer to the open question above: io_words.c under #ifdef __STARKERNEL__, wired to the same merged serial+keyboard source the REPL uses — see that section for the full design.

  • 4.3.6 — Em-square glyph coordinate convention. EM-UNITS 1000 (baseline Y=0, x-height ≈500, cap-height ≈700, ascender ≈750, descender ≈-250), plus EM-X/EM-Y/ G-LINE scaling/translating em-square strokes into CART-PLOT screen coordinates via */, parallel to 4.3.3's PROJECT. Prerequisite for every glyph-drawing word that follows. Done when: a normalized test shape scales/translates correctly onto raster coordinates at two different requested sizes, verified live via screendump against hand-calculated pixel positions. Refs: §27.6.1.

    Done, 2026-08-09. Blocks 49164917 in capsules/fabric.4th (lint-clean per mkcapsule --lint), landed there rather than a new file — 4.3.6d already names only fabric.4th and the 4.3.6c font capsule as the two capsules wired into boot, and blocks 4916+ were confirmed free across the entire block map, not just the fabric.4th family. 1000 CONSTANT EM-UNITS (the punch list's "EM-UNITS 1000" is prose shorthand, not FORTH argument order — matches fabric.4th's own 46341 CONSTANT COS45 precedent). EM-X/EM-Y/G-LINE exactly as §27.6.1 specifies, no deviation.

    Verified live on amd64, not just non-crashing: fabric.4th isn't wired into init.4th's boot chain yet (that's 4.3.6d), so a temporary monitor+serial-injection harness (built, run once, reverted — same pattern as 4.3.5b/4.3.5d's probes) manually EXEC'd the capsule and called G-LINE at two sizes from a shared origin: GSIZE 100 (white, origin 200,300) and GSIZE 50 (yellow, origin 500,100), each drawing one leg to 1000 700 (positive em-y) and one leg to 300 -333 (negative em-y, deliberately chosen non-multiple-of-1000 to exercise */'s truncate-toward-zero behaviour the section itself flags — 300*-333/1000 truncates to -33, not floor's -34).

    Screendump + exact pixel-bbox extraction (Python/PIL, not eyeballed) confirmed both shapes landed exactly on hand-calculated raster coordinates: white bbox (200,300, 429,532) against hand-calc EM-X(0)=200, EM-X(1000)=300, EM-Y(700)→raster 429, EM-Y(-333)→raster 532 — exact match on all four; yellow bbox (500,550,664,715) against the size-50 hand-calc — exact match, confirming correct scaling at a second size and correct truncation behaviour on the descender case. Screenshot (local only, fb/ is gitignored per 4.3.2): fb/amd64/verify-4.3.6-20260809-184431.png.

    Three-architecture acceptance boot clean, identical Stadium conservation on all three (resident_sum=43691 reservoir=21845 sum=65536, matching item 4.2's baseline — unaffected by this item, as expected): amd64 (logs/20260809-184959), aarch64 (logs/20260809-185037), riscv64 (logs/20260809-185127).

  • 4.3.6a — UTF-8 decoder. DECODE-UTF8, composed in FORTH per the compose-in-FORTH-first rule — no existing UTF-8 decoding anywhere in this tree (checked). Must handle 1-byte and 2-byte sequences (ASCII, Latin-1) and 3-byte sequences (General Punctuation — confirmed load-bearing for v1, not future-i18n-only, since smart quotes/dashes/ellipsis live outside Latin-1). Done when: correctly decodes a test string mixing 1-byte ASCII, 2-byte Latin-1 (e.g. ° U+00B0), and 3-byte General Punctuation (e.g. U+2014) sequences, verified live against hand-computed expected codepoints. Refs: §27.6.2.

    Done, 2026-08-09. Blocks 49184920 in capsules/fabric.4th (lint-clean). UTF8-SEQ-LEN/UTF8-CONT? verbatim from §27.6.2. DECODE-UTF8 ( addr u -- codepoint addr' u' ) dispatches to one of four UTF8-ASSEMBLE-N helper words (1/2/3/4-byte, all four formulas implemented since the spec text lists all four as what the word "must compute," even though only 1/2/3 are required by this item's own acceptance) — factored out because the single-word draft hit mkcapsule's real limit, discovered here: 64 chars × 16 lines per block, not the looser 1024-byte framing used elsewhere in this document. Same factoring pattern 4.3.3b already used for LINE/LINE-SETUP/etc. Invalid lead bytes fall to U+FFFD (replacement character) advancing 1 byte, avoiding an infinite loop on malformed input — not specified by §27.6.2, a minimal safety default consistent with the word's own contract always returning a valid (codepoint addr' u').

    Hand-verified the 2-byte and 3-byte formulas against the required test characters before writing code: ° U+00B0=176 via 0xC2 0xB0 ((0xC2 AND 31) LSHIFT 6 OR (0xB0 AND 63) = 128 OR 48 = 176); U+2014=8212 via 0xE2 0x80 0x94 ((0xE2 AND 15) LSHIFT 12 OR (0x80 AND 63) LSHIFT 6 OR (0x94 AND 63) = 8192 OR 0 OR 20 = 8212) — both exact.

    Verified live on amd64, same temporary-probe pattern as 4.3.6 (built, run once, reverted): a 6-byte test buffer ('A' 0xC2 0xB0 0xE2 0x80 0x94) decoded via three sequential DECODE-UTF8 calls, each printing its codepoint and re-feeding the returned (addr' u') into the next call. Results: 65 (ASCII A), 176 (°), 8212 () — exact match on all three, and the buffer's remaining length hit exactly 0 after the third call, confirming the byte-consumption bookkeeping is correct, not just the codepoint arithmetic.

    Three-architecture acceptance boot clean, Stadium conservation unchanged from item 4.3.6's baseline (resident_sum=43691 reservoir=21845 sum=65536): amd64 (logs/20260809-185758), aarch64 (logs/20260809-185835), riscv64 (logs/20260809-185925).

  • 4.3.6b — Codepoint → glyph dispatch. Bucketed CASE/OF/ENDOF chain (DISPATCH-DIGIT/-UPPER/-LOWER/-ASCII-PUNCT/-LATIN1/-GENPUNCT) routed by DISPATCH-GLYPH via WITHIN range checks, per §27.6.3 — chosen over a flat xt-table despite the table composing more cleanly with override (§27.6.5's finding). Every glyph word follows the ( -- em-advance ) contract; unmatched codepoints fall to TOFU. Done when: dispatch correctly invokes the right stroke word and returns its advance for at least one codepoint from each of the six buckets, and falls through to TOFU (not a crash) for a codepoint outside all six ranges. Refs: §27.6.3.

    Done, 2026-08-09. Blocks 49214924 in capsules/fabric.4th (lint-clean). DISPATCH-GLYPH and the six bucket words exactly as §27.6.3 specifies, plus TOFU (empty box via four G-LINE calls, advance 500 per the section's own proposal) and DRAW-GLYPH ( codepoint x y size color -- adv ).

    Deliberately minimal, not the real glyph set. The 113-glyph repertoire is item 4.3.6c's scope, not this one's — building it now would be jumping ahead per §25.0 rule two. Each of the six buckets got exactly one placeholder stroke word (G-TEST-DIGIT/-UPPER/-LOWER/-PUNCT/-LATIN1/-GENPUNCT, obviously-temporary names, each drawing one G-LINE and returning a distinct advance) — enough to prove the routing mechanism without designing any real letterforms. 4.3.6c will need to add the full CASE coverage to these same six bucket words; these placeholder entries are expected to be superseded there, not treated as finished glyph art.

    A real block-format limit found while landing this item, not previously hit: mkcapsule's actual rule is 64 chars × 16 content lines per block, and — not previously noticed — a blank separator line between blocks is charged to the preceding block's line count, not free. Item 4.3.6a's DECODE-UTF8 block (4920) was already at exactly 16 lines with zero slack, so appending this item's blocks after it pushed it to 17 via the following blank line. Fixed by merging two adjacent, unrelated lines in that block (ULEN ! UADDR ! / UADDR @ C@ ULEAD ! → one line) — a one-line mechanical reformat with identical semantics, not a scope change; re-verified via the DECODE-UTF8 regression check below before relying on it.

    Verified live on amd64, same temporary-probe pattern as prior 4.3.6x items: a regression check of DECODE-UTF8 (65/176/8212, matching item 4.3.6a's own values — confirms the block-4920 line merge changed nothing) followed by DRAW-GLYPH called with one representative codepoint per bucket plus one out-of-range codepoint (1, a control character outside all six WITHIN ranges). All seven results matched expected exactly: digit 48400, upper 65600, lower 97450, punct 33250, latin1 176550, genpunct 8212700, out-of-range 1500 (TOFU). Extraction required care — injected commands raced ahead of the verbose per-word execution trace in the log, so printed results appeared shifted relative to naive line-proximity matching; resolved by anchoring on each command's own echo line as an ordered marker and taking the last printed value before the next marker, not by assuming adjacency.

    Three-architecture acceptance boot clean, Stadium conservation unchanged: amd64 (logs/20260809-191200), aarch64 (logs/20260809-191237), riscv64 (logs/20260809-191327).

  • 4.3.6c — Default system font-set capsule. The confirmed 113-glyph v1 repertoire (95 ASCII printable + 11 Latin-1 Supplement + 7 General Punctuation, itemized in §27.6.4), each glyph a stroke-drawing word in the 4.3.6 em-square convention returning its own em-advance, one capsule. Done when: every glyph in the 113-glyph v1 repertoire is defined, lint-clean (mkcapsule --lint), individually verified to render a recognizable shape via screendump. Refs: §27.6.4.

    Done 2026-08-09. capsules/font.4th, all 113 glyphs, blocks 4925-4984, 60 blocks.

    Correction found mid-build, not designed around silently: the first pass built every glyph with G-LINE only, missing that CIRCLE/ARC/ELLIPSE already existed (item 4.3.3b, fabric.4th 4907-4912) for exactly this. User caught it and chose to rework everything already built, not just curves going forward. Required new em-square-aware wrappers — EM-R (magnitude scaling, no GOX/GOY translation) plus G-CIRCLE/ G-ARC/G-ELLIPSE, added to fabric.4th blocks 5000-5002 (non-contiguous with fabric.4th's own 4900-4924 range because font.4th had already claimed 4925+ — BLOCK_MAP.md checked before picking 5000). Scoped to this item (4.3.6c), not a reopening of 4.3.6, same precedent as the FORGET-hook/stadium_owner additions elsewhere in this list — but 4.3.6 itself should be read as having grown these three words later. ARC's sweep direction was derived, not assumed (ang=0→3 o'clock, increasing angle sweeps CCW through 12 o'clock) and confirmed by a one-shot render before any glyph used it (top semicircle at a0=0 a1=PI, matched the derivation).

    Bounded curve scope (advisor-flagged risk: hand-tuning arc sweep angles per glyph is unbounded, one QEMU boot per iteration). Only quarter/half/three-quarter/full sweeps of the four Q48.16 literals (0, PI/2=102944, PI=205887, 3PI/2=308831) plus full CIRCLE/ELLIPSE, no custom-fitted angles. Converted: digit 0, upper O Q C G D, lower o b d p q c e, punct % . @. D's bowl and the lowercase b/d/p/q bowls use one ARC each, radius = half the bowl height, centered on the stem's x — the arc's two endpoints land exactly on the stem ends by construction (no seam). Everything else (S/s/&/digits 2-9/etc.) stays the original blocky G-LINE style — recognizable, not hand-fitted, per the same advisor guidance.

    Early-binding fix applied, not just documented: per §27.6.5 (found during 4.3.6b), CASE binds early, so font.4th redefines all six DISPATCH-* bucket words with real CASE tables (replacing fabric.4th's 4.3.6b placeholders) and redefines DISPATCH-GLYPH/DRAW-GLYPH themselves (blocks 4979-4984) — fabric.4th's originals were also compiled early, against the old placeholder buckets, so redefining only the six bucket words would not have been enough. Verified live: DRAW-GLYPH called directly with codepoints 48/67/111/176 rendered the real 0/C/o/° (ellipse/arc/ellipse/circle), not TOFU, confirming the full pipeline binds to the new definitions.

    Verification: mkcapsule --lint capsules/font.4th clean. All 113 glyphs screendump- verified in one grid render (digits+%.@, upper, lower, Latin-1+GenPunct rows) plus a separate DRAW-GLYPH-pipeline render — arc-bowl direction, oval shapes, and dispatch routing all confirmed correct by eye.

    Three-architecture acceptance boot clean, Stadium conservation unchanged (resident_sum=43691 reservoir=21845 sum=65536): amd64 (logs/20260809-202214), aarch64 (logs/20260809-202252), riscv64 (logs/20260809-202342).

  • 4.3.6d — Boot-time loading. Wire fabric.4th and the 4.3.6c font capsule into init.4th's boot chain. Done when: standard three-arch QEMU boot shows both capsules EXEC'd cleanly as part of the normal boot sequence (not manual injection), no errors, logs committed. Refs: §27.6.

    Done 2026-08-09. capsules/init.4th block 2049, right after the existing S" lib.4th" EXEC: added S" fabric.4th" EXEC then S" font.4th" EXEC. init.4th is Hera's Mama IDENTITY capsule, so this runs automatically on every boot, not via manual injection. Verified by booting normally (no serial injection at all) and calling DRAW-GLYPH directly — codepoint 79 (O) rendered its real ellipse glyph, proving both capsules were live in the dictionary from the standard boot chain alone.

    Three-architecture acceptance boot clean, Stadium conservation unchanged (resident_sum=43691 reservoir=21845 sum=65536): amd64 (logs/20260809-202736), aarch64 (logs/20260809-203103), riscv64 (logs/20260809-203156).

  • 4.3.6e — User font override. Mechanism resolved 2026-08-09 (§27.6.5): override replaces a whole DISPATCH-* bucket word, not individual glyphs — CASE's early binding means redefining a single glyph word (e.g. G-A) alone does not change what an already-compiled DISPATCH-UPPER calls, so the override capsule must redefine the entire bucket it wants to change. Done when: a second, user-supplied capsule redefines one DISPATCH-* bucket word (e.g. DISPATCH-UPPER), and after loading it, drawing a codepoint from that bucket renders the overridden glyph, not the default — verified live via screendump, both before and after the override loads. Refs: §27.6.5.

    Done 2026-08-09. capsules/user-font-demo.4th, blocks 4200-4202. Not wired into init.4th — an optional capsule loaded manually via EXEC, same as doe.4th.

    §27.6.5 was incomplete, corrected here rather than silently followed. The resolved note said the override capsule must redefine the whole DISPATCH-* bucket word — true but insufficient. DISPATCH-GLYPH and DRAW-GLYPH are themselves compiled early (in font.4th, against font.4th's own DISPATCH-UPPER) — the same finding already hit once while building 4.3.6c's own dispatch wiring, applying again one layer up. Redefining only DISPATCH-UPPER would not have been picked up; user-font-demo.4th redefines DISPATCH-UPPER and DISPATCH-GLYPH/DRAW-GLYPH (blocks 4201-4202). Confirmed by testing the incomplete version first — redefining DISPATCH-UPPER alone left DRAW-GLYPH still drawing the original G-A, exactly as the early-binding rule predicts.

    Verified live, single boot, one screendump: codepoint 65 drawn via DRAW-GLYPH before S" user-font-demo.4th" EXEC renders the plain default A; the same call after renders G-A-ALT (the A with a diagonal cross through it), side by side in one image. All other letters (G-B..G-Z) render unchanged, calling font.4th's originals directly by name — proving override granularity is per-bucket, not all-or-nothing across buckets.

    Three-architecture acceptance boot clean, Stadium conservation unchanged (resident_sum=43691 reservoir=21845 sum=65536): amd64 (logs/20260809-203815), aarch64 (logs/20260809-203858), riscv64 (logs/20260809-203950).

  • 4.3.6f — TEXT entry point. TEXT ( c-addr u x y size color -- ): decode UTF-8 via 4.3.6a, dispatch each codepoint through 4.3.6b/DRAW-GLYPH, draw via its stroke word, scale its returned em-advance to pixels (GSIZE @ EM-UNITS */) and accumulate into the cursor X, per §27.6.6. Done when: a string containing ASCII, at least one Latin-1 character, and at least one General Punctuation character (e.g. an em dash) renders correctly on the CANVAS in one call, verified via screendump, with correct proportional spacing (no overlapping/misspaced glyphs, visibly different advance widths for e.g. i vs M). Refs: §27.6.6.

    Done 2026-08-09. capsules/font.4th block 4985, using 2>R/2R> to stash the DECODE-UTF8 remainder (addr' u') off the data stack while DRAW-GLYPH's five args (codepoint already sits at the right stack depth for it) are pushed and consumed — avoids the alternative of re-deriving the remainder pointer by hand each iteration. Defined in font.4th, not fabric.4th, for the same reason DISPATCH-GLYPH had to be redefined there in 4.3.6c: a fabric.4th-compiled TEXT would bind early to fabric.4th's own placeholder DRAW-GLYPH, not the real one.

    Verified live: S" Hi 30° end—X" (ASCII + ° U+00B0 Latin-1 + U+2014 General Punctuation, mixed in one string) rendered via one TEXT call — correct glyphs, no overlap, visibly proportional spacing (i narrow, H/X/m-width chars wider), confirmed by screendump.

    Three-architecture acceptance boot clean, Stadium conservation unchanged (resident_sum=43691 reservoir=21845 sum=65536): amd64 (logs/20260809-204422), aarch64 (logs/20260809-204458), riscv64 (logs/20260809-204550).

  • 4.3.6g — Checkpoint: render every v1 glyph on the CANVAS. Same posture as 4.3.4/4.3.5f — stop and review here before scoping REPL wiring (M8). Done when: a single screendump shows all 113 glyphs in the v1 repertoire (§27.6.4) rendered legibly and correctly positioned, on all three architectures, matching the 4.3.3b/4.3.4 completion pattern (screendump-verified, not just non-crashing). Three-arch acceptance boot clean, logs committed. Refs: §27.6.

    Done 2026-08-09. Fixed the cross-arch framebuffer-size bug found while first attempting this checkpoint (paused note, now superseded): aarch64 and riscv64 both boot an 800×600 ramfb device (confirmed for riscv64 too, not assumed from aarch64's number — checked via screendump pixel dimensions), against amd64's 1280×800 GOP framebuffer. Rebuilt the verification grid to fit 800×600 (7 rows, GSIZE 28, all rows below the boot log text), which is automatically safe on amd64's larger screen too.

    All 113 glyphs confirmed legible and correctly positioned, one screendump per architecture, no clipping, no dispatch errors: amd64, aarch64, riscv64 all rendered from the identical command sequence (same FORTH source, same VM, different framebuffer geometry only).

    Three-architecture acceptance boot clean, Stadium conservation unchanged (resident_sum=43691 reservoir=21845 sum=65536): amd64 (logs/20260809-213223), aarch64 (logs/20260809-213301), riscv64 (logs/20260809-213354).

    Stroke font (4.3.64.3.6g) is now fully complete. Per this item's own posture, stop here before scoping REPL wiring (M8) — 4.3.7 (TrueType, adjunct) is the scoped-but-not-started next work, not M8.

  • 4.3.7 — TTF parser core. A C module (not FORTH — parsing and rasterization are impractical to interpret) reading a .ttf's sfnt directory plus head/maxp/loca/ glyf/cmap tables, resolving a codepoint to a glyph index and its outline data. All scaled/derived values in Q48.16, not float — see §27.7. Done when: given an embedded test font's bytes in memory, the parser resolves at least one ASCII codepoint to the correct glyf table offset and reads its outline header (contour count, bounding box), verified against values independently read from the same font with a reference tool (e.g. fonttools/ttx), not just "doesn't crash." Refs: §27.7.

    Done 2026-08-10. include/starkernel/ttf.h + src/starkernel/hal/ttf.c: sfnt directory walk, head (unitsPerEm, indexToLocFormat)/maxp (numGlyphs)/loca/glyf table location, and a format-4 cmap subtable selector + lookup (format 12 explicitly deferred — not needed for the BMP-only v1 glyph repertoire). Every multi-byte read is manually big-endian-decoded with a bounds check against the buffer length first — no libc byteswap dependency, matches the freestanding/no-libc build (-ffreestanding -nostdlib -fno-builtin, confirmed by direct single-file compile against the real Makefile.starkernel amd64 COMMON_CFLAGS/ARCH_CFLAGS, zero warnings under -Wall -Werror -Wextra). No Q48.16 conversion needed here — every field this item reads is a raw on-disk integer, not a scaled/derived value; Q48.16 becomes relevant starting 4.3.7a (outline point extraction).

    Verification tool substitution, recorded plainly: fonttools/ttx is not installed in this environment (no network install attempted). Verified instead against a from-scratch second implementation — a plain struct-module Python script (/tmp/.../scratchpad/ttf_ref.py, not committed, reproducible from this note) that shares no code with ttf.c — arguably a stronger independence guarantee than a shared-library-backed tool would have given, though not what the item text named. A host test harness, tools/ttftest.c (new, follows tools/README.md's already-established "host test, no QEMU needed" pattern — note fbtest.c, that entry's other example, turns out not to actually exist in tools/; stale-doc discrepancy, reported not fixed), compiles ttf.c directly and checks 7 codepoints from fonts/JetBrainsMono-Regular.ttf (A a 0 . ! @ plus space, the empty-glyph case) against the Python reference's output — glyph index, contour count, bounding box (including two negative-yMin cases, a and @, which exercise signed-field decoding), glyf offset, and glyf length all match. gcc -std=c99 -Wall -Wextra -Werror, zero warnings.

    .ttf bytes reach the parser via a plain host fopen/fread into a malloc buffer in ttftest.c, not yet via a capsule (that's 4.3.7b, still open — see §27.7's 2026-08-10 note on the raw-blob-vs-hex/base64 design question). ttf_parse() itself is agnostic to how its buffer arrived, so this doesn't gate 4.3.7's own "done when" clause.

    fonts/JetBrainsMono-Regular.ttf added (SIL OFL 1.1, confirmed by reading the font's own embedded name-table license string directly, not assumed from the filename) as the v1 test/default font, resolving §27.7's licensing-check blocker — see fonts/README.md.

    Not a kernel-boot change (no FORTH words, no capsule/init.4th wiring, ttf.c isn't called from anywhere in the boot path yet — it's picked up by the existing hal/*.c wildcard in Makefile.starkernel but dead code until something calls it) — no three-arch QEMU acceptance boot applies to this item; the amd64 single-file freestanding compile check above is what stands in for it, per this item's own "done when" clause (which never asked for a kernel boot).

  • 4.3.7a — Glyph outline extraction. Simple and composite glyph outlines from glyf — on-curve/off-curve point lists, quadratic Bézier control points, composite glyph transforms — in Q48.16. Done when: outline point lists for a handful of test glyphs (including at least one composite, e.g. an accented character if the test font has one) match reference-tool output within Q48.16 rounding tolerance. Refs: §27.7.

    Done 2026-08-10. ttf_glyph_outline() in src/starkernel/hal/ttf.c/ttf.h: flags run-length decode, delta-decoded x/y coordinates (simple glyphs), and recursive composite component resolution, into caller-supplied point/contour-end buffers (no allocation in this module). Points are stored in raw font design units shifted into Q48.16 (q48_from_i32, a local two's-complement left-shift — deliberately not routed through q48_mul/q48_div, see below).

    Known gap, reported not fixed, per this repo's rule against modifying a shared/tested module without being asked: src/starkernel/math/q48_16.c's q48_mul/q48_div are unsigned-only (q48_div saturates via an unsigned overflow check on a; q48_mul does a plain unsigned widen-multiply) — confirmed by reading the source before writing any of this, not assumed. A composite glyph's transform can carry a signed F2Dot14 scale/ rotation/skew, which needs a signed fixed-point multiply that function doesn't provide. Resolution taken here: decode_composite_glyph() applies (dx,dy) translation only (plain q48_add, safe under two's-complement regardless of the unsigned typing) and explicitly rejects any component with a non-identity scale/2×2 transform or point-matched (non-xy-offset) args, returning TTF_ERR_UNSUPPORTED rather than silently mis-rendering it. Checked, not assumed: every composite in fonts/JetBrainsMono-Regular.ttf (11 sampled accented Latin glyphs: ÁÉÍÓÚÑéáñüö) uses identity-scale translation-only components, so this doesn't block the v1 repertoire (§27.6.4) — but it's a real limitation for an arbitrary future font, and fixing it means either giving q48_16.c a signed multiply variant or doing the scale math locally the same way translation already is. Whoever picks that up next should decide which, not silently patch it in passing.

    Verified against /tmp/.../scratchpad/ttf_outline_ref.py (not committed, reproducible from this note) — a second from-scratch Python decoder sharing no code with ttf.c, covering the same "no fonttools installed" substitution already recorded under 4.3.7. Three glyphs checked point-for-point (coordinates, on/off-curve flags, contour-end indices) via the extended tools/ttftest.c: . (12 points, 1 contour, simple), A (17 points, 2 contours, simple), and á (43 points, 3 contours, a genuine 2-component translation-only composite exercising the recursive path) — all match exactly. gcc -std=c99 -Wall -Wextra -Werror, zero warnings; single-file freestanding compile against the real amd64 Makefile.starkernel flags also re-checked clean. Same "not a kernel-boot change yet" posture as 4.3.7 — no FORTH/capsule wiring exists for this module, so no three-arch QEMU acceptance applies here either.

  • 4.3.7b — Font data ingestion. .ttf bytes encoded (hex or base64 — pick one, record why) into capsule blocks per the resolved design decision (§27.7), decoded into a kmalloc buffer at capsule load time. Done when: a font capsule loads cleanly (mkcapsule --lint), and the decoded in-memory bytes checksum-match the original .ttf file. Refs: §27.7.

    Done 2026-08-10, on the corrected design (§27.7's 2026-08-10 correction, not the hex/base64 premise this item's own text was written against — see that note for the full story of why). capsules/fonts/JetBrainsMono-Regular.ttf added; mkcapsule.c ingests it unmodified as capsule fonts:JetBrainsMono-Regular.ttf (raw bytes, no text encoding, no tool changes needed — it already handles arbitrary files). Verified two ways: mkcapsule --lint capsules/ passes clean (29 .4th files, 0 violations — the .ttf isn't .4th so lint correctly skips it rather than misapplying text-block rules to it); and a byte-exact round-trip check — built capsule_generated.c, extracted the capsule_arena[] bytes at the font's descriptor offset/length (270224 bytes) back out with a throwaway script, cmp'd against the source .ttf: identical.

    No kmalloc copy, since none is needed: ttf_load_from_capsule() (new, in ttf.c/ttf.h, #ifdef __STARKERNEL__-gated) resolves the capsule via capsule_find_by_name(), validates its content hash via capsule_validate(..., verify_hash=1), and points ttf_font_t at the capsule_arena bytes directly (capsule_get_payload()) — zero-copy, since the arena is already a const array baked into the kernel image and ttf_parse() only ever reads through a const uint8_t*. Compiles clean (-Wall -Werror -Wextra) both with and without -D__STARKERNEL__, against the real amd64 Makefile.starkernel flags including -include include/starforth_config.h -DPARITY_MODE=0. Not callable from FORTH yet — no capsule-birth/init.4th wiring exists for it; that's 4.3.7e's job once TTF-TEXT needs it. Same "not a kernel-boot change" posture as 4.3.7/4.3.7a; no three-arch QEMU run applies.

  • 4.3.7c — Rasterization. Bézier curve flattening to line segments (reusing the existing LINE/Bresenham primitive where practical), then fill. Antialiasing approach is an open question — not decided here, resolve when this item is picked up. Done when: a single glyph outline rasterizes to a recognizable filled (or outlined, if AA is deferred) shape on the CANVAS, screendump-verified. Refs: §27.7.

    Done 2026-08-10. ttf_rasterize_glyph() in src/starkernel/hal/ttf.c/ttf.h: quadratic-Bezier contour flattening (fixed 8-segment subdivision per curve, same fixed-segment-count approach as capsules/fabric.4th's CIRCLE/ELLIPSE, 36 segs, and ARC, 18 segs — no adaptive tessellation) followed by an even-odd scanline fill into a caller-supplied 8-bit-per-pixel bitmap. No antialiasing, per this item's own allowance.

    Doc error found and reported, not fixed: §27.7 decision #1 claims 4.3.3b put LINE/CIRCLE/ARC/ELLIPSE "in C rather than FORTH" — 4.3.3b's own completion note says the opposite: they're FORTH words in capsules/fabric.4th (blocks 49034912), built on the C-level PLOT/TO-RASTER primitives. C is still the right call for this item (4.3.7's item text says so directly, independent of that reasoning), but "reusing the existing LINE/Bresenham primitive" from a C module can't mean literally calling the FORTH word — it means the same Bresenham/scanline approach at the C level, which is what this implementation does (no FORTH call from ttf.c).

    Fill rule: even-odd, not TrueType's native nonzero winding. Simpler to implement (no edge-direction bookkeeping) and identical to nonzero winding for the v1 glyph repertoire (§27.6.4), whose contours are simple and properly nested (outer contour + inner counters, e.g. a/o) — the two rules only diverge on self-intersecting outlines, which no v1 glyph has. Not correct in general for an arbitrary font; whoever needs that generality later should switch to nonzero winding (requires tracking edge direction, not otherwise hard).

    Local signed Q48.16 multiply (q48_smul), not the shared q48_mul. Rasterization scales outline coordinates (routinely negative — e.g. a's yMin is 10) by a non-negative scale/Bezier-blend weight, exactly the signed use case q48_mul/q48_div (src/starkernel/math/q48_16.c) don't support (see 4.3.7a's completion note). Per this repo's rule against modifying a shared/tested module without being asked, that gap is reported there, not patched here; q48_smul is local to ttf.c, a plain ((int64_t)a * (int64_t)b) >> 16, safe because every value in this module (glyph coordinates scaled to at most a few hundred pixels) stays far inside int64_t range. Scanline edge-intersection division is done as plain int64_t arithmetic directly (x0 + dx*t_num/dy), not through any Q48.16 divide helper — C's native signed integer division already handles the sign correctly there, so no wrapper was needed for that part.

    Verification. Host-side (tools/ttftest.c, extended): test_rasterize() rasterizes A/./a at 28px into a small bitmap and prints an ASCII-art dump plus structural checks (non-empty, not implausibly full, background corner untouched). All three glyphs render as visually correct, recognizable letterforms; all checks pass. No independent reference rasterizer exists to diff pixel-for-pixel against (unlike 4.3.7/4.3.7a, which had a from-scratch Python decoder) — the item's own acceptance bar is "recognizable," not pixel-exact, so this is judged sufficient.

    Live screendump, amd64, per this item's explicit requirement (the only 4.3.74.3.7b items so far to need one). A throwaway registered word, TTF-PROBE (added to src/word_source/framebuffer_words.c, __STARKERNEL__-gated), loaded the font capsule, rasterized 'A' at 48px, and blitted it via fb_put_pixel() at screen position (100,100). Boot: custom one-off script (not a repo file — mirrors scripts/qemu_screenshot.sh's monitor-socket + socat + HMP screendump pattern, plus a serial-socket command injection channel that script doesn't have, same injection technique used throughout 4.3.x) built the amd64 ISO, booted to [Hera] ok>, injected TTF-PROBE over the serial socket, confirmed TTF-PROBE: rasterized 'A' at (100,100) in the serial log, then issued screendump over the monitor socket. Result: a clearly legible white 'A' at the expected screen position, committed as fb/amd64/ttf-rasterize-glyph-A-20260810-221922.png. Probe reverted immediately after (framebuffer_words.c back to its pre-probe content, confirmed via git diff) — per feedback-revert-probes-after-capture, only the permanent rasterizer in ttf.c/ttf.h survives.

    Compile-checked on all three architectures (make -f Makefile.starkernel ARCH=<arch> clean then plain build, no qemu), zero warnings from ttf.c on any — this item's own "done when" only requires the amd64 screendump, not a three-arch boot (that's 4.3.7f's job), but ttf.c builds into all three via the hal/*.c wildcard so a portability compile check costs little and catches real bugs early.

  • 4.3.7d — Glyph raster cache. Rasterizing on every draw call is too slow for repeated text; cache rasterized bitmaps keyed by (font, codepoint, size). Done when: drawing the same codepoint/size twice measurably hits the cache on the second call (e.g. a counter or timing difference), verified live, not just "code that should cache." Refs: §27.7.

    Done 2026-08-10. ttf_raster_cache_get()/ttf_raster_cache_init() in src/starkernel/hal/ttf.c/ttf.h: a fixed, caller-owned slot array (no allocation, same convention as the rest of this module), linear-scan lookup keyed by (font pointer, codepoint, size_px), round-robin eviction once every slot is full. Each slot is a fixed TTF_CACHE_BITMAP_DIM (80×80) square, rasterized at a fixed origin (TTF_CACHE_MARGIN, size_px + TTF_CACHE_MARGIN) regardless of the glyph's own bounding box — a caller doing real text layout (4.3.7e) needs to know this fixed convention, not assume the bitmap is tightly cropped to the glyph.

    Verified live, not just "code that should cache" (this item's own bar): extended tools/ttftest.c's test_raster_cache() calls ttf_raster_cache_get() twice for the identical (font, 'A', 24px) key — first call returns was_hit=0 (rasterized), second returns was_hit=1 (cache hit), and the slot's own hits counter reads exactly 1 afterward, checked programmatically, not just printed. A third call for a different codepoint ('a') at the same size misses again, proving the key actually discriminates rather than the cache just always reporting "hit". Wall-clock clock() timing is also printed as corroborating evidence (miss 0.040ms vs. hit 0.001ms on this run) but is explicitly labeled informational-only in the test's own output, since host clock() resolution is coarse and this repo doesn't treat unverified timing claims as proof on their own — the hit counter is the load-bearing check.

    No kernel-boot/screendump verification needed — this item's own "done when" only asks for a measurable hit, which the host test above demonstrates directly; unlike 4.3.7c, nothing here is CANVAS-visual. Compile-checked clean (-Wall -Werror -Wextra) on all three architectures, with and without -D__STARKERNEL__.

  • 4.3.7e — TTF-TEXT entry point. TTF-TEXT ( c-addr u x y size color -- ), analogous to 4.3.6f's TEXT but TrueType-backed — becomes the primary text-rendering path per the resolved relationship to the stroke font (§27.7); TEXT/the stroke system remain available, not deprecated. Done when: a UTF-8 string renders correctly via TTF-TEXT in one call, screendump-verified, proportional spacing correct. Refs: §27.7.

    Done 2026-08-10. New src/word_source/ttf_words.c/ttf_words.h, registered from word_registry.c as Module 30. TTF-TEXT lazily loads the v1 default font capsule (fonts:JetBrainsMono-Regular.ttf) and its raster cache once on first use, decodes the UTF-8 string byte-by-byte (a C reimplementation mirroring capsules/fabric.4th's DECODE-UTF8 exactly -- same lead-byte-length table, same U+FFFD fallback), looks up each codepoint's bitmap via ttf_raster_cache_get() (4.3.7d), blits it via fb_put_pixel(), and advances the pen by the glyph's real hmtx advance width (new -- see below) scaled to pixels via the shared q48_mul/q48_div (safe here since advance widths and scale are always non-negative, unlike the rasterizer's signed cases).

    Necessary plumbing added, not scope creep beyond this item's own "done when": ttf_parse() now also locates hhea (for numberOfHMetrics) and hmtx, and ttf_glyph_advance_width() reads a glyph's advance width from it. Without this, "proportional spacing correct" (this item's own acceptance clause) would be unmet -- there is no advance-width data anywhere else in the parser. Verified in tools/ttftest.c: A/a/0/space all read advance_width=600, correctly uniform since JetBrainsMono-Regular.ttf is monospace (a real structural property to check against, not an arbitrary assumption).

    Coordinate convention, recorded explicitly, not conflated with the stroke font's: TTF-TEXT's (x,y) is raster pixel space (top-left origin, Y-down) -- the same space PLOT/fb_put_pixel() use -- NOT capsules/fabric.4th's Cartesian Y-up space that the stroke font's TEXT (4.3.6f) uses via CART-PLOT. These are two deliberately different coordinate systems on two separate text paths; a caller mixing them up would get a vertically-flipped y. (x,y) is the first glyph's baseline-left origin.

    Verified live, amd64, screendump. Boot: same one-off script pattern as 4.3.7c (monitor socket + socat + HMP screendump, plus serial-socket command injection) -- booted to [Hera] ok>, injected S" Hi 4.3.7e!" 200 200 28 16777215 TTF-TEXT over the serial socket, confirmed no error in the serial log (next prompt was a clean ok>), then captured a screendump. Result: "Hi 4.3.7e!" renders legibly at the expected position, mixed case + digits + punctuation all correct, glyphs evenly spaced left to right with no overlap -- fb/amd64/ttf-text-hi437e-20260810-230926.png (not committed; fb/ is gitignored, matching every other screendump referenced in this document). Unlike 4.3.7c's TTF-PROBE, TTF-TEXT is the item's own permanent deliverable, not a throwaway -- nothing to revert.

    Compile-checked clean (-Wall -Werror -Wextra) on all three architectures.

  • 4.3.7f — Checkpoint: TTF rendering, all three architectures. Same posture as 4.3.6g. Done when: a single screendump per architecture shows a representative sample string rendered correctly via TTF-TEXT, three-arch acceptance boot clean, logs committed. Refs: §27.7.

    Done 2026-08-10/11. No code changes -- ttf.c/ttf.h/ttf_words.c are unchanged since 4.3.7e; this is verification only, same posture as 4.3.6g.

    Three-arch acceptance boot clean, standard make -f Makefile.starkernel ARCH=<arch> clean qemu, run in the required order, one at a time: logs/20260810-231553/amd64/, logs/20260810-231640/aarch64/, logs/20260810-231739/riscv64/ -- all three reached [Hera] ok> cleanly, logs committed.

    One screendump per architecture, identical TTF-TEXT command sequence (S" Hi 4.3.7e!" 200 200 28 16777215 TTF-TEXT, the same string already used for 4.3.7e's amd64 verification, reused here as the "representative sample string" this item asks for), via the same monitor-socket + serial-injection one-off script pattern as 4.3.7c/e. (200,200) at 28px was already safely inside the smaller 800×600 ramfb bound found by 4.3.6g (aarch64/riscv64 ramfb vs. amd64's 1280×800 GOP) -- confirmed, not re-derived, since TTF-TEXT's raster-space coordinates don't change with screen size the way 4.3.6g's grid did. All three screendumps show "Hi 4.3.7e!" rendered legibly, correct mixed-case/digit/punctuation glyphs, identical spacing (same monospace 600-unit advance width on every architecture, since it's the same font capsule and same C code): fb/amd64/ttf-text-hi437e-20260810-230926.png (already captured for 4.3.7e, reused rather than re-captured), fb/aarch64/ttf-text-hi437e-20260811-004152.png, fb/riscv64/ttf-text-hi437e-20260811-004313.png (not committed; fb/ is gitignored, matching every other screendump referenced in this document, including 4.3.6g's).

    TrueType rendering (4.3.7-4.3.7f) is now complete, adjunct to the stroke font per §27.7's decision #4 -- both text paths coexist. Per this item's own posture (matching 4.3.6g), stopping here before scoping REPL wiring (M8).

    M8 scoped 2026-08-11 in docs/lithosananke/ROADMAP.md, not here -- per §27.5, M8 is tracked outside Stadium, so its design decisions live in that roadmap's own M8 section rather than as new 4.3.x items. Key finding from that scoping pass, worth recording in this document too since it bears on §27.6's CANVAS/REPL-strip framing: the REPL's on-screen text already renders via the existing VT100 console (console_fb_init()/vt100_init(), baked-in font_8x16.c), independent of both the stroke font and TrueType work above -- neither 4.3.6-4.3.6g nor 4.3.7-4.3.7f was ever a prerequisite for the REPL strip itself. What's still open for M8 is purely input-side: KEY-EVENT (4.3.5f) isn't yet wired into the REPL's character source (console_getc()), and KEY/?TERMINAL remain the dead stubs already noted above.

    (4.3.x is open-ended — more items get appended here as framebuffer/keyboard Console work is scoped item by item, developed on the fly per §25.0. 4.4 below is a new, independent section for the REPL itself — separate because the REPL is independent of the screen-output and keyboard work above. Artemis's item (renumbered from 4.4 to 4.5, then to 4.6, 2026-08-11) is unaffected by anything added above this marker.)

  • 4.4 — Design-only: lock the prompt format and color values. No code. Write down the exact target string — [VM name] ok> for now, (user) added later per 4.4s — and the exact color values for the bracketed VM name and for ok> against repl-mockup.png. Captain Bob's spoken values (orange, cyan) were both qualified as tentative/uncertain — this item is where they become specific RGB or ANSI SGR index values, or are confirmed as still-approximate and revisited later. Done when: a literal prompt string and two specific color values are written into this document, referencing the mockup. Refs: §25.5, §27.8.

    DONE 2026-08-11. Format: [VM name] ok> — e.g. [Hera] ok>, [Hermes] ok>. The entire bracketed substring (brackets included) renders in one color; ok> (including its trailing space) renders in a second, distinct color. Values chosen with Captain Bob, checked against what already exists in the codebase rather than invented cold:

    • ok>: bright cyan, 0x55FFFF. Reuses FB_ANSI_PALETTE[14] (framebuffer.c:30) as-is — no new constant, stays consistent with the ANSI palette the rest of the console already uses.
    • [VM name]: standard web orange, 0xFFA500. No orange exists anywhere in the classic 16-color ANSI palette (checked — FB_ANSI_PALETTE has none), so this is a fresh literal value, not a reused constant. TTF-TEXT's color argument is a raw RGB value (not an ANSI palette index), so this is not a constraint — any RGB is valid. These are literal FB_RGB()-equivalent values for 4.4a/4.4h to consume directly; no implementation performed by this item, per its own "no code" scope.
  • 4.4a — Build [VM name] bracket text in the prompt, monochrome. Replace the two existing, mutually inconsistent VM-identity conventions — console.c's per-line [Name] prefix (console_set_vm_name()/g_active_vm_name, unaffected/out of scope for non-prompt lines) and repl.c's prompt-suffix <Name>)ok> (sk_repl_run()/sk_repl_step()) — with [VM name] ok>, no color yet (4.4h). VM attach itself needs no new work: USE (mama_forth_words.c:1108) already sets both g_repl_active_vm and console_set_vm_name() — this item is a display change only. Done when: all three architectures boot to a bracketed [Hera] ok> prompt (replacing today's separate [Hera]-per-line-prefix + bare ok>); USE-driven attach to another VM shows [Hermes] ok> (etc.), replacing today's Hermes)ok> suffix; the serial-injection acceptance harness (greps for ok>) still matches; three-arch QEMU boot + logs per CLAUDE.md. Refs: §27.8.

    DONE 2026-08-11. Caught a bug in the plan before writing it: console.c's console_putc() already fires the [VMName] prefix at line-start whenever g_active_vm_name is set — including on the prompt itself. Printing a second, manually built bracket (the original plan) would have produced [Hera] [Hera] ok> . Fixed by not building a bracket at all — repl.c's sk_repl_step()/sk_repl_run() now print only "ok> ", letting the existing per-line prefix supply [VMName] for free. This also explains, and organically fixes, the old non-Hera double-display bug noted in §27.8: the previous <Name>)ok> suffix was always being printed after that same automatic prefix, so a Hermes-attached prompt actually read [Hermes] Hermes)ok> (name shown twice) before this change. The is_hera branch and its emergency_console assignment are otherwise untouched — this is a display-only change, the ACL bypass semantics are identical to before.

    Known, intentional gap until 4.4s: the old prompt distinguished zuse)ok> from bare ok> textually. That distinction is not yet visible in the new unified prompt — it moves to the (user) segment, which is explicitly scoped-but-blocked at 4.4s. The underlying zuse_session/emergency_console mechanics are unchanged; only the on-screen indicator is temporarily gone.

    Regression: clean. All three architectures boot to [Hera] ok>, verified live: amd64 (logs/20260811-073408/), aarch64 (logs/20260811-073448/), riscv64 (logs/20260811-073542/).

    Caveat found afterward, not fixed here: that verification is serial-log-only — [VMName] never actually reaches the framebuffer (console.c's emit_prefix() is serial-only by construction). Given its own item, 4.4d, rather than silently rolled into this one.

  • 4.4b — Resolve: does prompt color depend on 4.4j? Investigation, not implementation. Read the REPL's actual output path (console_puts() → ... → vt100_putc()draw_cursor_glyph(), all currently font_8x16.c) to determine whether a single hardcoded-color prompt segment can call TTF-TEXT directly, independent of the general ANSI/SGR retarget (4.4j), or whether all REPL output — prompt included — shares one rendering pipeline that requires 4.4j to land first before any TTF output reaches the screen. Done when: the dependency question is answered in writing here, based on reading the actual call chain, not assumed. Refs: §27.8.

    DONE 2026-08-11 — answer: no dependency on 4.4j. draw_cursor_glyph() (vt100.c:125) already calls fb_draw_glyph(px, py, ch, f, b) with f/b taken from g_vt.fg/g_vt.bg — color support is already wired all the way through the existing font_8x16.c pipeline, completely independent of which font renders the glyph shape. apply_sgr() (vt100.c:337-348) already parses true 24-bit-color SGR: ESC[38;2;R;G;Bm sets foreground RGB directly (not limited to the 16/256-color palette), ESC[39m resets to default. So 4.4h does not need to wait on 4.4j/4.4i at all — it can send ESC[38;2;255;165;0m (4.4's locked orange) before the bracket, ESC[39m after, and ESC[38;2;85;255;255m (4.4's locked cyan) before ok> , all through console_puts() exactly as it works today. TTF (4.4i/4.4j) changes what draws the glyph, not whether color reaches the screen — those are orthogonal, confirmed by reading the call chain rather than assumed.

  • 4.4c — Wire console_fb_init() into the boot path. Found while verifying 4.4d (below) by screendump — not a bug in 4.4a/4.4b, a pre-existing, deliberate gap. kernel_main.c:830-834 has an explicit comment: "console.c / vt100.c are superseded by the Console drawing-fabric redesign (FABRIC.md §27) and are deliberately not invoked here... fb_init() wires the raw GOP framebuffer directly... nothing else touches it." Only raw fb_init() runs; console_fb_init() (which calls vt100_init()) is never called anywhere in the boot sequence. Consequence, confirmed by screendump: vt100_putc() no-ops on every call (if (!g_vt.initialized) return;), so no console output has ever reached the framebuffer — not REPL text, not POST/boot logs, nothing except the framebuffer driver's own corner self-test blocks (fb_draw_orientation_test()) and whatever TTF probes have drawn directly. ROADMAP.md's now-obsolete M8 section claimed this path was "already live" — that was never actually screendump-verified and turned out to be wrong. End goal, stated by Captain Bob 2026-08-11: the framebuffer console and the serial console must be functional identically — every character reaching one reaches the other. console_putc() already implements exactly that split for ordinary characters (raw_putc() for serial, vt100_putc() for framebuffer, unconditionally paired); the only thing missing is turning the framebuffer half on at all. Fix: call console_fb_init(&boot_info->framebuffer, fb_fmt) in kernel_main.c, replacing or immediately following the existing raw fb_init() call, so vt100_init() actually runs. Done when: three-arch screendump shows live boot/POST/REPL text actually rendered on the framebuffer (not just the corner test blocks) — matching what the serial log already shows; three-arch QEMU boot + logs per CLAUDE.md. Refs: §27.8.

    Done 2026-08-11. amd64 screendump: evidence/amd64/qemu-screenshot-20260811-081837-pre-4.4e-fix.png shows real boot/REPL text on the framebuffer for the first time (not just the corner blocks) — the fix's core mechanism works, confirmed further legible by the 4.4f fix (see below). Three-arch serial boot clean (logs/20260811-084941/amd64/, logs/20260811-085022/aarch64/, logs/20260811-085119/riscv64/, all reaching [Hera] ok>). Screendump verification itself remains amd64-only — no aarch64/riscv64 screendump tooling exists yet (Captain Bob's call 2026-08-11: acceptable gap, not a blocker for this checkbox; building that tooling is separate future work if wanted). Verifying this screendump surfaced three further bugs, tracked as 4.4e/4.4f/4.4g below.

  • 4.4d — Fix: [VMName] prefix never reaches the framebuffer. Depends on 4.4c (the framebuffer console must be live before this matters). Found while scoping 4.4h, given its own item per §25.0 rule 3 rather than folded in silently. console.c's emit_prefix() (called from console_putc() at line-start when g_active_vm_name is set) writes its [VMName] characters directly via raw_putc() — serial only, per its own comment ("no recursion into console_putc"). It never calls vt100_putc(), so the bracketed VM name has never appeared on the actual screen, only in the serial log — including in 4.4a's own verification, which was serial-log-only (satisfied that item's literal Done when, but not the visual intent behind this whole design conversation). Fix: route emit_prefix()'s characters through vt100_putc() as well when fb_is_available(), mirroring console_putc()'s existing serial/framebuffer split for ordinary characters — the same pattern already used there, not a new mechanism. Done when: three-arch screendump shows [Hera] ok> (or [Hermes] ok> etc.) actually rendered on the framebuffer, not just present in the serial log; three-arch QEMU boot + logs per CLAUDE.md. Refs: §27.8.

    Done 2026-08-11. Same evidence and three-arch serial-boot basis as 4.4c: evidence/amd64/qemu-screenshot-20260811-081837-pre-4.4e-fix.png shows [Hera] ok> genuinely on screen, not just in the serial log.

  • 4.4e — Fix: console_putc() forwards \n to vt100_putc() without \r, corrupting line starts. Found while verifying 4.4c/4.4d by screendump — code is pre-existing, not introduced by either item, just invisible until 4.4c turned the framebuffer console on. console.c's raw_putc() auto-injects \r before \n for the serial path (line ~176-186); console_putc() forwards the same raw \n to vt100_putc() with no accompanying \r (console.c:232-233). vt100_putc()'s \n handler (vt100.c:464-469) only increments the row, never resets the column. Consequence, confirmed by screendump: every line after the first starts at whatever column the previous line happened to end on, not column 0 — text scatters diagonally across the screen instead of forming a left-aligned column. Fix: emit \r to vt100_putc() alongside \n wherever console_putc() mirrors to the framebuffer, matching what raw_putc() already does for serial. Done when: three-arch screendump shows every REPL/boot text line starting at column 0; three-arch QEMU boot + logs per CLAUDE.md. Refs: §27.8.

    Done 2026-08-11. amd64 screendump: evidence/amd64/qemu-screenshot-20260811-082822-post-4.4e-fix.png shows every line starting at column 0, compared against the pre-fix screendump above where lines started at scattered x-positions. 4.4f (glyph flip) and 4.4g (missing lines) are both still present, unchanged, in this same screendump — confirming they are independent bugs, not caused by the \r/\n issue. Three-arch serial boot clean, same basis and same logs as 4.4c.

  • 4.4f — Fix: character glyphs rendered vertically flipped on the framebuffer. Found while verifying 4.4c/4.4d by screendump. [Hera] ok> was legible in content and position but every glyph appeared upside-down under 6× zoom. Root cause: font_8x16_data (font_8x16.c) stores each glyph's 16 scanline bytes bottom-to-top, not top-to-bottom, confirmed by hand-decoding two glyphs' raw bytes — 'A' (0x41) reads as a shape converging to a point at the bottom unless the row order is reversed, at which point it becomes the textbook capital A (narrow apex, crossbar, splayed feet); 'T' (0x54) reads as a bar under a stem unless reversed. fb_draw_glyph() (framebuffer.c:186-192) mapped glyph data row 0 to the top pixel row (py + row*scale), assuming top-to-bottom storage — wrong for this table. Fix: flip the row-to-pixel mapping in fb_draw_glyph() to py + (15 - row) * scale, leaving the 4096-byte font table itself untouched. Done when: root cause identified and three-arch screendump shows glyphs in correct orientation; three-arch QEMU boot + logs per CLAUDE.md. Refs: §27.8.

    Done 2026-08-11. evidence/amd64/qemu-screenshot-20260811-084630-4.4f-fix-legible.png[Hera] Heartbeat: ..., [Hera] LithosAnanke v1.5.4, [Hera] ok> all legible in correct orientation, compared against the upside-down glyphs in the pre-fix screendump linked from 4.4d/4.4e above. Three-arch serial boot clean, same basis as 4.4c. Screendump verification remains amd64-only, same accepted gap as 4.4c/4.4d/4.4e.

  • 4.4g — Design decision needed: console_fb_init() runs after capsule birth, not before, so almost all boot output is framebuffer-invisible by construction. Found while verifying 4.4c/4.4d by screendump — initially logged as an unexplained rendering anomaly (hundreds of lines of log_message()-driven HADES/ECW/Stadium/self-test output missing from a screen with room for 50 character rows), but root-caused 2026-08-11, and it isn't a rendering bug at all. Root cause: capsule_birth_mama() (kernel_main.c:622) runs init.4th, which births the whole Tripod fleet (Hermes ×2, Artemis) and their self-tests — this single C call is where essentially all of log_message()'s HADES/ECW trace and the Stadium/self-test output originates, synchronously, inside FORTH interpretation. console_fb_init() doesn't run until kernel_main.c:852, well after that call returns. So that entire block of output is serial-only by construction, the same structural reason M1M6 output is serial-only — just far larger in extent than originally scoped (this item's initial framing wrongly assumed "boot output" meant only the pre-VM M1M6 messages). Only the small tail after line 852 — heartbeat summary, version banner, REPL banner, ok> — ever reaches the framebuffer, and that tail is legible and correctly scrolled once 4.4c/4.4e/4.4f are applied; there is no scroll or CSI-parser defect. fb_scroll_rows() (framebuffer.c:257) was read during the original investigation and is fine. Open question, not decided: does 4.4c's stated goal ("every character reaching [the serial console] reaches the [framebuffer] other") require moving console_fb_init() earlier — e.g. before capsule_birth_mama() — so the fleet-birth/self-test transcript becomes framebuffer-visible too? That's a boot-sequencing change with its own considerations (framebuffer must still be probed/available at that point; scroll volume during birth would be substantial), not something to decide unprompted. Done when: Captain Bob decides whether to reorder console_fb_init() relative to capsule birth; if yes, implement and three-arch-verify; if no, close this item as "working as designed" with this root-cause note as the record. Refs: §27.8.

    Evidence, amd64, 2026-08-11: evidence/amd64/qemu-screenshot-20260811-084630-4.4f-fix-legible.png — the visible tail (Heartbeat/version/REPL banner/ok>) is exactly, and only, the console_putc() calls issued after kernel_main.c:852; everything above that line in the serial log predates the framebuffer console's existence.

    Decided, 2026-08-11 — yes, reorder. Captain Bob's reasoning: the serial log already captures the full transcript losslessly, so this isn't about not losing data; it's about that same transcript also being visible on a real screen. Weighed against 4.5f's measured cost (~12x more boot-time heartbeat ticks, roughly 27005300 vs ~200 depending on architecture) — confirmed one-shot, paid only during the fleet-birth/self-test sequence at boot, never repeated during REPL/runtime — and judged worth it ("that's nothing"). console_fb_init()'s call site moved permanently in kernel_main.c, from after capsule_birth_mama() to just before it. Three-arch verified: amd64 (logs/20260811-171007/amd64/), aarch64 (logs/20260811-171118/aarch64/), riscv64 (logs/20260811-171322/riscv64/) — all reach ok>, POST Failed: 0, identical dict-hashes across all three. Screendump on the landed build: evidence/amd64/qemu-screenshot-20260811-171612-4.4g-landed.png — framebuffer now shows the full HADES/ECW/Stadium/self-test transcript, not just the tail.

  • 4.4h — Apply color to the prompt. Depends on 4.4d (coloring a bracket that isn't drawn on screen accomplishes nothing). Per 4.4b's finding: either an independent TTF-TEXT call with the color from 4.4, or gated on 4.4j completing first. Done when: three-arch screendump shows [VM name] and ok> in the colors locked at 4.4; three-arch QEMU boot + logs per CLAUDE.md. Refs: §27.8.

  • 4.4i — Verify the VT100 CSI/SGR parser's glyph-draw call site is cleanly separable. Verification only, no code change. vt100.c's CSI/SGR state machine (escape parsing, cursor tracking, 16/256-color SGR) calls draw_cursor_glyph() from a single site in put_char() — confirm nothing else in the parser depends on font_8x16.c-specific assumptions. Specifically check: the cursor/cell-grid model (g_vt.cols/g_vt.rows/cell_h()) assumes fixed-width character cells; TTF-TEXT uses real per-glyph hmtx advance widths (proportional in general). The chosen v1 font, JetBrainsMono-Regular.ttf (§27.7), is nominally monospace, so advance widths should be uniform and tile into the existing fixed-cell grid — but this item confirms that by reading the font's actual hmtx table for the glyphs in use, not by assuming "monospace" in the filename guarantees it. Done when: the call-site separation is confirmed clean, and the advance-width/cell-grid compatibility question is answered from the actual font data, in writing here. Refs: §27.8.

  • 4.4j — Retarget the glyph-draw call site from font_8x16.c to TTF-TEXT. Depends on 4.4i. Boundary, corrected 2026-08-11: font_8x16.c/VT100 keeps rendering everything through and including POST (the parity/dictionary-hash self-test) — not just the pre-bootstrap M1M6 messages originally scoped here. POST runs after the capsule/VM system is up (it hashes the loaded Mama capsule dictionary, per CLAUDE.md), so "before the capsule bootstrap" was the wrong boundary; "through POST" is later and is what Captain Bob confirmed. TTF-TEXT takes over only once POST completes and the interactive REPL itself starts — per Captain Bob 2026-08-11, TrueType is the primary path for anything a user is actively working with; the stroke font (TEXT) is reserved for later retro/game-styled work, not this item. Done when: REPL output and CANVAS scroll-box text render via TTF-TEXT; all boot-time and POST messages are unaffected (still font_8x16.c/VT100); three-arch QEMU boot + logs per CLAUDE.md. Refs: §27.8.

    Correction, recorded here rather than silently repeated: §27.7 decision #4 (2026-08-09) and Captain Bob's 2026-08-11 restatement both list "early boot" as a stroke-font use case. That's imprecise — the stroke font is capsule-based (fabric.4th) and is exactly as unavailable before the VM/capsule bootstrap as TrueType is, per §27.6's own reasoning ("font_8x16.c stays, structurally, not by preference"). The actual early-boot/POST fallback is font_8x16.c specifically, not "the stroke font" generically.

  • 4.4k — Live-verify ANSI color end-to-end. Depends on 4.4j. Inject an SGR color escape sequence (ESC[3xm/ESC[9xm/256-color) over serial and confirm it visibly changes TTF-TEXT-rendered glyph color in a screendump — the parser and the retargeted draw call working together, not just each existing independently. Done when: screendump evidence of a color change from an injected SGR sequence, on all three architectures; three-arch QEMU boot + logs per CLAUDE.md. Refs: §27.8.

  • 4.4l — Confirm actual booted framebuffer resolution, all three architectures. Investigation only. amd64 already confirmed this session: 1280×800 (QEMU/OVMF GOP default, read directly off the 2026-08-11 verification screendump). aarch64/riscv64 unconfirmed. Done when: resolutions for all three architectures are recorded here from real screendumps or boot logs, not assumed. Refs: §27.8.

  • 4.4m — Decide REPL-strip pixel height. Currently undefined anywhere in this document or the code. Once 4.4j lands, REPL text renders via TTF-TEXT at some chosen point/pixel size (also not yet chosen) — strip height derives from that size's line height plus padding, not from font_8x16.c's fixed 8×16 cell. Done when: a REPL text size (px) and a resulting strip height (px) are both decided and recorded here. Refs: §27.8.

  • 4.4n — CANVAS rectangle definition, per architecture. Screen height (4.4l) minus REPL strip height (4.4m) minus the small fixed gap (exact pixel value chosen here) between CANVAS and the strip. Done when: a CANVAS rectangle (top-left, width, height) is computed and recorded for each architecture. Refs: §27.8.

  • 4.4o — 640×480 scroll-box centering math within CANVAS. Layout math computed on top of TTF-TEXT's own coordinate primitive, which is simpler and already fixed: Cartesian, origin at the bottom-left of the physical framebuffer (not CANVAS-relative — resolved and implemented ahead of this item, 2026-08-11, ttf_words.c's baseline_y = fb_height() - y, verified by amd64 screendump). This item translates 4.4n's CANVAS-relative rectangle into that same physical-framebuffer space. Open, blocking question, not assumed: if any architecture's CANVAS height (4.4n) is smaller than 480, this item cannot center the box as specified and must stop and report per §25.0 rule 5, not silently shrink it. Done when: box top-left coordinates are computed for each architecture, or the item stops and reports which architecture's CANVAS is too small. Refs: §27.8.

  • 4.4p — Three-arch screendump verification of 4.4l4.4o. Geometry only — not the toggle or scrollback. Done when: screendump on all three architectures shows the 640×480 box centered within CANVAS at the computed coordinates, with the fixed gap above the REPL strip visible; three-arch QEMU boot + logs per CLAUDE.md. Refs: §27.8.

  • 4.4q — Scrollback, ~1000 lines. A circular buffer of prior scroll-box lines, target depth approximately 1000 (Captain Bob's own qualification: "something along those lines," not a hard-locked spec number). Storage mechanism (kmalloc ring buffer vs. static array) is this item's own implementation call. Done when: scrolling back through more than one screen's worth of REPL output recovers prior lines up to the target depth, verified live on amd64 at minimum; three-arch QEMU boot

    • logs per CLAUDE.md. Refs: §27.8.
  • 4.4r — Toggle word: hide/show the scroll box. A FORTH word that hides the 640×480 scroll box, revealing the rest of CANVAS beneath it for drawing; showing it again restores the scroll-box content undisturbed, no scrollback loss. Depends on 4.4o/4.4p (geometry) and 4.4q (scrollback) all being done first. Done when: toggling off then on again, verified by screendump, shows the CANVAS drawing area unobstructed while off and the exact prior scroll-box content restored when back on; three-arch QEMU boot + logs per CLAUDE.md. Refs: §27.8.

  • 4.4s — (user) prompt segment — scoped, blocked, not started. Extends 4.4's prompt to [VM name] (user) ok>, e.g. [Hera] (zuse) ok>. Blocked: zuse_session (include/vm.h:393) is a boolean only — no username/identity string exists anywhere in the ACL system today (confirmed by reading capsules/zuse.4th, capsules/ACL.4th, vm_core.c). This item cannot start until ACL identity storage exists — that's the open Phase 8 PKI/user-minting item already on record in CLAUDE.md's ACL section. Recorded here so the prompt's final shape isn't lost, not because it's ready to build. Refs: §27.8, CLAUDE.md ACL section (Phase 8).

  • 4.5 — URGENT, flagged by Captain Bob 2026-08-11: the kernel build has never used any compiler optimization. Blocking priority, set by Captain Bob 2026-08-11: no other punch-list item is worked until 4.5a4.5f are done, ahead of whatever would otherwise come next in sequence. Found while investigating why moving console_fb_init() earlier in boot (4.4g) stalled boot indefinitely (12,700+ lines logged, still running after 3 minutes vs. a normal few-second boot). Cross-cutting — affects the entire kernel, not just Console work — recorded here because it was found during Phase 4 and blocks 4.4g, but it is its own problem. Finding: Makefile.starkernel's COMMON_CFLAGS (and every ARCH_CFLAGS block — amd64, aarch64, riscv64) specifies no -O flag at all, so the kernel and loader compile at GCC/Clang's default -O0. This was verified by reading the file directly, not assumed. Not a regression — checked, not assumed. Captain Bob's recollection was that -O2 was already in use; that recollection is correct for the other two Makefiles in this ecosystem, just not this one. git log --all -p -- Makefile.starkernel (17 commits, full history) shows the only optimization flag ever present in this file is one unrelated host build-tool line (cc ... -O2, for mkcapsule-adjacent tooling, not the kernel itself), unchanged since it was added. Makefile.starkernel itself has had no -O flag on the kernel/loader CFLAGS since its very first commit, a5ed8c3 ("Initial commit — LithosAnanke kernel", 2026-08-01) — this was true from day one of this file, not something that regressed later. Where the impression came from — both siblings do use it:

    • /home/rajames/CLionProjects/StarForth/Makefile (the standalone StarForth repo): TARGET_CFLAGS_standard defaults to -O2 -flto=auto -fuse-linker-plugin -DNDEBUG; fast/fastest/turbo targets go to -O3 plus -DUSE_ASM_OPT=1.
    • This repo's own vendored hosted Makefile (root-level, builds the plain hosted starforth binary per CLAUDE.md) carries the same TARGET_CFLAGS_standard/fast/ fastest/turbo ladder — it was copied over intact when the VM was vendored into LithosAnanke.
    • Makefile.starkernel is a separate file, written fresh for the bare-metal kernel target (a5ed8c3) rather than derived from either of the above, and the -O ladder simply never got carried into it. include/vm_asm_opt.h/vm_asm_opt_arm64.h/ vm_asm_opt_riscv64.h document a hand-optimized-assembly VM path with suggested flags including -O3/-flto and a USE_ASM_OPT macro — but that macro is never referenced anywhere in Makefile.starkernel or any Kconfig* file, so it was never wired in either; those headers are aspirational documentation for a path that doesn't exist in this build. Why this isn't a trivial one-line fix — a real, confirmed correctness hazard exists. At -O0 every memory access is a genuine load/store with no caching across calls or inlining, so interrupt-shared state "just works" by accident even without volatile. Under optimization that accident stops protecting you. A sweep of the ISR-adjacent globals found the codebase is otherwise disciplined about this — g_sk_fault_word, g_spurious_count (amd64 interrupts.c:59), g_plic_claim_count (riscv64 interrupts.c:47), g_pending_counter/g_pending_valid/g_adaptive_period_ns (heartbeat.c:57-63, the documented ISR→mainline top/bottom-half handoff) are all correctly declared volatile — but one field was missed: TimeTrustState.ticks (include/starkernel/timer.h:90, a plain uint64_t, not volatile) is incremented directly in interrupt context (heartbeat.c:163, heartbeat_tick(), called from the timer ISR on all three architectures — amd64 interrupts.c:342, aarch64 interrupts.c:89, riscv64 interrupts.c:80) and read directly by mainline code via heartbeat_ticks() (heartbeat.c:200-202), including in a busy-wait loop at kernel_main.c:880 (while (heartbeat_ticks() - wait_start < 3 ...)). At -O0 this works. Under optimization, if the compiler inlines heartbeat_ticks() into that loop, it can legally prove (per the C abstract machine, which does not model asynchronous interrupts) that nothing in the loop body writes g_heartbeat.ticks, hoist the read out entirely, and turn that loop into either an instant no-op or an infinite spin — on all three architectures at once, since the pattern is shared. This is a real, specific, isolated bug (one field, not a sprawling unknown), not a hypothetical one — but it is unaudited, and enabling -O2 before fixing it would ship a live regression. Not scheduled as one item — broken out below (4.5a4.5f) per §25.0 rule 1, same treatment as 4.4's own design-only lock before 4.4a onward. Nothing below is started; this is scoping only, per Captain Bob's explicit instruction 2026-08-11. Refs: discovered via 4.4g; independent of Console work.
  • 4.5a — Full ISR/interrupt-context global-state audit, all three architectures. Investigation only, no code change. For each architecture, enumerate every vector actually wired to a handler (amd64: IDT/APIC vectors in arch/amd64/interrupts.c; aarch64: GIC vectors in arch/aarch64/interrupts.c; riscv64: PLIC/trap causes in arch/riscv64/interrupts.c), list every global or static variable each handler touches either directly or through a called function (e.g. heartbeat_tick()TimeTrustState.ticks), and check each one's declaration for volatile. The one confirmed hazard (TimeTrustState.ticks, timer.h:90) came from investigating a single busy-wait loop found by accident while working 4.4g — not from an exhaustive pass — so this item exists precisely because absence of further evidence so far is not evidence of absence. Done when: a written inventory exists here (or in a linked scratch doc) covering every handler on all three architectures, with every non-volatile ISR-touched global listed by file:line, or an explicit "none found" recorded per architecture. Refs: 4.5.

    Done 2026-08-11. Every vector handler on all three architectures read; every global or static each one touches (directly or through a called function) checked against its declaration. One confirmed hazard, matching what 4.5 already reported; everything else checked out, for reasons recorded below rather than left as a bare "it's fine."

    Confirmed hazard (1): TimeTrustState.ticks (g_heartbeat.ticks, timer.h:90) — written directly in the timer ISR on all three architectures (heartbeat_tick(), heartbeat.c:163, called from amd64 interrupts.c:342, aarch64 interrupts.c:89, riscv64 interrupts.c:80) and read directly by mainline via heartbeat_ticks() (heartbeat.c:200-202), including the busy-wait at kernel_main.c:880. Genuinely concurrent ISR-write / mainline-read of non-volatile state — the real bug 4.6b exists to fix.

    Already correctly volatile (no action needed): g_sk_fault_word (all three interrupts.c), g_spurious_count (amd64 interrupts.c:59), g_plic_claim_count (riscv64 interrupts.c:47), g_pending_counter/g_pending_valid/g_adaptive_period_ns (heartbeat.c:57-63, the documented top/bottom-half handoff), i8042.c's ring[]/ ring_head/ring_tail/g_i8042_isr_count, virtio_input.c's g_diag_head/g_diag_tail/ g_virtio_input_isr_count.

    Not volatile, but not a hazard — write-once-during-init, then single-context for the rest of boot, so no concurrent access ever occurs: each architecture's timer-calibration state (amd64 apic.c: timer_initial_count, s_apic_hz; aarch64 apic.c: s_timer_ppi, s_timer_period_tsc, s_counter_hz_apic; riscv64 apic.c: s_timer_period_tsc, s_time_hz, s_sbi_time_ok, s_next_deadline) is written once by apic_timer_init() before arch_enable_interrupts() is ever called, then touched only by each arch's ISR-context *_timer_rearm() for the rest of boot — verified by grepping every read/write site of each symbol, not assumed from the pattern looking familiar. Same reasoning for virtio_input.c's g_vinput_ready/g_virtio_input_plic_source/g_virtio_input_gic_intid: all three are written exactly once inside virtio_input_find_keyboard() (called from kernel_main.c during M7.pre PCI init, before interrupts are enabled) and read-only by the ISR afterward.

    Special case, no volatile needed by design: console_puts()/console_println() and the framebuffer/VT100 state they touch (g_vt, g_active_vm_name, g_line_start, g_fb) are reachable from ISR context only on the fatal exception/fault path (isr_common_handler()'s default case, aarch64_exception_handler(), riscv64_exception_handler()) — and every one of those paths halts the core permanently afterward (while(1) arch_halt(); / for(;;) wfi/wfe). There is no return to mainline after a fault handler touches console state, so no concurrent-access hazard exists despite the state not being volatile. The hot-path IRQs (timer tick, keyboard, virtio-input) never call console functions at all.

    No other ISR-reachable global state was found beyond what's listed above, on any of the three architectures.

  • 4.5b — Fix every hazard 4.5a found. Depends on 4.5a. Code fix, no optimization flags touched yet. TimeTrustState.ticks is the one already-confirmed instance — mark it volatile, or fold it into the existing g_pending_counter/g_pending_valid-style ISR→mainline handoff pattern already used elsewhere in heartbeat.c, whichever fits the finding better once 4.5a's full inventory is in hand. Any further hazards 4.5a surfaces get fixed here too, not deferred. Done when: every hazard from 4.5a's inventory is fixed; three-arch boot still clean at the unchanged -O0 (this item changes correctness under future optimization, not present behavior, so a clean boot here proves no regression was introduced, not that optimization is now safe). Refs: 4.5, 4.5a.

    Done 2026-08-11. 4.5a found exactly one hazard, so this fixes exactly one field: TimeTrustState.ticks (include/starkernel/timer.h:90) marked volatile in place, rather than folded into the g_pending_counter handoff — ticks is a simple monotonic counter with a single ISR writer and no derived-state computation on the mainline side (unlike window/variance/trust, which genuinely need heartbeat_service()'s deferred-processing pattern), so a direct volatile on the one hazardous field is the narrower fix and leaves the struct's other fields — which don't need it — unaffected. Three-arch acceptance boot clean at unchanged -O0: amd64 logs/20260811-100623/, aarch64 logs/20260811-100724/, riscv64 logs/20260811-100825/, all reaching [Hera] ok>. This proves no regression, not that optimization is safe yet — that's 4.5e's job once 4.5c/4.5d actually turn it on.

  • 4.5c — Decide and record the target optimization flags. Design-only, no code. Depends on 4.5b (deciding flags before the known hazard is fixed is premature). Candidates to weigh, not yet chosen: matching the hosted/StarForth ladder's -O2 -flto=auto -fuse-linker-plugin -DNDEBUG (this repo's own Makefile's TARGET_CFLAGS_standard, and CLAUDE.md already documents that plain -flto without -fuse-linker-plugin causes "ELF section name out of range" errors on a codebase this size — the same risk applies here if LTO is chosen) versus a more conservative -O1 or -Og for kernel debuggability versus plain -O2 without LTO as a lower-risk first step. Also decide whether -DNDEBUG is appropriate here — check whether the kernel build path actually uses assert() anywhere before copying that flag by habit from the hosted ladder. Done when: an exact flag set is chosen and written down here with the reasoning, before any Makefile edit. Refs: 4.5, CLAUDE.md "Important: Linker Configuration".

    Decided 2026-08-11: -O2, no LTO, no -DNDEBUG.

    -O2, not -O1/-Og. The kernel has no verified track record at any optimization level, so this is inherently a first attempt regardless of which level is picked — there's no "safer" level that avoids needing full re-verification. Given that, pick the level that actually solves the motivating problem: -Og is tuned for debuggability and does not reliably batch/vectorize loops the way -O2 does, and the concrete case that started this (4.4g's fb_scroll_rows() stall) specifically needs real loop optimization, not just "some optimization." -O2 is also what both sibling Makefiles (this repo's own vendored hosted Makefile and the standalone StarForth repo's) already use as their default standard target — precedented in this codebase, not a novel choice.

    No LTO (-flto/-fuse-linker-plugin) yet. Deliberately deferred, not rejected. LTO is a second, independent source of risk on top of a first-ever optimization pass, and CLAUDE.md already documents that this exact codebase has hit "ELF section name out of range" from plain -flto before (hence the hosted ladder's -fuse-linker-plugin workaround). Stacking both changes at once would make any 4.5e boot failure ambiguous between "-O2 semantics exposed a bug" and "LTO-specific linker issue" — isolate the two. If -O2 alone verifies clean on all three architectures, LTO becomes its own follow-up item, not assumed here.

    No -DNDEBUG. Checked, not assumed: grep -rn "assert(" src/starkernel/ include/starkernel/ turns up exactly two files. uefi_loader.c's two hits are _Static_assert (compile-time, layout checks on BootInfo offsets — unaffected by NDEBUG regardless, since _Static_assert isn't gated by it). shim.c's one hit is a comment mentioning "assert()", not a call. There are zero runtime assert() call sites anywhere in the kernel build — include/starkernel/freestanding/assert.h's shim (#define assert(expr) ((void)(expr)), itself unconditional and not gated on NDEBUG either) exists but has nothing to affect. Copying -DNDEBUG from the hosted ladder would be exactly the cargo-culting this item's own text warned against.

    Chosen flags: -O2. Added to COMMON_CFLAGS in 4.5d; no other new flags.

  • 4.5d — Apply the chosen flags to Makefile.starkernel; build all three architectures. Depends on 4.5b (hazard fixed) and 4.5c (flags decided). Code change: add the flags to COMMON_CFLAGS (shared across all three ARCH_CFLAGS blocks, so this is one change, not three). Optimization can surface warnings -O0 never triggers (e.g. -Wmaybe-uninitialized) under the existing -Wall -Werror -Wextra standard — budget time to fix those, not to weaken the warning set. Done when: all three architectures build with zero warnings under the existing -Wall -Werror -Wextra standard. Refs: 4.5, 4.5c.

    In progress 2026-08-11 — two link-time findings, neither anticipated by 4.5c/4.5d's own text (which expected new warnings, not link failures). amd64 attempted; blocked before completion; not yet tried on aarch64/riscv64.

    Finding 1, fixed: adding bare -O2 broke the amd64 link with dozens of undefined reference to '__printf_chk' / __memset_chk / __fread_chk / __snprintf_chk across io_words.c, system_words.c, starforth_words.c, physics_benchmark_words.c, physics_pipelining_diagnostic_words.c. Root cause: these vendored word_source files unconditionally #include <stdio.h>/<stdlib.h>/ <string.h>/<signal.h> with no __STARKERNEL__ guard (confirmed by reading system_words.c:48-51); Makefile.starkernel has no -nostdinc, so these resolve to real glibc headers, and src/starkernel/vm/host/shim.c provides freestanding printf()/ snprintf() to satisfy calls into this vendored code. At -O0, __OPTIMIZE__ is undefined so glibc's default-on-Ubuntu _FORTIFY_SOURCE macros stay dormant and the plain symbol names resolve against shim.c — working by accident. At -O2 those macros activate and rewrite call sites to the _chk variants, which shim.c never provided. Fixed by adding -U_FORTIFY_SOURCE to COMMON_CFLAGS — the standard, precedented fix for freestanding/kernel builds (Linux and most embedded kernels carry this exact flag for this exact reason). Verified: all _chk link errors gone after adding it.

    Finding 2, NOT fixed, blocking this item: with fortification disabled, a second, unrelated problem surfaced — plain (unfortified) putc/getc are genuinely undefined symbols. shim.c only backfills printf/snprintf, never putc/getc. Call sites: profiler.c (profiler_print_hotspots/profiler_generate_report), io_words.c (io_word_key), system_words.c (system_word_words/system_word_vlist). These same call sites do not break the -O0 link — GCC's -O2 function-splitting pass visibly clones some of them (profiler_print_hotspots.part.0 in the linker error) and something about that changes which code the linker ends up pulling in; the exact mechanism by which -O0 avoids needing these symbols is not established, only that it reproducibly does. Captain Bob's call 2026-08-11: stop here, don't fix in-place — this needs a real design decision (backfill freestanding putc/getc in shim.c matching the existing printf/snprintf pattern, versus guarding these call sites out of __STARKERNEL__ builds entirely) rather than a mechanical flag. Makefile.starkernel reverted to the committed -O0 state (the change was uncommitted, so a plain git restore — nothing broken is in the tree or history). 4.5d cannot complete until this is resolved.

    Finding 2 fixed 2026-08-11: backfilled putc()/getc() in shim.c as thin wrappers reusing the existing putchar()/getchar() implementations exactly (putcconsole_putc via putchar; getc → the existing "no stdin in kernel" -1/EOF stub via getchar) — Captain Bob's chosen approach, not new behavior. Verified: with both Finding 1 (-U_FORTIFY_SOURCE) and Finding 2 fixed, amd64 links clean at -O2 with zero new warnings — rigorously confirmed by diffing normalized warning text between an -O0 and an -O2 build (diff empty; all ~3040 pre-existing warning lines, in vendored VM-core code already downgraded from -Werror by VMCORE_CFLAGS_COMMON, are byte-for-byte identical between the two, not introduced by this work).

    Finding 3, NOT fixed, blocking this item: amd64 boot stalls for minutes inside PM Timer TSC calibration at -O2, with Findings 1 and 2 both fixed. Serial log stops dead at Timer: CPUID frequency unavailable; trying PM Timer... — everything before that point (console, PMM, VMM, IDT, APIC, I/O APIC, i8042) comes up identically to the -O0 boot. This is calibrate_tsc_with_pmtimer() (arch/amd64/timer.c:560-589), a mainline-only (pre-interrupt-enable) busy-wait loop reading the ACPI PM Timer port via inl() (genuinely volatile inline asm, confirmed not compiler-eliminable) until it observes 1000 real PM-timer ticks elapse, bounded by a 5,000,000-iteration hard timeout. Added a one-shot diagnostic probe (per this document's established write/run-once/capture/revert discipline) printing iters/start/cur/delta every 0x100000 iterations: exactly one line fired (iters=0 start=9245289 cur=9245344 delta=55) in a 55-second bounded observation window, then nothing — the loop never reached its next million-iteration checkpoint. Not yet root-caused: this is consistent with either the 5,000,000-iteration timeout itself taking several real minutes to exhaust (implying each iteration got far more expensive under -O2, mechanism unknown) or a genuine non-terminating condition; the evidence gathered doesn't yet distinguish the two. Probe reverted after capture, not left in the tree. Makefile.starkernel reverted to -O0 again; nothing broken landed.

    Follow-up probing, 2026-08-11. A second, finer-grained one-shot probe (checkpoint every 1000 iterations instead of 0x100000) caught one full run: iters=0 delta=67, then iters=1000 delta=951 (~462 TSC cycles/iteration, unremarkable) — genuinely close to the target_ticks=1000 exit condition — then nothing for the rest of a 40s window; the loop never reached iters=2000. A third run with per-iteration granularity around the 9001100 range caught only iters=0 before a 400-second (6.7 minute) bounded wait expired — worse than the first two runs, and inconsistent run-to-run in exactly how far it gets, which itself is informative: this isn't a fixed, deterministic slowdown factor.

    Hypothesis tested and disproven: single-threaded TCG's cooperative scheduling being starved by an -O2-tightened loop body — meaning the emulated PM Timer's own host-side update never gets a chance to run, so cur stops advancing from the guest's perspective — seemed well-supported by a concrete precedent: calibrate_apic_timer() in apic.c:382-406 (a sibling calibration loop, same file family, same TCG target) already calls arch_relax() (x86 PAUSE, include/starkernel/arch.h:76, "architecture-friendly pause/yield hint inside busy loops") on every iteration of its own spin-wait, while calibrate_tsc_with_pmtimer() never had it. Added #include "arch.h" and one arch_relax(); call per loop iteration, matching that precedent exactly. Result: no change. Same exact stall point (Timer: CPUID frequency unavailable; trying PM Timer..., then nothing), confirmed with a fresh 60-second bounded wait. Reverted (both the #include and the arch_relax() call, plus Makefile.starkernel back to -O0); amd64 boots clean again at -O0, confirmed.

    Where this leaves it: the starvation hypothesis isn't confirmed wrong, exactly — x86 PAUSE is documented to matter most for VM-exit-based virtualization (KVM), and this project's entire acceptance methodology is TCG-only (software emulation, no VM-exits to hand control to a scheduler); PAUSE alone may simply not be the right primitive for whatever TCG-specific mechanism is actually at play here, if that theory holds at all. No alternative hypothesis has been tested. This is squarely a "stop and report" point per this document's own §25.0 rule 5 — root-causing further needs either deeper TCG/QEMU knowledge than has been brought to bear so far, or a fundamentally different diagnostic approach (e.g., instrumenting on the QEMU/host side rather than guest-side probes) that's a bigger step than another guess-and-check pass. Notable stakes: per CLAUDE.md, QEMU/TCG is not a target for this project, it's the acceptance target — this bug blocks the actual thing that matters, not an edge case.

    Root-caused and fixed, 2026-08-11 — it was never a hang. Used QEMU-side instrumentation as Captain Bob directed: -d int execution-exception tracing plus a chardev-based monitor socket (the older bareword -monitor unix:...,server,nowait syntax silently failed to create a socket on QEMU 10.2.1; the modern -chardev socket,... + -mon chardev=... form works). The trace showed the CPU never looping at all: a genuine #DE (divide error) at the second muldiv64() call site, cascading through a double fault into a triple fault — which -no-reboot converts into a silent, clean QEMU exit (exit code 0, empty stdout), indistinguishable from an infinite hang from the guest side without tracing. This is also why the earlier arch_relax() attempt did nothing: it was solving a hang that didn't exist.

    Register state at the fault: RAX=0xe8d4a51000 — exactly 1000 × 1,000,000,000, confirming the values feeding muldiv64(elapsed_ticks, 1000000000ull, PMTIMER_FREQ_HZ) (timer.c:597, elapsed_ticks at loop exit == target_ticks == 1000). That product fits entirely in the low 64 bits, so mulq's high-word output (RDX) is 0. muldiv64()'s inline asm declared RDX as a plain output ("=d"(hi)) — telling GCC only "I want to read RDX's value after this block," with nothing indicating that mulq writes RDX before divq needs a different value (the divisor c) out of it. Nothing stopped the register allocator from placing c itself in RDX, which mulq then overwrote with 0 before divq ever read it — dividing by a corrupted 0 instead of the intended PMTIMER_FREQ_HZ (3,579,545). Worked by accident at -O0's more conservative allocation; -O2 actually hit it.

    A unsigned __int128 rewrite was tried first (cleanest fix in principle) but needs libgcc's __udivti3 for the general 128÷64 case, undefined in this freestanding, -nostdlib build — not viable, the same class of problem as the putc/getc finding earlier in this item. Fixed instead by declaring rdx a pure clobber rather than an output — the same pattern the Linux kernel's own mul_u64_u64_div_u64 uses. A clobber tells GCC the register is used internally for the whole asm block and must never be allocated to any operand, which is exactly the guarantee the previous constraint list was missing. Also added a defensive end_tsc < start_tsc guard in the caller — this file's own comments already flag TSC non-monotonicity as a real risk under TCG hypervisor mode, and an underflowed delta_tsc would hit the same class of quotient-overflow #DE; not the bug that was actually found, but a real latent risk given what this function's own documentation already says about the environment. Fix committed (b43e51a), three-arch acceptance boot clean at unchanged -O0.

    Verified this specific stall is gone: with -O2 re-enabled (uncommitted), amd64 boot now proceeds far past this point — through capsule birth, Mama birth, ACL pinning, and into Hermes's word registration — before hitting a second, different, not-yet-fixed fault (below). Given that, -O2 was not left enabled; reverted to -O0 again.

    Finding 4, NOT fixed, blocking this item: a second, distinct fault during Hermes word registration at -O2. Same tracing technique, same deterministic reproduction (stuck at the exact same serial line, Registering FORTH-79 arithmetic words..., across repeated runs). Different signature this time: check_exception old: 0xffffffff new 0xd — a #GP (General Protection, vector 0xd) directly, not a #DE, with error code 0x102. Decoded per the x86-64 selector error-code format (bit 0 = external, bit 1 = IDT table indicator, bit 2 = TI, bits 315 = selector index): IDT=1, index = 0x102 >> 3 = 32 — exactly APIC_TIMER_VECTOR. This fires during otherwise-unrelated Hermes word-registration work, which is consistent with the periodic 100 Hz timer interrupt (that fires continuously in the background regardless of what else is running) hitting a problem with its own IDT descriptor — not yet traced to a specific cause the way Finding 3 was; no register-state correlation to a specific call site has been done yet for this one. Same cascade shape as before (#GP#GP → double fault → triple fault → silent QEMU exit), so this was very likely mis-diagnosed as "part of the same hang" before tracing distinguished the two. -O2 reverted; muldiv64()'s fix is kept (real, independently verified).

    Follow-up, 2026-08-11 — deeper tracing, not yet root-caused. Computed the loader's true runtime-vs-link-time relocation delta (printed &arch_interrupts_init at runtime, diffed against its nm link-time address: delta 0x3d5b9000) to correlate fault addresses against the actual binary — necessary because the running image is starkernel_loader.efi (a PE, MONOLITHIC_BUILD-embedded, genuinely relocated at UEFI load time), not the separately-linked starkernel_kernel.elf first assumed; that separate starkernel_loader.elf debug-symbol target (Makefile.starkernel:688) fails to link (R_X86_64_32S relocation error) and isn't part of all — a pre-existing gap, not touched.

    With the delta in hand, the fault RIP (0x3d5cc0e0/0x3d5cc0e0-ish across runs) decodes to link address 0x130e0 — the very entry of log_message() (vm/host/shim.c). This is almost certainly coincidental, not causal: log_message() is called on essentially every HADES/ECW dispatch during word registration, so an async 100 Hz timer tick landing exactly at its entry is unsurprising and doesn't implicate the function itself.

    Used QEMU's monitor to enable -d exec,int execution+exception tracing (the earlier -d int-only session had already established the fault; this pass wanted the instruction-by-instruction lead-up). Two attempts to start tracing late (right before the danger zone, via the monitor's stop/log/cont sequence, to keep the trace small) both failed — QEMU reached the crash and exited before the host-side script could connect, meaning the window between a detectable serial checkpoint and the actual fault is shorter than the host's reaction latency even under -S synchronization races. Fell back to tracing from cold boot with -S + immediate log exec,int before any cont (reliable, but expensive: ~2.7 GB / ~39M lines per attempt; both trace files deleted after use, not committed).

    That full trace shows the CPU executing a small, tight, three-block repeating loop (0x3d5bd1200x3d5bcad90x3d5bcd05) many times immediately before the fault — a completely unremarkable pattern consistent with normal per-word dictionary-registration work, not a wild jump in progress. Immediately after: Stopped execution of TB chain, Servicing hardware INT=0x20 (APIC_TIMER_VECTOR), and then the #GP — with IDT= already showing limit=0 at that exact instant. Re-examined the error code (e=0102) in this light: IDT=1 (bit 1), index 0x102>>3=32 — consistent with either "descriptor 32 specifically is bad" or "the table's limit is exceeded for any index," and an x86-64 IDT-limit violation reports the attempted vector number in the error code either way, so this doesn't distinguish between "one corrupted descriptor" and "the whole table became limit=0" — both remain live explanations.

    Ruled out: a second, illegitimate lidt call — confirmed only one call site in the entire codebase (interrupts.c:476, one-time M4 boot setup), and searched the exec trace for any later execution of arch_interrupts_init()'s address range — found none; the only occurrences are the single legitimate boot-time cluster. Not yet established: the actual corrupting write/instruction. The repeating loop immediately preceding the fault looks unremarkable in the trace, which means either (a) the corruption happened earlier still and IDTR/the IDT table sat silently wrong for a while before the next timer tick exposed it (most likely, given nothing in the visible lead-up looks like a wild jump), or (b) it's a genuine TCG emulation artifact rather than a guest-code bug at all (not tested — would need a different accelerator or QEMU version to rule in/out, and this project's methodology is TCG-only by design).

    Where this leaves it: pinpointing the exact corrupting instruction from here needs either GDB-level single-stepping (QEMU's -s -S gdb stub + a matching cross-gdb, watching the idt[] array and IDTR directly across the whole pre-fault window) or a fundamentally different narrowing strategy — a bigger tooling step than the tracing done so far, and one this session did not attempt. 4.5d cannot complete until Finding 4 is resolved.

    Finding 4 root-caused, 2026-08-11 — not corruption, mis-construction; static analysis, no GDB session needed. Before single-stepping, checked whether idt[] had a suspicious -O2-layout neighbor (per Findings 13's pattern) via nm/readelf on a fresh -O2 build — and found something more direct: arch_interrupts_init() itself, disassembled from the -O2 object file, is only 30 bytes (lidt off an uninitialized 6-byte stack slot → mask both PICs → ret). The entire 256-entry idt[]-population loop (set_idt_entry() calls, interrupts.c:461-468) and the idtr_desc.limit/.base assignments (interrupts.c:470-472) are gone from the compiled output — dead-store eliminated. Root cause: lidt()'s inline asm (interrupts.c:163-165, __asm__ volatile ("lidt (%0)" :: "r"(idtr_desc))) uses an "r" (register) constraint, telling GCC only that the asm reads the pointer value — nothing tells it the asm dereferences the pointee, so nothing anchors the writes to idt[] or idtr_desc's fields as observed. volatile blocks reordering/removal of the asm statement itself but does nothing for dead stores feeding into it. At -O0 this is invisible (nothing is eliminated without optimization); at -O2 GCC removes the whole population loop as unobservable, and IDTR gets loaded with stack garbage instead of the real table — explaining both the #GP on the first APIC timer tick that happened to hit a garbage descriptor slot, and the earlier limit=0-flavored trace evidence. Same bug class and same fix shape as Finding 3 (muldiv64): an inline-asm operand constraint too weak for what the asm actually touches. Precedented fix (this is exactly how Linux's own load_idt() is written): change the constraint to "m"(*idtr_desc) so GCC knows the asm dereferences the struct. Not applied — reporting only, per Captain Bob's Law. -O2/-U_FORTIFY_SOURCE build used only to produce the diagnostic object file, then reverted; tree confirmed clean, back at committed -O0. 4.5d still cannot complete until this fix is written and verified.

    Fix applied and verified, 2026-08-11 — all three architectures. lidt() (interrupts.c:163-165) changed to __asm__ volatile ("lidt %0" :: "m"(*idtr_desc));. amd64: disassembly of arch_interrupts_init() at -O2 now shows the full (GCC-vectorized) 256-entry population loop instead of the 30-byte stub; full acceptance boot reaches ok> clean, past the prior Hermes-registration fault point, POST Failed: 0, Mama/Hermes parity and dict-hash records present. Warning diff against a fresh -O0 baseline: byte-for-byte identical (3040 warnings both sides, zero new).

    Checked aarch64/riscv64 for the same bug class first (before building/booting): neither has an equivalent. Both install their vector/trap table entirely in hand-written .S (isr.S: msr vbar_el1, x0 on aarch64, csrw stvec, t0 on riscv64), not via a C loop populating an array that a separate C function then hands to inline asm through a pointer-only constraint. Their C-side inline asm is limited to scalar CSR/system-register reads (mrs/csrr) with correctly-matched "=r" constraints — not the same shape as the lidt bug, nothing to fix.

    Both then built and booted clean at -O2 anyway, no changes needed: aarch64 and riscv64 both reach ok>, POST Failed: 0, and — notably — identical dict-hashes to the amd64 -O2 run (0x211a35043331d472 Mama / 0x97502db38aec4d04 Hermes), confirming cross-arch parity holds under optimization, not just per-arch self-consistency. Warning diffs: riscv64 identical to -O0 (3037/3037) on first pass. aarch64's first pass showed a spurious +289-warning discrepancy in -Wmissing-field-initializers counts (already a documented, somewhat unstable warning class per this file's Code Standards section) that turned out to be parallel-make (-j) stderr interleaving corrupting the log, not a real -O2 regression — confirmed by rerunning both -O0 and -O2 serially (-j1): identical 3041/3041, zero new warnings.

    Landed, 2026-08-11. Captain Bob approved: -O2/-U_FORTIFY_SOURCE applied to COMMON_CFLAGS in Makefile.starkernel permanently (not reverted this time), fix and this writeup committed together.

  • 4.5e — Three-arch acceptance boot with optimization enabled. Depends on 4.5d. This is the actual gate, per CLAUDE.md's non-negotiable acceptance criteria — a kernel that has only ever been built and accepted at -O0 has no track record at any other optimization level, so this is a full fresh verification, not a formality. Done when: all three architectures boot clean to ok>/zuse)ok>, POST's dictionary-hash parity check still passes, logs captured in logs/ per CLAUDE.md. Refs: 4.5, CLAUDE.md acceptance criteria.

    Done, 2026-08-11. All three architectures verified in the same session that landed the fix (see 4.5d's log): amd64 (logs/20260811-164545/amd64/), aarch64 (logs/20260811-164924/aarch64/), riscv64 (logs/20260811-165027/riscv64/) — all reach ok>, POST Failed: 0, dict-hashes identical across all three (0x211a35043331d472 Mama / 0x97502db38aec4d04 Hermes), confirming cross-arch parity holds under -O2.

  • 4.5f — Retry the 4.4g console_fb_init() reorder now that optimization is live. Depends on 4.5e. This is the original motivating case: both attempts this session (bare, and with the fb_scroll_rows() volatile fix alone) stalled boot indefinitely at -O0. Done when: the reorder from 4.4g completes in a reasonable time (no multi-minute stall) on a three-arch boot, and an amd64 screendump shows the fuller boot transcript that was 4.4g's whole point. Refs: 4.4g, 4.5.

    Aside, not a task: the ACL-RWT DoE campaign's overhead numbers (CLAUDE.md, "measured overhead +0.0054%+0.0088%") were all measured at -O0. Nobody has asked whether those numbers still hold, or even remain comparable, once the kernel is built at a different optimization level. Not scoped here — flagging so it isn't lost if 4.5 ever lands.

    Experiment run, 2026-08-11 — uncommitted, reverted after capture, per Captain Bob's explicit instruction this was informational only (4.4g's reorder decision is still open, not made by this experiment). console_fb_init()'s call site moved in kernel_main.c from after capsule_birth_mama() to just before it (same code, no logic changes). amd64, -O2: boot completed cleanly, reached ok> in well under the bounded 300s test window (previously stalled indefinitely at -O0 — 12,700+ lines, still running after 3 minutes). Real cost, not zero: 2785 heartbeat ticks at 100 Hz vs. ~176231 ticks on the non-reordered -O2 boot from 4.5d/4.5e — the framebuffer-visible fleet-birth/self-test transcript adds substantial real time (screen-draw + scroll volume), it just no longer hangs. Screendump confirms the actual point: the framebuffer now shows the full HADES/ECW/Stadium/self-test transcript, not just the small post-birth tail — evidence/amd64/qemu-screenshot-20260811-170329-4.5f-reorder-experiment.png. Captured via a QEMU HMP monitor socket + screendump (the qemu Makefile target runs -display none with no monitor by default; this run added -chardev socket,id=mon0,... -mon chardev=mon0,mode=readline to get one). Makefile.starkernel's amd64 qemu target deadline was temporarily dropped from 43200s to 300s for this one test run, then reverted; kernel_main.c's reorder was reverted immediately after the screendump was captured. Not run on aarch64/riscv64 — this was a single-architecture feasibility check, not the item's own three-arch acceptance pass, which only applies once the reorder is actually decided and landed.

    What this answers and doesn't: confirms the stall was specifically an -O0 cost problem, not a correctness bug in the reorder itself — -O2 alone is sufficient to make it viable. Does not decide 4.4g's open question (whether the reorder is wanted); it only removes "it hangs" as a reason not to. The real-cost number above (≈12× more heartbeat ticks) is new information for that decision and wasn't available before this run.

    4.4g decided "yes," reorder landed for real, 2026-08-11 — see 4.4g's own record for the full three-arch verification and final screendump. This item's own done-when (three-arch boot with no stall, amd64 screendump of the fuller transcript) is now satisfied by that same landing, not a separate pass.

  • 4.6 — Artemis last. It works today; it is the thing that cannot be broken. Refs: §10.


25.6 Phase 5 — Verification and measurement

  • 5.1 — Re-run the DoE on the new substrate. A green POST suite is not evidence that K holds; those are different claims. Refs: §10.
  • 5.2 — Isabelle/HOL. One datatype, one index space, one conservation theorem. Refs: §13, §22.3.
  • 5.3 — Shrink the subsystem documents. ARTEMIS.md, HERMES.md, CONSOLE.md, TRIPOD.md should each reduce to roughly three lines. Any that grows is fighting the design. TRIPOD.md also needs its Immediate Goal rewritten — it currently requires Hera to spawn Hermes and Artemis at boot, which 0.1 undoes. Refs: §11.

25.7 Reported, not scheduled

Found while reading. Not fixed, not assigned. They become items only if Captain Bob says so.

  • Fleet heat leaks on every multi-VM touch. vm_physics_touch() fans out (moved_total * heat) / others_total per VM in integer arithmetic (capsule_vm_physics.c:304-305); the shares sum to less than moved_total, so total fleet heat drifts downward monotonically. VM_PHYSICS_EPSILON_Q48 is 5% of Q48_ONE, so a long enough run would trip VM-CONSERVED?. Nobody has measured the rate. This is a live defect in a conservation law the project makes claims about — see §20.2.

    Note to self, flagged by Captain Bob 2026-08-04, before item 5.1. Currently invisible: with Tripod pruned to Hera alone (item 0.1), others_total is always 0, so this path is never exercised — nothing today can trip it. It becomes reachable, and therefore measurable, the moment Phase 4 restores Hermes/Artemis. Check this before trusting item 5.1's DoE re-run as evidence that VM-CONSERVED? holds: a clean run on a document this careful about falsifiability elsewhere, sitting on top of an unmeasured, monotonic leak, would be a false negative, not a green light. Still reported-not- scheduled on purpose — becomes its own item only if Captain Bob says so.

  • hotwords_cache_promote() writes NULL into the ring if word is NULL and the cache is full (physics_hotwords_cache.c:363-364). Unreachable today.

  • heartbeat_trust() is exported and has zero callers.

  • m5_time_trust and m5_variance (include/vm.h:315-316) are declared and never used.

  • src/*.c.bak files are tracked in git at the src/ top level.

  • The bump-z / bump-y targets in the hosted Makefile reference version macros that do not exist in the generated include/version.h.

  • Kconfig/menuconfig has never been exercised end-to-end. Every Kconfig knob added so far, including item 4.1's STADIUM_WORD_HEAT_QUANTUM/STADIUM_WORD_COOL_RATE_Q48, has only ever been verified via its Makefile.starkernel kconfig_int/kconfig_bool default. Nobody has run make -f Makefile.starkernel menuconfig, changed a value, and confirmed it actually flows through to a build. Flagged by Captain Bob 2026-08-05.

  • stadium_admit() never writes stadium_owner[idx], on either the free-list-pop or the eviction-fallback path (found during item 4.1's design pass, 2026-08-05). Harmless today — every cell's owner byte is 0 (Hera) from stadium_boot_init(), and Hera is the only VM with a quota — but once item 4.2 restores Hermes, a resident's evict-credit (item 4.1's reservoir accounting in stadium_evict()) would flow to the wrong VM's reservoir unless this is fixed first.

  • Taxonomy and lexicon. Raised by Captain Bob 2026-08-04, mid-item-3.1. This document's physics-flavored vocabulary (heat, mass, density, patron, Stadium, and the rest) needs a glossary that is explicit these are named analogies, not physical claims — and that also covers the growing set of Kconfig build knobs (STADIUM_MAX_VM_COUNT and its siblings) so the terminology in code, Kconfig help text, and this document stays one language instead of drifting apart. Not scoped, not placed in a phase. Captain Bob: "I guess that we didn't finish out FABRIC.md quite as much as we thought."

25.7.1 Second review pass — 2026-08-03, pre-coding. Awaiting rulings.

A full re-read of this document as it stood after the first review's corrections, looking for what would break a lower-capability model working the punch list.

Status: all fourteen findings closed, 2026-08-03. A1 ruled (virtual tick) and applied to §16.4/§17.1/§18.4 and items 0.8/2.1. B1 verified (no DTB access; fixed into 0.3/0.6), B2 verified (no FP restriction on any arch; fixed into 0.2/0.5), B3 applied (EL governs the vector path; 0.4/0.5). C1C7 applied to their items; D1D3 swept. The findings below are preserved as the record of what was found and why.

GAP-A1 — §16.4's central inference is unsound. NEEDS RULING RULED 2026-08-03: virtual tick.

Applied. The recommended resolution below was adopted by Captain Bob. §16.4, §17.1 and §18.4 now carry the ruling; items 0.8 and 2.1 were reworded to it. Item 0.10 needed no change: with the engine staying execution-paced, its double-boot dict-hash check is a valid regression guard and the amd64-as-control framing is accurate again, since 0.8 no longer touches engine plumbing on any architecture. The argument below is preserved as the record of why.

§16.4 claims: "same input → same tick ordinal → same reap and inference events → same hash." The last arrow is invalid. The hash covers execution_heat, which is co-written by two streams — word executions (increments) and engine ticks (decay). Once ticks come from a hardware timer, where tick N lands relative to the instruction stream is wall-clock-dependent: under TCG, run A takes tick 42 after word #1000, run B after word #1017. Decay interleaves differently, heat trajectories diverge, hashes differ. Firing on tick count fixes the engine's schedule; the hash measures the composition of the two streams, and that is not fixed.

Blast radius:

  • Item 0.8 is ambiguous between two different kernels. Reading (i): the bottom half services only TIME-TRUST bookkeeping — safe, parity holds, but "compudynamics on the tick" did not actually happen. Reading (ii): the bottom half drives the engine/decay from the hardware tick — parity breaks by construction, not by implementation error.
  • Item 0.10's double-boot dict-hash check then fails under reading (ii), and no implementation effort can fix it.
  • Item 2.1's corrected acceptance (identical fleet heat sum across runs) is still unachievable under a hardware tick: touch amounts scale with elapsed ticks between fixed execution points, elapsed ticks vary run to run, so truncation losses vary, so the sum varies. The first review's amendment did not go far enough.
  • The tempting split does not survive either. "Hash-covered state on execution ticks, TTLs on hardware ticks" fails because TTL expiry has side effects on the instruction stream — a message expiring versus being delivered changes what runs, which corrupts heat downstream. §17.1's "one tick" instinct was right; it picked the wrong clock.

Recommended resolution (not decided): the engine's tick is a virtual tick — a pure function of the execution stream, which is exactly what exists today and why parity holds today. The hardware heartbeat becomes: the TIME-TRUST instrument (now real on three ISAs instead of one), the idle wake source, and the driver of nothing that feeds patron state. Scripted/parity runs stay bit-identical; interactive idling pumps virtual ticks from the REPL poll loop so TTLs still expire in real time, in a context where parity was never claimed. Phase 0's timer work remains fully justified as instrument and substrate. Under this ruling §16.4, §17.1, §18.4 and items 0.8, 0.10, 2.1 all need rewording. The alternative — re-baselining the parity claim itself — touches the patent support material and is not recommended.

GAP-B — unverified prerequisites (each is a short read; none has been done)

  • B1 — Device tree reachability. Items 0.3 and 0.6 instruct "read from the device tree" (0.6 forbids alternatives). Whether the loader captures the DTB from the EFI configuration table into BootInfo is unverified. If it does not, 0.3 and 0.6 silently require loader plumbing that has no punch item. Read uefi_loader.c / BootInfo first.
  • B2 — FP/SIMD in the ISR path. Items 0.2 and 0.5 save integer state only. If the kernel is not built with -mgeneral-regs-only (aarch64) / soft-float (riscv64), a C interrupt handler may clobber FP registers the interrupted mainline was using. One grep of Makefile.starkernel settles it; the items should carry the check.
  • B3 — 0.4's EL detection does not govern the vector path. 0.4 refuses to hardcode the EL for timer registers, but 0.5 hardcodes ELR_EL1/SPSR_EL1/eret, and today's isr.S installs VBAR_EL1. If EDK2 leaves the kernel at EL2, exceptions vector through VBAR_EL2 and 0.5's entire edit targets a table that is never consulted. EL determination must govern VBAR, the saved-state register forms, and the timer set.

GAP-C — defects in punch items a literal implementer will hit

  • C1 — Item 0.1 contradicts itself. Body says remove "blocks 20502059"; Refs says block 2050 survives. The delete set is 20512056 + 20582059; 2050 is edited (banner call kept, handshake/broadcast calls removed). A literal reading deletes the banner.
  • C2 — Items 0.2 and 0.5 have unsatisfiable acceptance. Both require having "taken and returned from at least one trap/IRQ," but at 0.2 no timer is armed (0.3) and at 0.5 there is no GIC (0.6) and no armed timer (0.7). No interrupt source exists at those stages. Fix: 0.2/0.5 accept on "boots unchanged, no regression"; the took-and-returned evidence moves to 0.3/0.7.
  • C3 — Item 0.3 lost the two silent-failure modes. The SBI timer is one-shot: a missed re-arm stops the heartbeat forever with no error. sie.STIE is also unmentioned. 0.7 says "re-armed each tick"; 0.3 must too.
  • C4 — §23.4 #4 blocks item 3.1 but is not a punch item. The continuation-cell encoding gates 3.1 by 3.1's own text, but rule 1 walks numbered items and nothing ever schedules it. It should become item 1.12.
  • C5 — Item 1.11's deferral is not a formal prerequisite. It says "do not settle speculatively" but states no blocker, so rule 1 would schedule it. Add "(blocked on 4.3)."
  • C6 — Item 0.10 misc. "amd64 output unchanged" treats amd64 as a control, but 0.8 changes amd64's engine plumbing by design — stale framing. "TIME-TRUST and variance sane" is soft; sharpen to trust near Q48_ONE, variance small relative to the new expected_delta.
  • C7 — The commit template hardcodes "Claude Opus 5." Whichever model implements will either violate the template or misattribute. Genericize.

GAP-D — inconsistencies left by the layered amendments

  • D1 — Three passages still argue from the K-justification the first review removed. §17.3 ("wastes the bounded capacity that gives K a fixed denominator"), §17.5's sizing argument (same phrase), and §17.6(d) — the worst, since it cites §2 for a claim §2 now explicitly disavows ("Without an inescapable bound, K is bookkeeping — §2 says this in as many words").
  • D2 — §19.6 #1 and #2 read as open but are resolved (#1 by §23.1 with the residue in §23.4 #4; #2 by §24.3). §17.4 got strike-through treatment; §19.6 did not.
  • D3 — §20.3 still says "LEANING nested" one section before §21 decides it. One forward pointer fixes it.

What held up under this pass

The patron taxonomy, behaviours-not-kinds dispatch, the three quantities, the two-valued cell union, the nested-elastic-quota layout, the identity/mass invariant, Hera's pin-and-panic, and §25.0's rules themselves. None of them moved.

Triage order when this is picked up: rule on A1 first — it decides what item 0.8 even means. B1/B2 are ten-minute reads. C and D are mechanical once A1 is ruled. Nothing should go to a coding model before C1, C2 and C3 are fixed at minimum — those are the ones it will hit in its first hour.


26. The hardware heartbeat must itself be adaptive — RULED 2026-08-03

Raised mid-0.8: the punch list, as written, makes the hardware tick a fixed-rate instrument (100 Hz, unconditionally, on all three architectures — the apic_timer_init(..., 100) calls built across items 0.10.7). That is correct for what §18.4/§18.5 require of the engine — the virtual tick stays execution-paced regardless. But it leaves the physical heartbeat monotonic, and the physical heartbeat was never supposed to be monotonic. Captain Bob: "the heartbeat is adaptive... it spreads the heartbeat out when your heart goes faster when you're running... it's gotta be an adaptive heartbeat, that's the whole thing to cage variance."

26.1 The finding — an adaptive-rate engine already exists, and it is orphaned

vm_runtime.c:703-752 ("Loop #7 — Adaptive Heartrate") computes a bounded adaptive period, vm->heartbeat.tick_target_ns, from the same ANOVA-driven stability signal Loop #5 already uses: early-exit (stable) slows it down, full-inference (volatile) speeds it up, ±25% per step, clamped to [¼×, 4×] of a configured base. This is real, executing code, faithful to the design in the sibling StarForth repo's own docs/working/architecture/03-architecture/heartbeat-system/architecture.md (Option A vs. Option B). It is not a proposal — it already runs, on every vm_tick().

It is orphaned. tick_target_ns is written to vm->heartbeat.worker->tick_ns (include/vm.h:286,289), and worker is always NULL in kernel builds — nothing in src/starkernel/ reads tick_target_ns at all. The mechanism that was meant to consume it, a pthread_create()-based background worker (heartbeat_thread_main(), vendored into vm_bootstrap.c:310), is deliberately and redundantly disabled for kernel builds (Makefile.starkernel:293,338, HEARTBEAT_THREAD_ENABLED forced to 0 twice) — correctly: this is a bare-metal single-hart kernel, there is no pthread implementation, and the mechanism's own history (segfault-analysis.md in the StarForth repo) is a real concurrent-access bug against RollingWindowOfTruth, fixed by a mutex a single hart doesn't need and can't cheaply provide (§21.2, item 0.9).

So: the decision logic is real, tested by inheritance, and currently produces a number nothing downstream ever reads. Phase 0 as written would ship a heartbeat that looks adaptive in the source tree and is not adaptive on the wire.

26.2 This does not reopen GAP-A1

§18.5 already proved the general shape of this argument for Loop #5 and is directly reusable for Loop #7: adaptation is safe exactly when its inputs are execution-derived, because then the decision to change the period is itself a deterministic function of the execution stream, not of wall-clock jitter. §18.5 point 3 already establishes that every InferenceInputs field feeding this ANOVA machinery — rolling window, trajectory length, prefetch hit rate, hot/stale word counts, total heat, word count — is execution-derived with zero timing input. Loop #7's stable/volatile classification is downstream of that same machinery. Nothing new needs proving there.

What must not change, and does not under this design:

  • The virtual tick stays the engine's clock (§18.4, unchanged). Adjusting the physical re-arm period changes when TIME-TRUST samples land and how often the idle path wakes — it does not move decay, reap, or inference off the virtual tick onto the hardware one.
  • The hardware tick's output still feeds nothing that reaches the parity hash (§18.5 points 1, 5, unchanged) — only its input (the period it's told to re-arm at) becomes execution-derived instead of fixed.
  • What was already true and already inert under §18.5 — that wall-clock interrupt arrival timing is not reproducible run to run — stays true and stays inert. Nothing patron-facing ever depended on it; this design doesn't change that.

26.3 The scale mismatch, and the ruling

tick_target_ns's configured base, HEARTBEAT_TICK_NS (include/starforth_config.h:71), is 10000ULL — 10 microseconds. Its live range under Loop #7's ±25%/[¼×,4×] bounds is therefore 2.5µs40µs. The hardware timer configured throughout items 0.10.7 runs at 100 Hz — 10 milliseconds. That is a three-orders-of-magnitude mismatch: reprogramming the physical re-arm to the literal tick_target_ns value would fire the timer 25,000400,000 times a second, which on a bare-metal single-hart kernel means the core spends effectively all its time in trap entry/exit and the REPL is never reached. HEARTBEAT_TICK_NS was tuned for a hosted OS thread's sleep granularity, not a bare-metal ISR period.

RULED (Captain Bob, 2026-08-03): same relationship, kernel-appropriate scale. The mechanism must be real and load-bearing — a genuine, measurable effect on the physical re-arm period, the same kind of effect the original hosted pthread experiments showed — but computed against the 10 ms / 100 Hz base already established for this kernel, not the 10 µs hosted base. Loop #7's decision logic (stable → slower, volatile → faster, ±25% per step, clamped [¼×, 4×]) is reused unmodified; only the base it is applied to changes.

26.4 The mechanism — no thread needed

Captain Bob authorized building a kernel-native thread/task if one were required ("if we gotta run a thread or whatever, it doesn't matter"). One is not required, and adding one would need a preemptive scheduler this single-hart kernel does not have (§21.2 already rules out real locking for exactly this reason). The existing shape gets there without new infrastructure:

  • vm_tick() already calls Loop #7 on the mainline path (execution-paced, never in interrupt context) and already produces a fresh tick_target_ns there.
  • Item 0.8 already introduces a shared heartbeat.c owning the top/bottom-half split. That file is the natural owner of one new piece of state: the current adaptive period, set by a new heartbeat_set_adaptive_period_ns(uint64_t ns) (called from vm_runtime.c's Loop #7 site, scaled to the kernel base per §26.3) and read by a new heartbeat_next_period_ns(void).
  • Each architecture's existing re-arm function (apic_timer_rearm(), riscv64_timer_rearm(), the aarch64 equivalent) already runs in interrupt context at the top of every tick (§18.4's "one tick" call sites, unchanged). It converts heartbeat_next_period_ns() to that architecture's raw counter units — a conversion each already does today for its fixed period — instead of using a hardcoded constant.

No new concurrency: the write happens on the mainline execution path, the read happens in interrupt context, and the value read is whatever was last written — the same single-writer/ single-reader shape every other piece of ISR-read, mainline-written state in this kernel already has (§21.1's finding that locking here is already free, because nothing here is actually concurrent on one hart). No pthread, no kernel task, no scheduler.

26.5 Open, deferred

  • Multi-VM: today Hera is the only VM, so "whose tick_target_ns drives the one physical timer" has one answer. Not resolved for when Hermes/Artemis return — deferred, not applicable yet (consistent with §20.5's other Tripod-return deferrals).
  • HEARTBEAT_TICK_NS's name and its hosted-scale value are unchanged by this ruling — the kernel-side base (10 ms) is a separate constant, not a redefinition of the hosted one. Naming the kernel constant is an implementation detail of the item that builds this, not a document-level open question.

RULED. Item 0.8 is amended below to include this; no new punch-list item is needed — this is squarely inside what 0.8 already builds (heartbeat.c, the three re-arm call sites).

27. Console — the drawing fabric (groundwork, 4.3.1)

STATUS: design-stage, 2026-08-07. Supersedes .claude/CONSOLE.md as the authoritative Console design document. CONSOLE.md was a rough prior working draft and is not edited further; nothing in it should be treated as decided just because it is written down there. This section covers 4.3.14.3.4 only — the groundwork slice: verify the hardware boundary works and stand up the coordinate machinery. It is explicitly not the full Console specification (fonts, scrolling-as-a-VM-behavior, message protocol from Hermes, etc.) — those are later items under 4.3, scoped once this slice's checkpoint (4.3.4) is reviewed.

27.1 The hardware boundary already exists (4.3.1)

console_fb_init() (hal/console.c:269) calls fb_init() with real UEFI GOP data from boot_info->framebuffer, but only after POST completes, immediately before the REPL starts (kernel_main.c:800-807). include/starkernel/framebuffer.h already exposes raw pixel primitives: fb_put_pixel, fb_fill_rect, fb_draw_glyph (8×16 cells), fb_scroll_rows. There is nothing to build to get pixels on screen — 4.3.1's task is verification, not construction: draw a simple, orientation-revealing test pattern with the existing primitives and confirm it displays right-side up.

27.2 No screenshot capability exists today (4.3.2)

All three qemu targets in Makefile.starkernel run with -display none and attach only a serial chardev socket (for the log) — no monitor, no QMP socket. There is no way today to issue QEMU's screendump command. 4.3.2 adds a monitor/QMP socket (mirroring the existing serial-socket pattern) so the framebuffer can actually be inspected as a .ppm after a run.

27.3 Coordinate system (4.3.3, 4.3.3a, 4.3.3b)

  • Origin bottom-left, (0, 0). Traditional Cartesian, not raster/top-left-Y-down. Reinterpreted 2026-08-07: §27.3 originally said the Y-flip "must live at the lowest primitive layer," written before the language split (below) was decided. PLOT is the true hardware boundary and is deliberately raster-native with no Cartesian awareness at all — same posture as BLOCK/UPDATE staying dumb about policy. CART-Y, in FORTH, is the lowest Cartesian-aware layer, which satisfies the original intent (nothing above it ever thinks about the flip) even though the flip itself lives one level up from the raw pixel write.
  • Z axis, depth-into-screen (not height-off-ground). Confirmed 2026-08-07: this is heading toward real 3D animation over time, not a single static scene — Z is being added now because retrofitting it later is more expensive than building it in from the start.
  • Projection: fixed orthographic, for now. Explicitly a placeholder — not the final projection, no perspective/camera work yet. Angle settled 2026-08-07: true 45° cavalier (both X and Z axes drawn at 45° off horizontal) — sx = x + z·cos45, sy = y + z·cos45. Ruled out the 2:1 pixel-art isometric convention (~26.57°); no reason recorded beyond preference.
  • Language: FORTH for the transform, C only for the raw pixel write. Per the project's compose-in-FORTH-first rule — PROJECT/CART-Y/CART-PLOT are policy, not hardware access, so they belong in capsules/fabric.4th, not in C. Only PLOT/FB-WIDTH/ FB-HEIGHT are C primitives.
  • Trigonometry (4.3.3a) and geometry primitives (4.3.3b), raised 2026-08-07 while scoping this item. Q.SIN/Q.COS (Taylor series, radian input) extend q48_16.c the same way Q.LOG/Q.EXP/Q.SQRT already do — not a new precedent, just more of the same module. LINE/CIRCLE/ARC/ELLIPSE build on those plus PLOT/CART-PLOT, entirely in FORTH, Q48.16 throughout. Resolution-agnostic by design — 48 integer bits is vastly more range than 1920×1080 needs, checked as a sizing sanity check only, not a hardcoded constraint.

27.4 Checkpoint: a cube (4.3.4)

Acceptance for this whole slice is a cube rendered on screen using the 4.3.3 coordinate/ projection machinery — the first real exercise of that math, expected to take real effort, not a quick add. Stop and review here before scoping the next 4.3.x item. Out-of-scope list for 4.3.14.3.4 is recorded once, at the 4.3 punch-list entry itself (§25.5), not repeated per sub-item.

27.5 Keyboard input, interrupt-driven (4.3.54.3.5f)

Scoped 2026-08-07, next after the 4.3.4 checkpoint. Purpose: get a real keypress into the kernel with no polling anywhere in the path, so the REPL keyboard-input work (M8, tracked outside Stadium — see .claude/CLAUDE.md's roadmap section) has something to call. This section is groundwork only, same as 27.127.4 was for the framebuffer — it stops at "a keypress produces an event at a shared interface," not REPL line-editing, not a scancode- to-ASCII layout, not repeat/modifier-key semantics. Those are later items, scoped once 4.3.5f is reviewed.

Why this split is bigger than 4.3.3's three-way split. 4.3.3/4.3.3a/4.3.3b all built on interrupt/timer substrate Phase 0 already finished. Keyboard input does not have that luxury on two of three architectures:

  • amd64 has a Local APIC (for the self-interrupting timer) but no I/O APIC driver at all, and pic_disable() (interrupts.c:228) permanently masks the legacy 8259 — so today there is no path for any legacy IRQ, keyboard's IRQ1 included, to reach the CPU. 4.3.5 stands up the minimum I/O APIC needed for one redirection entry.
  • riscv64 has never enabled external interrupts at all — Phase 0 (0.2/0.3) scoped strictly to the S-mode timer interrupt (sie.STIE). The PLIC (sie.SEIE, bit 9) is unimplemented; interrupts.c's existing comment naming the PLIC describes the architecture, not working code. 4.3.5b is that bring-up, split out as its own item because it is substrate, not keyboard-specific — same shape as Phase 0's per-ISA items. Found 2026-08-08 while implementing 4.3.5b: that substrate assumption itself had a substrate gap underneath it — riscv64 has no software-controlled paging at all (see 4.3.5a, inserted ahead of the PLIC item once this surfaced). Phase 0 never needed to touch memory translation because the timer only ever touches CSRs and SBI calls, never MMIO; PLIC bring-up is the first riscv64 item to need a real MMIO write, and that is what exposed it.
  • aarch64's GIC (item 0.6) was deliberately scoped to one PPI (the timer) and explicitly ruled a general GIC driver out of scope. A PCI virtio-keyboard-pci device signals via an SPI, a distinct GIC path 0.6 never touched. 4.3.5d is the minimal SPI extension, held to the same narrow-scope discipline 0.6 used.

Hardware path differs by architecture, same as the framebuffer did. amd64's q35 machine has a real i8042 PS/2 controller (QEMU default, IRQ1) — 4.3.5. Neither virt board (aarch64, riscv64) has PS/2 hardware; both get a virtio-keyboard device added to their QEMU command lines and a new virtio-input driver (this tree has none — virtio_blk.c is the only existing virtio driver, and it is fully synchronous/polled, so it is a transport reference only, not an interrupt-handling one) — 4.3.5c (riscv64, MMIO transport) and 4.3.5e (aarch64, PCI transport, riding the bus pci.c already enumerates for virtio-blk-pci).

No polling, anywhere, in any of 4.3.5/4.3.5c/4.3.5e. This is the one hard constraint carried through every sub-item — restated per item because it is the actual point of doing interrupt bring-up first rather than reading 0x64's status bit or spinning on a used-ring index, which would have been the fast/wrong way to get a key on screen quickly.

4.3.5f is the checkpoint, same posture as 4.3.4: converge the three architecture-specific paths behind one interface before anything scopes REPL line input on top of it.

27.5.1 aarch64 GIC-SPI derivation for 4.3.5d/4.3.5e (2026-08-08)

Two facts 4.3.5d/4.3.5e depend on were derived live rather than assumed, same discipline as apic.c's own file header uses for the timer PPI values (item 0.6).

1. aarch64 has no 4.3.5a-shaped landmine. arch_mmu_init() (arch/aarch64/arch.c:169) is the same kind of stub as riscv64's Sv39 gap — MMU bring-up "deferred to a later milestone" — so aarch64's MMIO also depends entirely on whatever EDK2 left mapped at ExitBootServices(). Live-probed rather than inferred: a temporary read-modify-write to GICD_ITARGETSR (offset 0x820, within the same 4KB page as this item's real target offsets 0x8230x826) survived cleanly on a solo aarch64 boot — no exception, boot proceeded to ok> (log: logs/20260808-093228/aarch64/). Recorded at the 4.3.5d entry itself (§25.5); this subsection is the supporting derivation, not a duplicate record.

2. The PCI slot→SPI routing formula, decoded from QEMU's own DTB, not recalled. Dumped with qemu-system-aarch64 -machine virt,dumpdtb=<file> -cpu cortex-a57 (QEMU 10.2.1, the exact binary and machine/cpu flags this tree's qemu target uses — Makefile.starkernel:822-824) and decoded by hand-parsing the FDT struct block (no dtc installed on this build host) for the pcie@10000000 node's interrupt-map / interrupt-map-mask properties. interrupt-map-mask = 0x1800 0 0 0x7 masks PCI device number down to its low 2 bits (slot mod 4) and the full 3-bit INTx pin field — meaning the table's 16 explicit entries (slots 03 × INTAD) cover every PCI slot QEMU assigns, not just 03, because routing repeats every 4 slots. Decoded table (INTID = GIC SPI number

  • 32; flags 0x4 = IRQ_TYPE_LEVEL_HIGH throughout):
slot mod 4 INTA INTB INTC INTD
0 INTID 35 (SPI 3) INTID 36 (SPI 4) INTID 37 (SPI 5) INTID 38 (SPI 6)
1 INTID 36 (SPI 4) INTID 37 (SPI 5) INTID 38 (SPI 6) INTID 35 (SPI 3)
2 INTID 37 (SPI 5) INTID 38 (SPI 6) INTID 35 (SPI 3) INTID 36 (SPI 4)
3 INTID 38 (SPI 6) INTID 35 (SPI 3) INTID 36 (SPI 4) INTID 37 (SPI 5)

Closed form, used at runtime by 4.3.5d/e rather than a hardcoded constant (the aarch64 qemu/qemu-esp targets don't pin PCI slot addresses the way the riscv64 target's addr=0x1/addr=0x2 do, so the keyboard's slot is whatever QEMU assigns):

pin  = pci_read8(dev, PCI_CFG_INT_PIN)   /* config offset 0x3D; 1=INTA .. 4=INTD */
slot = dev->device                        /* already populated by pci_find_first() */
spi  = 3 + ((slot + pin - 1) % 4)
intid = 32 + spi                          /* 35..38 */

Not yet decoded/needed: entries for PCI bridge-forwarded interrupts or multi-function devices beyond function 0 — out of scope for a single virtio-keyboard-pci device on bus 0.

27.5.2 riscv64 PLIC-source derivation for 4.3.5c (2026-08-08)

Same method as §27.5.1, applied to the riscv64 virt board's own DTB rather than assumed to match aarch64's — dumped with qemu-system-riscv64 -machine virt,dumpdtb=<file> -cpu rv64 (this tree's exact qemu target flags, Makefile.starkernel:921-923) and decoded the same way (hand-parsed FDT struct block, no dtc).

Transport correction that motivated this item's amendment. The soc/pci@30000000 node (compatible = "pci-host-ecam-generic", reg = 0x30000000 0x10000000) confirms PCI/ECAM, matching pci.c's existing riscv64 fallback base exactly — there is no MMIO virtio transport on this board, corrected at the 4.3.5c entry itself (§25.5).

PLIC's interrupt binding is one cell, not three. soc/plic@c000000's own #address-cells = 0, #interrupt-cells = 1 (phandle 0x3) — simpler than GIC's <type num flags> triple, so the interrupt-map entry stride here is 6 cells (3 child-addr

  • 1 child-irq + 1 phandle + 1 parent-irq), not GIC's 10. interrupt-map-mask is the identical 0x1800 0 0 0x7 (slot mod 4) pattern §27.5.1 found on aarch64 — same GPEX-family host bridge behaviour, confirmed independently rather than assumed carried over. Decoded table (phandle 0x3 on every entry, confirming it targets the PLIC node just read):
slot mod 4 INTA INTB INTC INTD
0 PLIC 32 PLIC 33 PLIC 34 PLIC 35
1 PLIC 33 PLIC 34 PLIC 35 PLIC 32
2 PLIC 34 PLIC 35 PLIC 32 PLIC 33
3 PLIC 35 PLIC 32 PLIC 33 PLIC 34

Closed form:

pin    = pci_read8(dev, PCI_CFG_INT_PIN)   /* config offset 0x3D; 1=INTA .. 4=INTD */
slot   = dev->device                        /* already populated by pci_find_first() */
source = 32 + ((slot + pin - 1) % 4)        /* 32..35 */

4.3.5c pins the new device to addr=0x3 (slot 3) explicitly in the Makefile rather than relying on QEMU's auto-assignment — deterministic, and keeps this table's slot-3 row as the one that actually matters for that item, though the runtime formula above holds regardless of slot.

27.6 Glyph rendering, UTF-8 Latin, capsule-loaded (4.3.64.3.6g)

Scoped 2026-08-09, next after the 4.3.5f checkpoint, per the 2026-08-07 sequencing note (§25.5): keyboard input → glyph rendering → REPL, before the 4.6 Artemis boundary (renumbered from 4.4, then 4.5, 2026-08-11 — REPL now has its own 4.4 section, and Artemis moved down to make room for item 4.5's -O0 finding).

Purpose and boundary. Render text onto the CANVAS region of the Stadium UI (per the reviewed mockup: a fixed REPL strip at the bottom of the screen, a separate large CANVAS region above it) using the stroke-drawing primitives 4.3.3b already built (LINE/CIRCLE/ ARC/ELLIPSE in capsules/fabric.4th). Additive, not a modification of the VT100 console text path. Non-goal, explicitly deferred: the CANVAS's actual scrollable framebuffer viewport (sizing, e.g. a 640×480 region) is REPL-wiring scope (M8), not this item.

Why font_8x16.c stays, structurally, not by preference. The new font system is FORTH-capsule-based, so it only exists once the VM is up — sk_vm_bootstrap.c's capsule/VM bootstrap is milestone M7, after console/PMM/VMM/interrupts/timer/kmalloc (M1M6, kernel_main.c). Anything rendered before or during that window — early boot messages, the REPL itself — has no capsule system to draw from yet, so the baked-in font_8x16.c raster font remains the only thing that can render text that early. It stays, unchanged, as the VT100/REPL path's font regardless of what this item builds.

Boot wiring — resolved 2026-08-09. The font capsule (and its fabric.4th dependency, not currently EXEC'd anywhere in init.4th) gets wired into init.4th's boot chain, loading as early as the capsule/VM bootstrap allows — reversing 4.3.3's "Console isn't a fleet VM yet, not wired in" stance for this specific capsule pair. This is 4.3.6d, below.

Found, not wired — flagged, not built on. include/block_subsystem.h:168 declares a per-block encoding field (0=ASCII, 1=UTF-8, 2=binary) that nothing in the tree reads or writes — dead metadata. Not assumed functional; a ruling on wiring it up vs. ignoring it is still open, not blocking anything below.

Block namespace. fabric.4th claims blocks 49004915 (confirmed against capsules/BLOCK_MAP.md); 4916+ in that family is free. Whether glyph work extends fabric.4th or lands in a new capsule file is an open call for whichever item creates it.

Detailed derivation follows, §27.6.1–§27.6.6, worked out 2026-08-09 against confirmed primitives only (cell_t = int64_tinclude/vm.h:71; */, WITHIN, CASE/OF/ ENDOF/ENDCASE, CREATE, EXECUTE, ', LSHIFT/RSHIFT/AND/OR all confirmed registered in src/word_source/*.c — no word or syntax below was assumed without checking). No $/0x hex-literal syntax was found anywhere in this tree, so every formula below uses decimal literals only.

27.6.1 Em-square coordinate convention (4.3.6)

PROJECT/TO-RASTER take plain Cartesian pixel-space integers, not Q48.16 — confirmed by reading fabric.4th: Z->DELTA is the only place Q48.16 conversion happens, and it is already integer-in/integer-out at the PROJECT boundary. So a glyph stroke needs one scale-and-translate step from a normalized em square into that same pixel space, then hands straight off to the existing LINE.

EM-UNITS 1000, standard Type1/OpenType proportions: baseline Y=0, x-height ≈500, cap-height ≈700, ascender ≈750, descender ≈-250.

Using */ ( n1 n2 n3 -- n4 ) = n1*n2/n3 (confirmed C source, arithmetic_words.c:212-234: int64_t intermediate = n1 * n2; result = intermediate / n3). Worth noting: the "64-bit intermediate to avoid overflow" comment there is vestigial at this cell width — cell_t is already int64_t (include/vm.h:71), so the intermediate buys nothing over plain * / that classic 16-bit-cell FORTH-79 needed */ for. Not a bug, just an observation; */ is still used below for its self-documenting "scale by a ratio" idiom.

VARIABLE GOX  VARIABLE GOY  VARIABLE GSIZE  VARIABLE GCOLOR
: EM-X ( em-x -- cart-x )  GSIZE @ EM-UNITS */ GOX @ + ;
: EM-Y ( em-y -- cart-y )  GSIZE @ EM-UNITS */ GOY @ + ;

VARIABLE GX1 VARIABLE GY1 VARIABLE GX2 VARIABLE GY2
: G-LINE ( gx1 gy1 gx2 gy2 -- )
  GY2 ! GX2 ! GY1 ! GX1 !
  GX1 @ EM-X  GY1 @ EM-Y  0
  GX2 @ EM-X  GY2 @ EM-Y  0
  GCOLOR @
  LINE ;

Store-then-fetch via VARIABLEs rather than deep stack-juggling four values — matches the house style already used throughout CIRCLE/ARC/ELLIPSE/EDGE, not a new pattern. GOX/GOY are the glyph's screen-space baseline-left anchor; GSIZE the requested pixel size; both set by DRAW-GLYPH (§27.6.3) before a glyph word runs.

Precision note, stated not silently assumed: */'s C division truncates toward zero, not floor. For negative em-y values (descenders, below baseline) this loses sub-pixel precision (e.g. -1 16 1000 */ = 0, not -1) — not a bug, but on record given this project's history with exactly this class of truncation issue (Q.TO-INT, 4.3.3b).

27.6.2 UTF-8 decoder (4.3.6a)

Standard, unambiguous algorithm — not a design choice, just correct implementation. Lead-byte classification by bit pattern:

: UTF8-SEQ-LEN ( lead -- n )      \ 0 = invalid lead byte
  DUP 128 < IF DROP 1 EXIT THEN
  DUP 224 AND 192 = IF DROP 2 EXIT THEN
  DUP 240 AND 224 = IF DROP 3 EXIT THEN
  DUP 248 AND 240 = IF DROP 4 EXIT THEN
  DROP 0 ;

: UTF8-CONT? ( byte -- flag )     \ true if 10xxxxxx continuation byte
  192 AND 128 = ;

Codepoint assembly formulas (the specification DECODE-UTF8 must compute — the exact FORTH stack mechanics for consuming 1-4 bytes from a buffer and returning both the codepoint and the advanced pointer are left to 4.3.6a's own implementation and live testing, per this project's demonstrated history of subtle stack/precision bugs surfacing only under real testing, not design review — presenting untested stack-juggling code here as settled would be exactly the kind of confidently-wrong mistake §25.0 rule 4 warns against):

  • 1-byte: codepoint = lead
  • 2-byte: codepoint = ((lead AND 31) LSHIFT 6) OR (cont1 AND 63)
  • 3-byte: ((lead AND 15) LSHIFT 12) OR ((cont1 AND 63) LSHIFT 6) OR (cont2 AND 63)
  • 4-byte: ((lead AND 7) LSHIFT 18) OR ((cont1 AND 63) LSHIFT 12) OR ((cont2 AND 63) LSHIFT 6) OR (cont3 AND 63)

Correction found while scoping the character list (§27.6.4): the 3-byte path is v1-required, not future-i18n-only. Smart quotes/en-dash/em-dash/ellipsis are Unicode General Punctuation (U+2000U+206F), which is not Latin-1 Supplement despite being commonly lumped in with "Latin typography" — it encodes as 3-byte UTF-8. Originally assumed 3/4-byte decoding was pure future-proofing; it is not, once those specific characters are in scope.

27.6.3 Codepoint → glyph dispatch (4.3.6b)

WITHIN's exact semantics confirmed against logical_words.c:370-384, not assumed from the ANS spec: ( n low high -- flag ) = low <= n < high (inclusive-low, exclusive-high).

Dispatch is a bucketed CASE/OF/ENDOF chain (not the flat 256-entry execution-token table originally proposed — deliberately chosen over the table despite the table composing more cleanly with override, see §27.6.5). CASE bodies cannot span block boundaries (4.3.3b finding), so ~113 glyphs are split into range buckets, each its own word:

: DISPATCH-DIGIT       ( codepoint -- em-advance )  \ 48-57
: DISPATCH-UPPER       ( codepoint -- em-advance )  \ 65-90
: DISPATCH-LOWER       ( codepoint -- em-advance )  \ 97-122
: DISPATCH-ASCII-PUNCT ( codepoint -- em-advance )  \ scattered 32-47,58-64,91-96,123-126
: DISPATCH-LATIN1      ( codepoint -- em-advance )  \ Latin-1 Supplement subset, §27.6.4
: DISPATCH-GENPUNCT    ( codepoint -- em-advance )  \ General Punctuation subset, §27.6.4

: DISPATCH-GLYPH ( codepoint -- em-advance )
  DUP  48   58 WITHIN IF DISPATCH-DIGIT        EXIT THEN
  DUP  65   91 WITHIN IF DISPATCH-UPPER        EXIT THEN
  DUP  97  123 WITHIN IF DISPATCH-LOWER        EXIT THEN
  DUP  32  127 WITHIN IF DISPATCH-ASCII-PUNCT  EXIT THEN
  DUP 160  256 WITHIN IF DISPATCH-LATIN1       EXIT THEN
  DUP 8192 8304 WITHIN IF DISPATCH-GENPUNCT    EXIT THEN
  DROP TOFU ;

: DRAW-GLYPH ( codepoint x y size color -- em-advance )
  GCOLOR ! GSIZE ! GOY ! GOX !
  DISPATCH-GLYPH ;

Each bucket word ends with the same default-clause pattern already used by doe.4th's WL-HI (DROP the unmatched selector, then the default expression) — not a new idiom:

: DISPATCH-DIGIT ( codepoint -- em-advance )
  CASE
    48 OF G-0 ENDOF   49 OF G-1 ENDOF   ( ... )   57 OF G-9 ENDOF
    DROP TOFU
  ENDCASE ;

Glyph-word contract: ( -- em-advance ). Every glyph word (G-A, G-0, …) draws itself via G-LINE using the GOX/GOY/GSIZE/GCOLOR context DRAW-GLYPH already set, then leaves its own advance width in em-units on the stack (per the proportional-width decision, §27.6.4). TOFU ( -- em-advance ) draws an empty box roughly cap-height tall and returns a fixed default advance — proposing 500 (half an em); flag if a different default is wanted.

Block budget, concretely estimated. WL-HI (8 entries, one line each) fits in one block with room to spare. At a conservative ~15 entries/block: DISPATCH-UPPER/DISPATCH-LOWER (26 each) need ~2 blocks apiece, DISPATCH-DIGIT (10) needs 1, DISPATCH-ASCII-PUNCT (~32) needs ~2-3, DISPATCH-LATIN1+DISPATCH-GENPUNCT (18 combined, §27.6.4) need ~1-2. ~8-10 blocks for dispatch alone, before any glyph's actual stroke data — on record so 4.3.6c isn't scoped against a fantasy budget.

27.6.4 Character list, confirmed 2026-08-09 (4.3.6c)

Correction to the original "ASCII + Latin-1 typographic set" framing: curly quotes, en/em dash, and ellipsis are not Latin-1 Supplement (U+0080U+00FF) — they're Unicode General Punctuation (U+2000U+206F), a separate, non-contiguous block. The confirmed v1 repertoire spans both, correctly bucketed:

  • ASCII printable (32126): all 95, DISPATCH-DIGIT/DISPATCH-UPPER/DISPATCH-LOWER/ DISPATCH-ASCII-PUNCT.
  • Latin-1 Supplement (11): ° © ® ± × ÷ ¢ £ § , plus non-breaking space (U+00A0 — non-printing, advance-only glyph, no visible stroke).
  • General Punctuation (7): ' ' (U+2018/2019), " " (U+201C/201D), (U+2013/2014), (U+2026).

113 glyphs total for v1 (95 + 11 + 7), each needing its own stroke-drawing word plus a CASE OF...ENDOF entry in its bucket.

27.6.5 User font override (4.3.6e)

Real constraint, found while deriving, not designed around silently. CASE/OF/ENDOF compiles an early-bound call — DISPATCH-UPPER's CASE body bakes in a call to G-A's address at the point DISPATCH-UPPER is compiled (during the default font capsule's load), the same way 4.3.3b found CART-PLOT-style redefinition only affects code compiled after the redefinition. A user-font capsule that later redefines G-A alone does not change what DISPATCH-UPPER calls — the override silently does nothing. This is the direct cost of choosing CASE dispatch over the flat xt-table (which would have made override a trivial table-slot overwrite, checked live via @ on every dispatch).

Resolved 2026-08-09: override replaces the whole bucket word, not individual glyphs. A user-font capsule that wants to change even one letter must redefine the entire relevant DISPATCH-* word (e.g. all of DISPATCH-UPPER to change one uppercase letter) — no new data structure, pure CASE, at the cost of override granularity. Worth stating in the open item itself, not discovered fresh when 4.3.6e is implemented.

27.6.6 TEXT entry point, algorithm-level (4.3.6f)

TEXT ( c-addr u x y size color -- ) walks the UTF-8 byte string left-to-right: decode one codepoint via DECODE-UTF8 (§27.6.2), call DRAW-GLYPH with the running cursor position to draw it and get back its em-advance, scale that advance to pixels via GSIZE @ EM-UNITS */ (same idiom as EM-X/EM-Y), accumulate into the cursor X, repeat until the buffer is exhausted. Presented at the algorithm level, same caveat as DECODE-UTF8 — the exact loop construction is 4.3.6f's own implementation work, not settled here.

Refs: §25.5 (punch list).

27.7 TrueType rendering, adjunct to the stroke font (4.3.74.3.7f)

Scoped 2026-08-09, immediately after the 4.3.6g checkpoint review (which surfaced this decision ahead of that item finishing — see 4.3.6g's paused note). Adjunct, not a replacement: the stroke-drawn font system (4.3.64.3.6g) stays; nothing from it is being reverted or deprecated by this work.

Four design decisions resolved 2026-08-09, before any implementation:

  1. Implementation layer: C, not FORTH. TTF parsing (table lookups, glyph index resolution) and rasterization (Bézier flattening, scanline fill) are impractical to write as interpreted FORTH — same reasoning that put LINE/CIRCLE/ARC/ELLIPSE (4.3.3b) in C rather than FORTH. A new C module, parallel to those, exposes a handful of new words to FORTH.

  2. Q48.16 fixed-point throughout, not float. Checked before deciding, not assumed. GAP-B2 (this doc, §"unverified prerequisites") already flagged the question — "if the kernel is not built with -mgeneral-regs-only (aarch64) / soft-float (riscv64), a C interrupt handler may clobber FP registers" — as unverified, one grep away from settled. That grep was run while scoping this item: Makefile.starkernel's ARCH_CFLAGS show amd64/aarch64 with no explicit FPU disable and riscv64 built lp64d (a hard-float ABI, not soft-float) — so hardware FP is implicitly available on all three, and GAP-B2's hypothesized risk is real, not hypothetical: none of the three architectures' ISR paths save/restore FP register state. This resolves GAP-B2's open question (confirmed real) but does not fix the underlying gap itself, which stays open for whatever eventually does need hardware FP. There is no documented project-wide "no float" policy — this is a targeted decision for this item. Building the rasterizer in Q48.16 sidesteps the risk for this work specifically (it never touches FP hardware), and incidentally keeps it consistent with the determinism convention the rest of the runtime (Compudynamics, the geometry primitives) already uses Q48.16 for.

  3. Font data storage: encoded into capsule blocks. A .ttf is binary; capsule blocks are text (64 chars/line, 16 lines/block, per tools/mkcapsule.c's validate_forth_blocks()). Chose hex/base64 encode-at-build/decode-at-load over a parallel build-time embedded-binary asset (like capsule_generated.c bakes in capsule text) specifically to keep font data inside the existing content-addressed capsule system rather than introduce a second, parallel asset-embedding mechanism. Hex vs. base64 not yet chosen — 4.3.7b's own call.

  4. Relationship to the stroke font: TrueType becomes the primary text path. Once TTF-TEXT (4.3.7e) exists, it's the intended path for real text rendering; TEXT/the stroke system (4.3.6f) remain available and are not deprecated — useful for early boot (before the capsule/VM bootstrap, same reason font_8x16.c stays per §27.6), low-memory contexts, or diagnostic/geometric use. Both continue to exist; callers choose.

Explicitly not decided here, left to the item that owns them: antialiasing approach (4.3.7c), hex vs. base64 encoding (4.3.7b), which .ttf file serves as the v1 test/default font (not chosen yet — needs a licensing check before any specific font is embedded).

Update 2026-08-10 — v1 test/default font chosen: JetBrainsMono-Regular.ttf, licensed SIL OFL 1.1 (permissive, embedding/redistribution allowed) — resolves the licensing-check blocker above.

Correction 2026-08-10, superseding both the paragraph above and decision #3: the "open question" recorded above was wrong — a grep-shallow check (grep -n "blob\|binary") missed the actual mechanism. A full read of tools/mkcapsule.c shows raw-binary-blob ingestion already exists, end to end, and decision #3's hex/base64-encoding premise is unnecessary:

  • process_file() reads any regular file under capsules/ (recursively, via nftw) into a CapsuleEntry as raw bytes. validate_forth_blocks() — the 64-char/16-line text check — only runs when the filename ends in .4th; every other file is accepted as-is, unvalidated and unmodified.
  • generate_output()'s payload-arena emission (const uint8_t capsule_arena[] = { 0x%02X, ... }) is a plain byte array, not a string literal — binary-safe, no NUL-termination assumption, already exercises correctly on non-text content.
  • Kernel-side, capsule_find_by_name() (include/starkernel/capsule.h) returns a CapsuleDesc* (offset/length into the arena) independent of capsule_exec_payload() (capsule_loader.h) — lookup and FORTH execution are already separate calls. A caller can fetch a capsule's raw bytes by name and simply never call capsule_exec_payload() on it.

So: drop .ttf bytes into capsules/ as-is (e.g. capsules/fonts/JetBrainsMono-Regular.ttf → capsule name fonts:JetBrainsMono-Regular.ttf), fetch via capsule_find_by_name(), done — no hex/base64 text-encoding, no mkcapsule.c changes, no parallel asset mechanism. Decision #3 is superseded by this. 4.3.7b's remaining work is just wiring a kmalloc copy (or direct-arena reference — 4.3.7b's own call) behind that lookup.

Refs: §25.5 (punch list), GAP-B2.

27.8 REPL: prompt, text path, CANVAS geometry (4.44.4s)

Scoped 2026-08-11, in conversation with Captain Bob, replacing docs/lithosananke/ROADMAP.md's M8 section entirely (marked OBSOLETE there, kept for history only). Original visual reference: repl-mockup.png.

What this item is not. Not a new subsystem — every piece below either reuses something already built (USE, console_set_vm_name(), vt100.c's CSI/SGR parser, TTF-TEXT's color argument) or extends it in a small, specific way. The scoping pass below exists because two things Captain Bob assumed were future work turned out to already exist, and one instruction ("VT100 is completely obsolete") turned out to need a narrower reading once the code was actually read — all found by checking the tree per §25.0 rule 4, not by assumption.

Found already built, not future work:

  • VM attach by name. USE (src/starkernel/capsule/mama_forth_words.c:1108) already sets g_repl_active_vm (repl.c:48) and calls console_set_vm_name() — the REPL can already attach to any named VM (Hera, Hermes, Artemis, any birthed child) today. Nothing new needed here; 4.4 only changes how that identity is displayed.
  • Two existing, inconsistent VM-identity displays. console.c's g_active_vm_name prefixes every console line with [Name] — this is what has appeared on every serial log line all session ([Hera] ...). Separately, repl.c's sk_repl_run()/sk_repl_step() build the prompt itself as <Name>)ok> for non-Hera VMs (e.g. Hermes)ok>), and zuse)ok>/ok> for Hera depending on vm->zuse_session. Neither matches Captain Bob's described [VM] (user) ok> format. 4.4 unifies both into one convention at the prompt itself; the per-line [Name] prefix on non-prompt output is unaffected (out of scope, not mentioned by Captain Bob, not touched here).
  • Stroke-font color. TEXT ( c-addr u x y size color -- ) (§27.6.6) already takes a color argument — confirmed before assuming 4.4j would need to add one.

End goal, stated by Captain Bob 2026-08-11, applies to every item in this series: the framebuffer console and the serial console must be functional identically — anything printed reaches both. This is the standard 4.4c4.4h (and, once 4.4j lands, the ANSI-colored TTF path) are built to.

Found while verifying 4.4d by screendump — not future work, a pre-existing gap: the whole VT100-on-framebuffer pipeline this series assumed was live (parser, cell grid, draw_cursor_glyph) has never actually been connected to the boot sequence. kernel_main.c:830-834 deliberately skips console_fb_init() (comment: "superseded by the Console drawing-fabric redesign... deliberately not invoked here"), so vt100_init() never runs and vt100_putc() no-ops on every call. No console output — not REPL text, not boot/POST logs — has ever reached the framebuffer; only the framebuffer driver's own corner self-test blocks have. ROADMAP.md's now-obsolete M8 section claimed this path was "already live"; that was never actually screendump-verified, and turned out to be wrong. Given its own item, 4.4c, ahead of 4.4d, which depends on it.

Found, changes the shape of "VT100 is obsolete": vt100.c is a complete ANSI/VT100 terminal state machine — CSI parameter parsing, cursor tracking, full SGR color support (apply_sgr(), 16/256-color, ansi256_to_rgb()) — not just a font_8x16.c wrapper. Its glyph-drawing call is a single, separate site (put_char()draw_cursor_glyph()). Captain Bob's "completely 100% obsolete" instruction, read against this, resolves to: keep the CSI/SGR engine (it's exactly the "ANSI colorization throughout" mechanism wanted), retarget only the one glyph-draw call site from font_8x16.c to TTF-TEXT (4.4i/4.4j). This is not a reversal of the obsolescence call — the VT100 console text path as currently wired (feeding font_8x16.c-rendered REPL text) is still going away for user-facing text; the parser underneath it is what survives, repointed.

Confirmed, not assumed: TrueType is the primary user-facing text path. This restates §27.7 decision #4, not a new decision — TTF-TEXT for anything a user actively works with (REPL, CANVAS), stroke font (TEXT) reserved for later retro/game-styled work. See 4.4a's correction note above for the one inherited imprecision (stroke font is not actually usable pre-bootstrap either — font_8x16.c is the real early-boot fallback, not "the stroke font" generically).

CANVAS coordinate layering. Two distinct coordinate ideas are in play and should not be conflated: TTF-TEXT's own coordinate primitive is Cartesian with origin at the bottom-left of the physical framebuffer, decoupled from REPL-strip sizing (resolved via AskUserQuestion 2026-08-11, implemented in ttf_words.c ahead of this item, verified by amd64 screendump showing y=60/400/700 rendering bottom/middle/top respectively). CANVAS-relative placement — the 640×480 scroll box centered within the region above the REPL strip — is a separate layout computation (4.4o) that translates into that same physical-framebuffer coordinate space before calling TTF-TEXT. The primitive doesn't know about CANVAS or the REPL strip; 4.4o's layout math does.

Refs: §25.5 (punch list), §27.6, §27.7.