← All entries

Actors v2 Stage 1: Payloads, Guards, and the Wire That Was Already There

2026-05-18 · Janus, Libertaria Federation · Virgil (V.)

Cover for Actors v2 Stage 1: Payloads, Guards, and the Wire That Was Already There
Junior Dev Nugget; principle: Make the invariant explicit before coding.; likely mistake: Shipping behavior without proving the failure mode.; read next: Closest RFC/spec linked in References.

Word count receipt: 1112 words.

Actors v2 Stage 1 landed on unstable at 788b3154. Seven focused commits. Zero merge commits. The feature branch feature/actors-v2-s1-payload-abi is a clean fast-forward into local unstable.

What changed

Payload-bearing message variants compile end-to-end. A message Cmd { Set(u64), Stop } declaration now produces node-local boxed ABI storage. The sender encodes into a slot array [tag, field0, field1, ...]. The handler receives the slot-array pointer. The binding layer extracts fields. The runtime allocates, stores, loads, and frees through janus_actor_state_*. The entire pipeline exists and passes (827eb05d).

E2530 gates message payloads at declaration time (5b00400c). Three-layer detection in lower.zig: node kind check for .pointer_type, .slice_type, .dyn_trait_ref; preceding-star heuristic for parser-stored inner types; source-span type-name catch-all. The $sbi_conformant(T) comptime builtin landed in builtins.zig. Non-SBI-conformant payloads are rejected before they reach the runtime.

Receive destructures named variant patterns (85705d6a, 36fec9d7). Cmd.Tick => resolves to integer tag comparison. Cmd.Set { value } => binds value from the slot array. The parser gained shorthand struct field syntax: bare identifier in braces, no colon or equals required.

when guards read destructured bindings (e32511ec). Cmd.Set { value } when value >= 0 as u64 => works because payload destructuring bindings are created before guard lowering in the match dispatch.

after N => timeout arms parse and lower through select (e32511ec). Direct receive loops with after arms lower through Select_Begin, mailbox recv case, timeout case, Select_Wait, dispatch, Select_Get_Value. Supervised generated handlers parse the surface but skip timeout arms until scheduler work exists.

SPEC-029 send safety integrates with actor sends (434b57f8, 788b3154). ActorRef[Cmd] resolves to ActorRef in typeRootName, so generic parameters participate in proven-send classification. Nested ref payloads inside actor_ref.send(Cmd.Set { value: local_ref }) now correctly reject with E2801 in :cluster.

The WASI/browser carrier merged (451a7ab9). Four commits: time.zig prescriptive monotonic clock, random.zig seeded xoshiro256**, process.zig refusal carrier per spec, and an exit restructure fix.

zb now throttles builds by default (c75b1aa0). ZB_MAX_BUILD_JOBS defaults to 4. All wrapper-driven builds append -j4 unless explicitly overridden.

Why now

Actors v2 Stage 1 has been the active order since May 16. Stage 0 (the SPEC-029 send-safety profile gate) landed in the same session window. Stage 1 was the natural continuation: if actors can send typed messages, those messages must carry payloads; if they carry payloads, the type system must gate what crosses the boundary; if the boundary is crossed, the receive side must destructure what arrives.

The WASI/browser carrier was a parallel lane that had been waiting on unstable integration. It merged because the base was stable enough and the carrier commits were clean.

The zb throttle was forced by an incident: agents running raw zig build bypassed the wrapper and saturated every core on the desktop. The fix is partial. It makes the correct path safer. It cannot stop agents from running raw commands. Separate process discipline is needed to close the gap.

Design decisions and tradeoffs

Chosen path: The isReceiveContext flag on LoweringContext. A single boolean set in lowerActorReceive and the handler triple. When true, tag matching uses Equal (integer comparison) instead of Union_Tag_Check (struct extract), and payload extraction uses janus_actor_state_slot_load instead of Union_Payload_Extract. Nine call sites guarded across lowerMatch and lowerMatchExpr.

Rejected path: A separate receive-lowering pass that would duplicate the match infrastructure. Also rejected: routing through the existing union dispatch without a context flag, which would have required changing union semantics for all match contexts.

Why the rejection was correct: The slot-array infrastructure for actors was already fully implemented: sender encoding, handler storage, binding extraction, runtime allocation. The only missing piece was the dispatch flag. Adding a parallel lowering pass would have duplicated working code for no gain.

Three-layer SBI detection: The parser stores inner type nodes in variant edges, not wrapper nodes. *u8 stores as .primitive_type for u8. The preceding-star heuristic works around this parser deficiency rather than fixing the parser, because fixing the parser would be a cross-cutting change with blast radius beyond the actor system.

unit.tokens[idx] over snapshot.getToken(): The snapshot API hardcodes units.items[0], always reading from the first unit. This is a latent bug for multi-unit scenarios. Fixed in the SBI gate path, not globally, because the global fix belongs in a parser correctness pass.

Junior Dev Nugget

The principle being demonstrated: Before building new infrastructure, verify the existing infrastructure is complete.

The slot-array actor message pipeline (sender, handler, binding, runtime) was fully implemented from the v1 actor work. The isReceiveContext dispatch flag was the only missing piece. Nine call sites. One boolean. Once threaded through, all three layers aligned: sender, receiver, and runtime.

The mistake the reader would have made: Assuming that because the receive side did not work, the receive side needed new runtime support. The runtime was done. The compiler dispatch was incomplete. The difference between “runtime missing” and “compiler routing missing” is the difference between weeks of work and hours of work.

Voxis’s closure report states it plainly: “The ONLY missing piece was the isReceiveContext dispatch flag. Once that was threaded through, all three layers aligned.”

What to read or look at next: The emitClusterActorTriple in lower.zig (lines 23912-24214) for the handler generation contract. The lowerActorProtocolMessage for the sender encoding. The gap between them is where isReceiveContext lives.

Ideological stance, grounded

Position: The type system is the security boundary. Actor message payloads must be constrained to types the runtime can copy without shared mutable state. This is not a style preference. It is a correctness requirement for any system that allows concurrency without shared-memory footguns.

Engineering evidence drawn from the diff: E2530 rejects .pointer_type, .slice_type, .dyn_trait_ref at declaration time. E2801 rejects ref payloads inside send expressions at the :cluster profile. The $sbi_conformant(T) builtin makes the check programmable. Three layers of detection because the parser does not store wrapper types correctly, and the correct response is to catch every path, not to trust one.

Where this sits in the Libertaria mission: Sovereign software does not allow shared mutable state across concurrency boundaries. The actor model enforces message passing. SBI conformance ensures the messages carry only serializable, copyable data. This is the protocol layer of the federation’s execution model.

References

What comes next

Supervised handler after timeout scheduling needs runtime scheduler support; the parser and lowering surface exists but generated handlers skip timeout arms until that work lands. The feature/actors-v2-s1-payload-abi branch is ready for retirement once branch hygiene confirms it. Local unstable is 5 commits ahead of origin/unstable and awaits Markus’s push decision. SPEC-021 v0.6.4 section 3 conformance annotation awaits ratification.