/* StarForth — Steady-State Virtual Machine Runtime Copyright (c) 2023–2025 Robert A. James All rights reserved. This file is part of the StarForth project. Licensed under the StarForth License, Version 1.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://github.com/star.4th@proton.me/StarForth/LICENSE.txt This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement. See the License for the specific language governing permissions and limitations under the License. */ /** * kernel_hermes.h - Kernel-resident Hermes: message and membership * structures (FABRIC-3.6.md task 2.1, item 28; design: FABRIC-3.5.md * SIII/SXXXIII/SXXXIV). * * Phase 2, task 2.1 ONLY: type definitions, wired to nothing, drawing no * heat. No allocator, no send/deliver/reap logic, no registration * anywhere -- those are tasks 2.2 onward, each its own commit. This file * existing and compiling changes no VM's dictionary and no runtime * behaviour; that is deliberate (FABRIC-3.5.md SXXII.4: Phase 2 structures * come first and prove nothing until the allocator is built on top, SXL.4 * item 41). * * SkHermesMessage mirrors capsules/common/messaging.4th's live MSG-CELLS * layout (9 cells: MSG-TYPE@/FROM@/TO@/PADDR@/PLEN@/STADIUM-CELL@/SEQ@/ * CH@/ORIG-TYPE@) field-for-field, per FABRIC-3.5.md SXXXIII.4 item 1 -- * "roughly half the file is accessors that become struct fields." The * Stadium-cell field is the heat coupling itself: a message's heat is not * a field of its own, it IS the Stadium cell it occupies (SXL.4's * consumption model, SXXXIX.4's per-VM invariant) -- there is deliberately * no separate heat field here to keep that single-source-of-truth. * * SkHermesMembership is the "one broadcast membership list" SXXXIII.4 * item 3 and SXXXIII.5 recommend in place of messaging.4th's 28-word * channel abstraction (CH-REQUEST/ACCEPT/CONFIRM/CLOSE/MINT-ID and the * CH-NEGOTIATING/OPEN/CLOSING state machine) -- traced to have exactly one * live caller, CH-ADD-MBR, everything else channel-shaped is unexercised. * Whether kernel-Hermes ever adds negotiation on top is item 27, an open * Phase 3 ruling (FABRIC-3.6.md B1) -- this structure does not answer * that question, it only holds a flat list, which is correct either way. */ #ifndef STARKERNEL_VM_KERNEL_HERMES_H #define STARKERNEL_VM_KERNEL_HERMES_H #ifdef __STARKERNEL__ #include #include #include "starkernel/vm_uuid.h" /* VMUuid */ #include "starkernel/q48_16.h" /* Q48_ONE */ /* * SK_HERMES_MSG_MAX / SK_HERMES_MEMBER_MAX - sizing. Mirrors * messaging.4th's own MSG-MAX (32) and MBR-MAX (64) as a starting point -- * kernel-Hermes is a single central pool rather than N per-VM arenas, so * these may need revisiting once real traffic exists to size against. Not * a ruling, just where the FORTH precedent already was. */ #define SK_HERMES_MSG_MAX 32 #define SK_HERMES_MEMBER_MAX 64 /* SK_HERMES_CHUNK_MAX_PAYLOAD - one block (1024 bytes, 64x16, SXLIV.1), * moved up here (was originally defined much further down, next to * SkHermesChunkHeader) so SkHermesMessage's own payload_buf field below * can be sized against it. See this file's chunking section, further * down, for the full sizing rationale -- not restated here. */ #define SK_HERMES_CHUNK_MAX_PAYLOAD 1024 /* * SK_HERMES_Q_SLOT - per-message admission heat, task 2.2 (item 28). * messaging.4th:44-46 derives Q.SLOT as the reservoir remaining after * COMMON-CH's own Q.1/3 floor, split across MSG-MAX + (CH-MAX-1) slots -- * a floor that exists to reserve heat for the one live channel object * itself. Kernel-Hermes has no such object: SXXXIII.4 item 3 and * SXXXIII.5 replace the channel abstraction with a flat membership list * that carries no Stadium heat of its own, so there is nothing left for a * floor to protect. Deliberately simpler here rather than carrying the * old formula's now-unmotivated term forward: reservoir split evenly * across message slots only. */ #define SK_HERMES_Q_SLOT ((uint64_t)Q48_ONE / SK_HERMES_MSG_MAX) /* * SkHermesMessage - one message slot, field-for-field mirror of * messaging.4th's 9-cell MSG layout. * * @field type Message type code (SPAWN/PAUSE/RESUME/KILL-style * codes are Category A/dead per task 0.1/0.4; live * types today are CONSOLE-CMD-EVENT(7), * ELEVATE-REQUEST(8), BLK-ATTACH-EVENT(9), messaging.4th * reserved sentinels MSG-NACKED(253)/MSG-DELIVERED(255)). * @field from Sending VM (mirrors MSG-FROM@). * @field to Target VM (mirrors MSG-TO@). * @field payload_addr Payload address a reader dereferences (mirrors * MSG-PADDR@). CORRECTED 2026-09-22 (real defect, * FABRIC-3.6.md task 3.8's own findings log, "a real * payload-aliasing defect"): this used to be the * caller's own out-of-line pointer, stored as-is -- * two sends before either drained meant both * messages pointed at the same caller-owned buffer, * whichever send wrote last silently winning. Now * always points at this SAME message's own * `payload_buf` below, which sk_hermes_send_one() * copies into at send time -- every message owns its * payload bytes, independent of whatever the caller * does with its own buffer afterward. Every existing * reader (sk_hermes_drain_checkpoint(), * sk_hermes_reassemble()) is unaffected: `payload_addr` * still points at valid payload bytes of the same * length, just message-owned instead of caller-owned. * FABRIC-3.5.md SXLIII.6.1 (item 44, still open): a * payload above INPUT_BUFFER_SIZE-1 (1024) bytes cannot * be drained in one interpret call -- bound or chunk it * before Phase 3, not here. * @field payload_buf Backing storage for `payload_addr` above -- see its * own doc comment. Sized to SK_HERMES_CHUNK_MAX_PAYLOAD, * the uniform bound sk_hermes_send_one()/ * sk_hermes_publish() already enforce on `payload_len` * (defined just above, moved up in this same fix so it * is in scope here). No reader should touch this field * directly -- go through `payload_addr`, which is what * every existing reader already does. * @field payload_len Payload length in bytes (mirrors MSG-PLEN@). * @field stadium_cell Index into the Stadium cell array this message's * heat currently occupies, or a sentinel meaning "none" * (mirrors MSG-STADIUM-CELL@) -- the heat coupling * itself; see this file's own top comment. * @field seq Monotonic send sequence (mirrors MSG-SEQ@). * @field channel Broadcast/channel marker; unused today (mirrors * MSG-CH@) -- item 27 territory, not decided here. * @field orig_type Original type before a NACK/redeliver rewrite * (mirrors MSG-ORIG-TYPE@). * @field in_use Free-list occupancy flag for task 2.2's allocator. * Not present in the FORTH layout (which uses * MSG-TYPE@ 0<> as its own live/free test) -- kept * explicit here rather than overloading `type == 0`, * since kernel-Hermes's held/pulled/returned/consumed * ledger (task 2.4, SXL.4) needs an unambiguous * occupancy bit independent of the type field's value. */ typedef struct { uint32_t type; VMUuid from; VMUuid to; void *payload_addr; uint8_t payload_buf[SK_HERMES_CHUNK_MAX_PAYLOAD]; uint32_t payload_len; int32_t stadium_cell; uint32_t seq; uint32_t channel; uint32_t orig_type; int in_use; VMUuid owner; /* VM whose reservoir funded this message (task 2.7) */ } SkHermesMessage; /* * SkHermesMembership - one flat broadcast membership list: every VM that * has joined, no per-member state beyond identity. Replaces the 28-word * channel abstraction; see this file's own top comment for why. * * @field members Member VM identities, valid for indices < count. * @field count Number of valid entries in members[]. */ typedef struct { VMUuid members[SK_HERMES_MEMBER_MAX]; size_t count; } SkHermesMembership; /* * sk_hermes_alloc - Heat-coupled allocate (task 2.2, item 28): pull * SK_HERMES_Q_SLOT from vm_id's own Stadium reservoir, admit a real * Stadium-floor patron with that heat (behaviour DELIVER, matching * messaging.4th's own `SB-DELIVER STADIUM-ADMIT`), and claim a free * message slot recording the admitted cell. Refuses cleanly, rolling * back anything already pulled/admitted, if reservoir, arena, or the * Stadium floor itself refuses. * * CORRECTED in task 2.3 (see this file's .c counterpart's own top * comment): the first cut of this function never admitted a real * Stadium patron, which left held message heat invisible to SXL.4's * conservation invariant while allocated. It now does, which is what * makes sk_hermes_release()'s eviction meaningful. * * Wired to nothing outside this file's own self-test (kernel_main.c) as * of task 2.2/2.3 -- no FORTH word, no capsule interaction, no protocol * logic (send/deliver/reap are later tasks). Exercised only by a * synthetic-VM self-test; does not change any real VM's dictionary. * * @param vm_id Caller whose reservoir is charged. * @param out_msg On success, set to the claimed slot. Untouched on * refusal. * @return 0 on success, -1 on refusal (insufficient reservoir, no free * slot, or the Stadium floor itself refused admission -- this * function does not distinguish the three in the return value; * all three leave all state exactly as it was). */ int sk_hermes_alloc(VMUuid vm_id, SkHermesMessage **out_msg); /* * sk_hermes_release - Return a message's heat via the Stadium eviction * path (task 2.3, item 28), mirroring MSG-FREE-NODE * ("DUP 5 CELLS + @ STADIUM-EVICT DROP"). stadium_evict() itself returns * the departing patron's remaining heat to its owning VM's reservoir -- * this function does not touch the reservoir directly, matching the * FORTH shape exactly. Clears the slot (in_use = 0) whether or not * eviction succeeds, since a message this function was asked to release * should not remain allocated either way. * * @param msg A slot previously returned by sk_hermes_alloc(). Refused * (returns -1, no effect) if NULL or already released. * @return 0 on successful eviction, -1 if msg was invalid or eviction * itself was refused by the Stadium floor. */ int sk_hermes_release(SkHermesMessage *msg); /* * sk_hermes_ledger - Task 2.4 (item 28), the four counters FABRIC-3.5.md * SXXXVII.3/SXL.4 name: held, pulled, returned, consumed. Each is touched * at exactly one call site: * * held += pulled amount -- sk_hermes_alloc(), on success only * held -= returned amount -- sk_hermes_release(), on success only * pulled += pulled amount -- sk_hermes_alloc(), same site as held's * returned += returned amount -- sk_hermes_release(), same site as held's * held -= decay delta -- sk_hermes_decay(), same site as consumed's * consumed += decay delta -- sk_hermes_decay() (task 2.5) * * The audit invariant this ledger exists to make checkable (task 2.6, * epsilon zero): held == pulled - returned - consumed. All four are * exact integers (SXXXVII.3: "Nothing here is a measurement. There is no * noise to tolerate.") -- this accessor is how task 2.6's audit, task * 2.7's Stage B proof, and task 2.8's scan cross-check all read the same * four numbers, rather than each keeping its own copy. * * @param held Set to the current live total (may be NULL). * @param pulled Set to the cumulative total ever pulled (may be NULL). * @param returned Set to the cumulative total ever returned (may be * NULL). * @param consumed Set to the cumulative total ever consumed by decay * (may be NULL). */ void sk_hermes_ledger(uint64_t *held, uint64_t *pulled, uint64_t *returned, uint64_t *consumed); /* * SK_HERMES_Q_DECAY - per-application decay factor, Q48.16. Identical to * messaging.4th's `65208 CONSTANT Q-DECAY` (65208/65536 ~= 0.99499): * FABRIC-3.5.md SXL.4 rules that the consumption economy is preserved * exactly, not re-tuned. */ #define SK_HERMES_Q_DECAY ((uint64_t)65208) /* * sk_hermes_decay - Apply one decay step to a live message (task 2.5, * item 28), mirroring MSG-COOL-ONE (`MSG-HEAT@ Q-DECAY Q.* MSG-HEAT!`): * the message's Stadium-cell heat becomes q48_mul(heat, Q_DECAY). Unlike * MSG-COOL-ONE, the destroyed difference is RECORDED (SXXXVII.3): the * delta heat_before - heat_after is added to `consumed` and subtracted * from `held`, so held == pulled - returned - consumed stays exact. * * @param msg A live slot from sk_hermes_alloc(). Refused (returns -1, no * effect) if NULL, not in use, or holding no Stadium cell. * @return 0 on success, -1 if refused. */ int sk_hermes_decay(SkHermesMessage *msg); /* * Task 2.6 (item 28): the self-audit, epsilon zero (SXXXVII.3). * * sk_hermes_audit_values - pure predicate: nonzero iff * held == pulled - returned - consumed exactly. Separate from the live * ledger so the check can be exercised with deliberately corrupted values * without mutating real state. * * sk_hermes_audit - checks the live ledger (O(1), SXXXVII.4); on failure * prints a console line and bumps a failure count. Called at the end of * every successful mutation: alloc, release, decay. * * sk_hermes_audit_failure_count - cumulative failures seen by the live * audit; must be 0 in a healthy kernel. */ int sk_hermes_audit_values(uint64_t held, uint64_t pulled, uint64_t returned, uint64_t consumed); int sk_hermes_audit(void); uint64_t sk_hermes_audit_failure_count(void); /* * Task 2.8 (item 28), SXXXVII.4: the scan that verifies the counters * themselves. DIAGNOSTIC ONLY -- O(SK_HERMES_MSG_MAX), never called from * alloc/release/decay. * * sk_hermes_scan_held - walks the message arena and sums the live Stadium * cell heat of every in-use message (the ground truth `held` claims to * track). Sets *live_count (may be NULL) to the messages counted. * * sk_hermes_scan_check - nonzero iff the scan sum equals the `held` * counter exactly. */ uint64_t sk_hermes_scan_held(size_t *live_count); int sk_hermes_scan_check(void); /* * Channel table (FABRIC-3.6.md task 3.2, B1 / FABRIC-3.5.md SXLV.1): B1 * overrules SXXXIII.5's single flat broadcast list -- messaging is * publish/subscribe, one permanent common channel every VM joins at * birth, private channels created by request/grant/deny (task 3.6, not * this one). SkHermesMembership (task 2.1) becomes per-channel: one * instance per SkHermesChannel table entry instead of one global list. * * INERT, same posture as task 2.1: this task wires channel existence and * membership bookkeeping only. No publish, no dispatch, no ACK/NACK, no * ACL hook (tasks 3.3, 3.6, 3.7) -- creating/destroying/subscribing a * channel here changes no VM's dictionary and delivers no message. * * Sizing: SXLV.1 says the channel table has "no fixed channel maximum, * same reasoning as SXLV.2" -- SXLV.2/task 3.1's own ruling is boot-time, * RAM-derived sizing (Stadium's stadium_max_vm_count_val pattern), not a * literal unbounded/growable table. No separate numeric sizing rule was * ruled for the channel table specifically (task 3.0's five sub-items * covered ACK cadence/ACL-hook-location/switch-table-sizing/chunk- * framing/drain-cadence, not this). Extending task 3.1's already-ruled * pattern directly -- same stadium_max_vm_count() bound, same * kmalloc-at-boot shape -- is the smallest choice consistent with what * was ruled, flagged here as an engineering extrapolation, not restated * as a separate Captain Bob ruling. */ /* SK_HERMES_CHANNEL_COMMON - the permanent common channel's fixed index. * Exists (in_use, empty membership) from sk_hermes_channels_boot_init() * itself, before any VM is born -- every VM joins it at birth (task 3.2's * own kernel_main.c/capsule_birth.c wiring), never destroyed. */ #define SK_HERMES_CHANNEL_COMMON 0 /* * SkHermesChannel - one channel/topic: its membership plus an occupancy * flag for the dynamic table below. No name field -- messaging.4th's own * channel abstraction (CH-ARENA) has none either; a channel is identified * by its table index, the same way a Stadium quota slot or a switch- * signal slot is identified by index, not by string. */ typedef struct { SkHermesMembership membership; int in_use; } SkHermesChannel; /* Boot-time allocation: kmalloc's the channel table to * stadium_max_vm_count() entries (see this section's own sizing note * above) and creates the common channel (index SK_HERMES_CHANNEL_COMMON, * in_use, empty membership -- members join at birth, not pre-populated * here). Must run after stadium_boot_init() (that bound is 0, and this * fails, until Stadium has computed it). Soft failure -- returns -1 and * leaves the table unallocated (capacity 0, so every call below simply * refuses) rather than halting boot, same posture as * stadium_boot_init()/session_boot_init()/sk_vm_switch_signal_boot_init(). * Idempotent-unsafe: calling twice leaks the first allocation, so callers * must call it exactly once. */ int sk_hermes_channels_boot_init(void); /* sk_hermes_channel_create - allocate a new, empty, inert channel. * Returns its table index, or -1 if the table is full or * sk_hermes_channels_boot_init() was never called/failed. */ int sk_hermes_channel_create(void); /* sk_hermes_channel_destroy - tear down a channel created by * sk_hermes_channel_create(). Refuses (-1, no effect) if channel_id is * SK_HERMES_CHANNEL_COMMON (the common channel is permanent, SXLV.1), * out of range, or not in use. Clears membership. */ int sk_hermes_channel_destroy(int channel_id); /* sk_hermes_channel_subscribe - add vm_id to channel_id's membership. * Refuses (-1, no effect) if channel_id is invalid/not in use, vm_id is * already a member, or the channel's membership is already at * SK_HERMES_MEMBER_MAX. Idempotent in effect (a second call with the * same args refuses rather than duplicating), not in return value. */ int sk_hermes_channel_subscribe(int channel_id, VMUuid vm_id); /* sk_hermes_channel_unsubscribe - remove vm_id from channel_id's * membership (compacts the list, same shape as * sk_vm_switch_signal_unregister()). Refuses (-1, no effect) if * channel_id is invalid/not in use or vm_id is not a member. */ int sk_hermes_channel_unsubscribe(int channel_id, VMUuid vm_id); /* sk_hermes_channel_is_member - nonzero iff vm_id is currently a member * of channel_id. Zero (not an error signal) if channel_id is invalid/not * in use. */ int sk_hermes_channel_is_member(int channel_id, VMUuid vm_id); /* sk_hermes_channel_member_count - current membership size of channel_id, * or -1 if channel_id is invalid/not in use. */ int sk_hermes_channel_member_count(int channel_id); /* sk_hermes_channel_capacity - the table's boot-time-computed capacity * (0 if sk_hermes_channels_boot_init() was never called or failed) -- * DoE/test observability, mirrors sk_vm_switch_signal_slot_capacity(). */ int sk_hermes_channel_capacity(void); /* Boot-time allocation for the per-subscriber pending-queue table * (FABRIC-3.6.md task 3.3), kmalloc'd to stadium_max_vm_count() entries -- * same sizing pattern as the channel table (task 3.2) and switch table * (task 3.1); see kernel_hermes.c's own comment on this call for why. Must * run after stadium_boot_init(). Soft failure -- returns -1 and leaves the * table unallocated (every queue lookup then finds nothing) rather than * halting boot. Idempotent-unsafe: call exactly once. */ int sk_hermes_queues_boot_init(void); /* * sk_hermes_send_one - point-to-point delivery to exactly one VM * (FABRIC-3.6.md task 3.6, extracted from sk_hermes_publish()'s own * per-subscriber body, which now calls this once per channel member). * One allocation, one queue push, same rollback-on-refusal discipline * sk_hermes_publish() already had -- one code path, so the ledger can * never diverge between the two callers. * * Exists because task 3.6's negotiation messages (request/grant/deny/ * close/ACK/NACK) are inherently addressed to ONE specific VM, and * sk_hermes_publish()'s fan-out cannot express that: it sets msg->to to * whichever member it is currently iterating, so a negotiation message * "published" to the common channel would be delivered to (and drawn * heat against) every common-channel member, each believing it was the * addressee, not just the real target. `messaging.4th`'s own * `CH-REQUEST ( type from to paddr plen -- )` carried an explicit `to` * for exactly this reason -- point-to-point addressing was in the * original protocol from the start. * * @param from Sender, whose reservoir funds the allocation. * @param to The one recipient. * @param type Message type code. * @param channel_id Tag copied into the message's own `channel` * field -- purely informational at this level (no * membership is consulted or required), letting a * response encode context (e.g. task 3.6's GRANT * uses this to tell the requester which private * channel was just created). * @param payload_addr Caller-owned buffer, read and copied into the * message's own storage before this function * returns (2026-09-22 fix, this struct's own * payload_addr field doc comment has the full * account) -- the caller does not need to keep * it alive past this call. * @param payload_len Refused (-1) if it exceeds * SK_HERMES_CHUNK_MAX_PAYLOAD. * @return 0 on success, -1 on any refusal (bound, reservoir, arena, or * destination queue full -- all leave state exactly as found). */ int sk_hermes_send_one(VMUuid from, VMUuid to, uint32_t type, uint32_t channel_id, void *payload_addr, uint32_t payload_len); /* * Publish path, no dispatch (FABRIC-3.6.md task 3.3, SXLIII.3; heat-cost * ruling 2026-09-21: one message per subscriber, separate heat draw each -- * matches the existing heat-coupled allocator 1:1, no refcount machinery). * * sk_hermes_publish() loops sk_hermes_send_one() (task 3.6 extraction, * above) once per channel member, funded by the publisher's own * reservoir each time. It does not interpret, deliver, or otherwise * dispatch anything -- draining a queue at a VM's own outermost * interpret checkpoint is task 3.4's scope, not this one's. * * Best-effort, not atomic across subscribers: if a given subscriber's * send_one() call is refused (reservoir exhausted, message arena full, * or that subscriber's own pending queue full), that one subscriber is * skipped -- send_one() has already rolled back its own allocation * internally, so nothing here needs to -- but delivery to every OTHER * subscriber already queued is not undone. This was not a * separate Captain Bob ruling; it is the natural reading of "ledger audit * and stadium_conserved() hold across N publishes to M subscribers" (task * 3.3's own check) -- those invariants hold under partial delivery just * as well as under all-or-nothing, and requiring atomicity across M * independent reservoir-funded allocations would need a two-phase * commit/rollback this task's inert scope does not call for. * * @param from Publisher, whose reservoir funds every allocation. * @param channel_id Target channel (SK_HERMES_CHANNEL_COMMON or a * channel from sk_hermes_channel_create()). Refused * (-1) if invalid/not in use. * @param type Message type code, passed through unchanged. * @param payload_addr Caller-owned buffer, read once per subscriber via * sk_hermes_send_one() (its own doc comment has the * full account) -- the caller does not need to keep * it alive past this call. * @param payload_len Payload length in bytes. Refused (-1, no * allocation attempted) if it exceeds * SK_HERMES_CHUNK_MAX_PAYLOAD (task 3.5, added here * since task 3.3 deliberately parked this check -- * see that section's own doc comment below). * @return Count of subscribers successfully enqueued to (0..member count), * or -1 if channel_id itself was invalid or payload_len exceeded * the bound. */ int sk_hermes_publish(VMUuid from, int channel_id, uint32_t type, void *payload_addr, uint32_t payload_len); /* SK_HERMES_PENDING_MAX - per-subscriber pending-queue depth. The global * message arena (SK_HERMES_MSG_MAX) is the real ceiling on how many * messages can ever be in flight system-wide, so sizing each VM's own * queue to that same bound is a safe, simple upper limit rather than a * new number to justify. */ #define SK_HERMES_PENDING_MAX SK_HERMES_MSG_MAX /* sk_hermes_pending_count - number of messages currently queued for * vm_id (0 if vm_id has no queue yet -- never having received a message * is not an error). */ int sk_hermes_pending_count(VMUuid vm_id); /* sk_hermes_pending_peek - the oldest still-queued message for vm_id, or * NULL if vm_id has no queue or an empty one. Does not remove it -- task * 3.4's drain logic is expected to peek, interpret, then pop. */ SkHermesMessage *sk_hermes_pending_peek(VMUuid vm_id); /* sk_hermes_pending_pop - removes (does not release/interpret) the * oldest queued entry for vm_id. Callers that also want the message's * heat returned must call sk_hermes_release() on the value * sk_hermes_pending_peek() returned, themselves, before or after popping * -- this function only advances the queue. Refused (-1, no effect) if * vm_id has no queue or an empty one. */ int sk_hermes_pending_pop(VMUuid vm_id); /* Forward declaration only -- kernel_hermes.h deliberately does not * include vm.h (kept decoupled from the full VM struct, same posture as * every other type in this file), but sk_hermes_drain_checkpoint() below * needs a VM* parameter. Matches vm.h's own `typedef struct VM VM;` * shape exactly, so no redefinition conflict. */ typedef struct VM VM; /* * Drain at the outermost checkpoint (FABRIC-3.6.md task 3.4, SXLIII.3-.5; * one message per checkpoint, ruled 2026-09-21). Called from * execute_colon_word()'s existing cooperative checkpoint in vm_core.c, * gated the same way the switch-signal checkpoint already is * (sk_vm_at_outermost_interpret()). * * AMENDS SXLIII.5's own claim: "recursive drain is prevented for free" * via g_vm_interpret_depth is true (the SAME message cannot drain twice), * but that counter says nothing about a SEPARATE, real hazard SXLIII.5 * never named -- calling vm_interpret() on this same vm, from inside its * own currently-running vm_interpret() call, overwrites vm->input_buffer/ * input_length/input_pos with the drained payload's own state. * VMCallState (vm_state_push()/vm_state_pop(), mama_forth_words.c) saves * only rsp/exit_colon/ecw_nesting -- never these three fields (the exact * gap FABRIC-3.md SXX documents: "the same class... for input_buffer/ * input_pos not being saved by vm_state_push/pop"). Left unaddressed, * the enclosing vm_interpret() call's own while loop would silently lose * the rest of its input line/block the moment a drain fires mid-line -- * the same failure shape as the INPUT_BUFFER_SIZE 256 defect * .claude/CLAUDE.md calls non-negotiable, and trap #1 in this document's * own START HERE. Not a divergence from SXLIII.3's ruling (the mechanism * is exactly as ruled); cursor/mode/error/abort preservation is this * function's own implementation obligation, not a new design question. * * sk_hermes_drain_checkpoint() therefore snapshots vm->input_buffer/ * input_length/input_pos/mode/error/abort_requested before calling * vm_interpret() on the queued payload, forces vm->mode to * MODE_INTERPRET for the duration (a checkpoint reached mid-colon- * definition must not let the payload's words compile into the * enclosing definition), and restores all six afterward -- fully * isolating the drain from whatever the enclosing execution was doing. * * Payload convention: payload_addr is trusted to point at a * NUL-terminated C string (vm_interpret()'s own signature takes no * length) -- payload_len is not consulted here. Bounding/validating that * is task 3.5's scope (payload bound and chunking), not this one's. * * Fast path: sk_hermes_publish()/sk_hermes_pending_pop() maintain a * single system-wide pending-total counter; this function reads it * first and returns immediately if it is 0, so the common case (nothing * in flight) costs one integer read on every word dispatch, not a * stadium_max_vm_count()-sized queue-table scan. * * @param vm The VM at its own outermost checkpoint. NULL is refused. * @return 1 if a message was drained cleanly, 0 if nothing was pending * for this vm, -1 if a message was drained but interpreting its * payload set vm->error (restored to its pre-drain value either * way -- a bad message must not abort the enclosing execution). */ int sk_hermes_drain_checkpoint(VM *vm); /* * Payload bound and chunking (FABRIC-3.6.md task 3.5, B4 / FABRIC-3.5.md * SXLV.3; chunk framing ruled 2026-09-21: each chunk carries (msg_id, * seq, is_last) ahead of the real content). * * Sizing decision (not itself ruled -- see SXLV.4's own note that the * concrete framing was a task-writing prerequisite, not a ruling): * SK_HERMES_CHUNK_MAX_PAYLOAD (1024, one block, SXLIV.1) bounds a * message's payload_len UNIFORMLY -- chunked or not. A chunk carrier's * own payload is [SkHermesChunkHeader][content slice], so the slice * itself is capped at 1024 - sizeof(SkHermesChunkHeader), not 1024, * keeping every message on the wire under the same one-block bound * SXLIII.6.1 already requires for vm_interpret()'s own drain limit. * (The alternative -- slice up to 1024, carrier up to * 1024+sizeof(header) -- was considered and rejected: it would mean a * chunk carrier can never be handed to vm_interpret() as-is, making a * later chunk-aware drain a special case instead of the same drain path * every other message already uses.) * * sk_hermes_publish() (task 3.3, above) enforces this bound directly -- * refuses (-1) any single payload_len over SK_HERMES_CHUNK_MAX_PAYLOAD, * whether or not it's a chunk carrier, since both cases hold the same * invariant now. * * SENDING a payload over SK_HERMES_CHUNK_MAX_SLICE bytes is deliberately * NOT a kernel-Hermes API here -- it is a loop a caller writes with the * primitives below plus sk_hermes_publish(), the same way this task's * own self-test proves reassembly (kernel_main.c: build N static chunk * buffers, sk_hermes_publish() each). Building a chunking sender would * need kernel-Hermes to own chunk-buffer memory with a real lifetime * (kept alive until every subscriber has drained it, freed only once * kernel-Hermes has no way to know that) -- a genuine new question this * task's inert scope does not call for. A real multi-chunk sender is a * later task's problem, when a real message type actually needs one * (3.8+, the same boundary chunk-aware drain is already deferred past). */ /* SK_HERMES_CHUNK_MAX_PAYLOAD (1024 bytes, 64x16, SXLIV.1) -- the * uniform bound every message's payload_len must satisfy, chunked or * not (see this section's own sizing note above) -- is defined earlier * in this file now, next to SkHermesMessage's own payload_buf field, * which needs it in scope. Not redefined here. */ /* SkHermesChunkHeader - prepended to a chunk carrier's own payload, * ahead of up to SK_HERMES_CHUNK_MAX_SLICE bytes of real content. * msg_id is caller-chosen and must be unique per logical multi-chunk * send from a given sender -- reassembly groups chunks by it. seq is * 0-based, ascending, no gaps, {0..n_chunks-1}. is_last is set on * exactly the chunk whose seq is n_chunks-1, no other. */ typedef struct { uint32_t msg_id; uint32_t seq; int is_last; } SkHermesChunkHeader; /* Content bytes per chunk -- the one-block bound minus header overhead. */ #define SK_HERMES_CHUNK_MAX_SLICE (SK_HERMES_CHUNK_MAX_PAYLOAD - (uint32_t)sizeof(SkHermesChunkHeader)) /* sk_hermes_chunk_count - number of SK_HERMES_CHUNK_MAX_SLICE-sized * chunks `len` bytes needs (ceiling division). 0 bytes needs 0 chunks; * 1..SK_HERMES_CHUNK_MAX_SLICE needs 1; and so on. Pure arithmetic, no * side effects -- a caller sizing its own chunk-send loop calls this * first. */ uint32_t sk_hermes_chunk_count(uint32_t len); /* sk_hermes_reassemble - concatenates n_chunks chunk carrier messages * (already drained off a subscriber's own pending queue via * sk_hermes_pending_peek()/sk_hermes_pending_pop(), task 3.3) sharing * one msg_id back into one byte-exact buffer at out_buf, in ascending * seq order regardless of the order chunks[] itself is passed in. * * Refuses (-1, *out_len untouched, no partial copy ever lands in * out_buf) if: n_chunks is 0 or exceeds SK_HERMES_MSG_MAX (the system- * wide message arena size -- chunking can never need more chunk-carrier * messages than the whole arena holds); any chunk is NULL, has no * payload, or its payload is shorter than sizeof(SkHermesChunkHeader); * the chunks' msg_ids disagree; seq values are not exactly * {0..n_chunks-1} (a duplicate or a gap); is_last is not set on exactly * the seq==n_chunks-1 chunk and no other; a non-final chunk's content * slice is not exactly SK_HERMES_CHUNK_MAX_SLICE bytes, or the final * chunk's is 0 or over that bound; or the total reassembled length * would exceed out_buf_cap (checked once, from validated seq * completeness, before any memcpy -- never order-dependent on which * chunk happens to overflow first). * * @param chunks Array of n_chunks chunk carrier message pointers. * @param n_chunks Chunk count (from sk_hermes_chunk_count() at send * time, or simply len(chunks[])). * @param out_buf Caller-owned destination buffer. * @param out_buf_cap Capacity of out_buf in bytes. * @param out_len Set to the reassembled length on success only. * @return 0 on success, -1 on any refusal above. */ int sk_hermes_reassemble(SkHermesMessage **chunks, int n_chunks, uint8_t *out_buf, uint32_t out_buf_cap, uint32_t *out_len); /* * ACK/NACK and private-channel negotiation (FABRIC-3.6.md task 3.6, B1 / * FABRIC-3.5.md SXLV.1). "A private channel is created by request -> * grant/deny over the common channel" -- read as: every VM is a * common-channel member from birth (task 3.2), so a requester can * always REACH a target without a prior private channel; the exchange * itself is point-to-point (sk_hermes_send_one(), task 3.6's own * extraction above), not a fan-out to the whole common-channel * membership -- see sk_hermes_send_one()'s own doc comment for why * fan-out is structurally wrong for an addressed request. * `messaging.4th`'s own `CH-REQUEST` carried an explicit `to` for the * same reason; this is the same shape, not a new one. * * "A deny is a NACK" (SXLV.1) -- there is no separate CH_DENY type; * denial IS SK_HERMES_MSG_TYPE_NACK. ACK is sent once, for the * channel-open + delivery moment (the ruled cadence, 2026-09-21), not * for every message that follows on the new channel. * * The GRANT/DENY *decision* is task 3.7's scope -- the ACL policy hook, * "kernel-Hermes asks ACL.4th, never decides in C, never gates on * zuse_session" (CLAUDE.md, SXLV.1). sk_hermes_channel_respond() below * takes that decision as an explicit caller-supplied `approved` flag * (named for what it is, not `allow`, so it reads as a caller decision * passed in, never as policy living in C) -- task 3.7 replaces the CALL * SITE that produces this flag with a real ACL.4th query; this * function's own signature does not change. */ #define SK_HERMES_MSG_TYPE_CH_REQUEST 20 #define SK_HERMES_MSG_TYPE_CH_GRANT 21 #define SK_HERMES_MSG_TYPE_ACK 22 #define SK_HERMES_MSG_TYPE_NACK 23 #define SK_HERMES_MSG_TYPE_CH_CLOSE 24 /* SK_HERMES_MSG_TYPE_BLK_ATTACH (FABRIC-3.6.md task 3.8, Stage C) -- * DELIBERATELY the same numeric value as messaging.4th's own * BLK-ATTACH-EVENT constant (9), not a fresh kernel-Hermes-space number * like the five above. SXXXIV.2's partition rule is "one owner per * message type, never shared" -- it is about which LAYER holds the * message, not about the two layers needing disjoint numbering. Once * kernel-Hermes is this type's sole owner, keeping the same value * documents that this is a cutover of the SAME logical message, not a * new one invented alongside it. */ #define SK_HERMES_MSG_TYPE_BLK_ATTACH 9 /* SK_HERMES_MSG_TYPE_CONSOLE_CMD (FABRIC-3.6.md task 3.9, Stage D) -- * same reasoning as SK_HERMES_MSG_TYPE_BLK_ATTACH above: deliberately * reuses messaging.4th's own CONSOLE-CMD-EVENT value (7), since this is * that type's real cutover, not a fresh kernel-Hermes-space number. */ #define SK_HERMES_MSG_TYPE_CONSOLE_CMD 7 /* SK_HERMES_MSG_TYPE_ELEVATE_REQUEST (FABRIC-3.6.md task 3.10, Stage D) * -- reuses messaging.4th's own now-historical ELEVATE-REQUEST value (8). * At task 3.10, SEND-ELEVATE-REQUEST/ELEVATE-GRANT/CH-REQUEST all FIND * live in Hera's own dictionary and SEND-ELEVATE-REQUEST was a real, * complete, directly-callable entrypoint (H.5/H.8's own design: any VM * wanting word-ACL elevation calls it with its own pubkey and the target * word name). FABRIC-3.6.md Phase 4, Stage E deleted messaging.4th * (SEND-ELEVATE-REQUEST's only definition) -- KH-ELEVATE-SEND below has * had no FORTH caller since, and ELEVATE-GRANT (zuse-eligibility.4th, * still loaded at boot) is unreachable. Found, not fixed -- Phase 8 PKI * decision needed before Phase 5 close-out, see FABRIC-3.6.md's own * Phase 4 entry. */ #define SK_HERMES_MSG_TYPE_ELEVATE_REQUEST 8 /* The five negotiation types above (20-24) were chosen clear of * messaging.4th's own live/reserved type space (PAUSE-EVENT=2, * RESUME-EVENT=3, KILL-EVENT=4, CONSOLE-CMD-EVENT=7, ELEVATE-REQUEST=8, * BLK-ATTACH-EVENT=9, MSG-NACKED=253, MSG-DELIVERED=255) precisely * because none of them had a real cutover yet -- kernel-Hermes stayed * its own inert, parallel type space for those. SK_HERMES_MSG_TYPE_ * BLK_ATTACH, _CONSOLE_CMD, and _ELEVATE_REQUEST above are the * deliberate exceptions: tasks 3.8/3.9/3.10 ARE those types' real * cutovers, so they reuse the FORTH-side value rather than picking a * fresh one. */ /* sk_hermes_channel_request - requester asks target for a new private * channel. A point-to-point CH_REQUEST (sk_hermes_send_one(), tagged * with SK_HERMES_CHANNEL_COMMON purely for reachability context), * funded by requester's own reservoir. Creates nothing -- the responder * decides via sk_hermes_channel_respond() below. * @return 0 on success, -1 if the send itself was refused. */ int sk_hermes_channel_request(VMUuid requester, VMUuid target); /* sk_hermes_channel_respond - target answers a pending request from * requester, `approved` supplied by the caller (see this section's own * top comment on why that is task 3.7's future call site, not a policy * decision living here). * * Approved: creates a new private channel, subscribes both requester * and target, sends CH_GRANT to requester (msg->channel = the new * channel id -- how the requester learns which channel to use) followed * by one ACK (the ruled channel-open+delivery cadence). If channel * creation fails (table full) or either subscription fails, the * partial channel is destroyed and this call falls through to the * denied path below instead of leaving a half-open channel or a silent * false grant -- the same "leave no half-state" discipline * sk_hermes_publish()'s own per-subscriber rollback already uses. * * Denied (approved==0, or a failed grant attempt as above): sends NACK * to requester ("a deny is a NACK", SXLV.1 -- no separate type). No * channel exists afterward either way. * * @return The new channel id (>= 0) on a real grant, or -1 on any deny * -- both a policy deny and a failed grant attempt leave no * channel behind, which is what this task's own check asks for. */ int sk_hermes_channel_respond(VMUuid target, VMUuid requester, int approved); /* sk_hermes_channel_close - tears down a private channel. Authority is * membership alone: either party may close a channel it belongs to * (narrowest defensible rule given both are already trusted members of * it -- not gated on anything beyond that, and not itself a ruling). * Refuses (-1, no effect) if closer is not a member of channel_id, or * channel_id is SK_HERMES_CHANNEL_COMMON (sk_hermes_channel_destroy() * itself already refuses the common channel). * @return 0 on success, -1 on refusal. */ int sk_hermes_channel_close(VMUuid closer, int channel_id); /* * Channel-open policy hook (FABRIC-3.6.md task 3.7, B1, ruling 3.0b: * "kernel-Hermes asks ACL.4th, never decides in C, never gates on * zuse_session"). sk_hermes_channel_open_policy() is the CALL SITE task * 3.6's own doc comment named as replacing sk_hermes_channel_respond()'s * caller-supplied `approved` bool with a real query -- this function IS * that query; sk_hermes_channel_respond()'s own signature stays * unchanged, as promised. * * Deliberately NOT vm_interpret() (contrast task 3.4's drain, which * genuinely needs to run arbitrary payload text): HERMES-CHANNEL-OPEN? * is a FIXED, known word name, so this uses the plain-word-dispatch * pattern FABRIC-3.md SXX already established for Hera's own MSG-TICK * self-drain ("a plain word dispatch in her own dictionary, not a * VM-EXEC dispatch into anyone else's input buffer -- no reentrancy * risk") -- vm_find_word() + calling the entry's own func pointer * directly, with the requester passed via the target's own data stack. * This touches neither target_vm->input_buffer nor input_pos, so it * carries none of task 3.4's cursor-preservation hazard; it DOES still * touch target_vm's own data stack (dsp), which is the correct and * expected thing for a word call to do. Calling this from within a live * checkpoint on a VM that is itself mid-dispatch is deferred, undecided * territory -- this task (like every Phase 3 task before Stage C/3.8+) * is self-test only, not wired into any real checkpoint yet. * * Fails CLOSED, not open: if target_vm has no HERMES-CHANNEL-OPEN? word * (e.g. ACL.4th was never loaded into it) or the word itself sets * target_vm->error or leaves too few stack items, this returns 0 * (denied) -- absence of policy must never silently mean "always * allow" (CLAUDE.md's own posture on ACL policy). A policy-word bug's * own error state is cleared on target_vm before returning, so a * broken policy word cannot leak an error into whatever else target_vm * is doing. * * @param target_vm The VM being asked -- its own dictionary is * consulted, its own data stack is used for the call. * @param requester Passed to HERMES-CHANNEL-OPEN? as (hi, lo) on * target_vm's own stack. * @return 1 if HERMES-CHANNEL-OPEN? approved (returned a nonzero top of * stack), 0 otherwise (denied, missing, or errored). */ int sk_hermes_channel_open_policy(VM *target_vm, VMUuid requester); #endif /* __STARKERNEL__ */ #endif /* STARKERNEL_VM_KERNEL_HERMES_H */