Skip to content

v2026.5.18 - Actors v2 Stage 1: Payload Messages, Receive Arms, SBI Gate

v2026.5.18 - Actors v2 Stage 1: Payload Messages, Receive Arms, SBI Gate

Section titled “v2026.5.18 - Actors v2 Stage 1: Payload Messages, Receive Arms, SBI Gate”

Release date: 2026-05-18 Profiles affected: :cluster Status: Compiler feature closure

Stage 1 closes the payload-bearing actor message path that Phase B left as “future work”. Message types can now carry fields, receive arms destructure them, guards can read those destructured bindings, timeout arms are wired for generated supervised actors, and the SBI conformance gate prevents non-owned types from crossing the actor boundary at declaration time.

message declarations with fields that are not SBI-conformant now produce E2530 at compile time:

message Bad {
Set { ptr: *u8 }, // E2530: pointer-typed field is not SBI-conformant
}

Only owned, by-value, and recursively SBI-conformant field types are accepted. This is the SPEC-021 v0.6.4 §3.1 assertion — memory is the message, and every field must be safely transferable across actor boundaries without shared mutable state.

The gate uses three-layer detection: node kind, preceding-star token heuristic, and source-span type name resolution. This works around the parser storing inner type nodes in variant edges rather than wrapper nodes.

The $sbi_conformant(T) comptime builtin is available for manual checks.

Receive arms now accept named unit-variant patterns from the message protocol:

receive do
Cmd.Tick => do
count += 1
end,
Cmd.Stop => do
return 0
end,
else => do
count = count
end,
end

Previously, receive arms only matched raw integer tags. Named variants route through integer Equal comparison (not Union_Tag_Check) via an isReceiveContext flag set in the handler triple’s lowering context.

Payload-bearing variants can be destructured directly in receive arms:

message Cmd {
Tick,
Set { value: u64 },
Stop,
}
actor Counter(msg: Cmd) do
var count: u64 = 0
receive do
Cmd.Tick => do
count += 1
end,
Cmd.Set { value } => do
count += value
end,
Cmd.Stop => do
return 0
end,
else => do
count = count
end,
end
end

The Cmd.Set { value } arm binds the value field into arm scope from the typed payload slot. The parser accepts shorthand struct syntax { field } without requiring : or =.

The slot-array infrastructure (sender, handler, binding, runtime) was already fully implemented in Phase B. The isReceiveContext flag was the only missing piece — it routes payload extraction through janus_actor_state_slot_load(msg_ptr, field_index+1) instead of the union-tag path.

Receive guards and supervised timeout arms

Section titled “Receive guards and supervised timeout arms”

Receive arms now accept when guards and after N => timeout arms:

receive do
Cmd.Set { value } when value >= 0 as u64 => do
count += value
end,
after 0 => do
count = count
end,
end

The guard expression can read payload bindings introduced by the pattern. after N => is a receive timeout arm. Generated supervised actors now get an Actor_timeout(actor) helper, and the generated Actor_start_supervised* wrappers register that helper with the local actor runtime. Delivered messages still call the normal handler; an empty mailbox at the timeout boundary calls the timeout helper.

Stage 0 audit confirmed that SPEC-029 send-safety enforcement is fully wired:

  • Profile gate: effect_graph.Profile.enforcesIsoTagSendSafety() returns true for :cluster and :sovereign only. :core, :service, and :compute are exempt.
  • Parser attachment: ref_cap flows from type nodes through the capability_bindings map to the send-safety checker.
  • Erasure: Capability metadata never reaches QTJIR — architecturally zero-cost per SPEC-029 §3.3.

Integration fixture verifies: val u64 payload sends via ActorRef[Cmd] compile and run; ref payloads are rejected with E2801.

Terminal window
./scripts/zb test-cluster-actors # cluster aggregate
./scripts/zb test-cap-send-safety # profile gate fixtures
./scripts/zb test-cluster-receive-destructure # named variants + payload
./scripts/zb test-cluster-supervised-after-timeout # generated timeout handler
./scripts/zb test-cluster-msg-non-sbi-reject # E2530 compile-fail
./scripts/zb test-cluster-actor-payload # payload smoke
./scripts/zb test-cluster-send-safety-payload # send safety × payload
  • Slot type is still u64: All actor vars and payload fields become u64 slots. Heterogeneous typed state remains future work.
  • Message ABI is i64 for tags: Unit variants lower to their i64 tag. Payload values transfer through boxed slot arrays.
{.profile: cluster.}
use std.cluster.local as cluster
message Cmd {
Tick,
Set { value: u64 },
Stop,
}
@mailbox(capacity: 4)
actor Counter(msg: Cmd) do
var count: u64 = 0
receive do
Cmd.Tick => do
count += 1
end,
Cmd.Set { value } when value >= 0 as u64 => do
count += value
end,
Cmd.Stop => do
return 0
end,
else => do
count = count
end,
after 0 => do
count = count
end,
end
end
pub func send_set(actor_ref: ActorRef[Cmd]) -> i32 do
let payload: val u64 = 42 as u64
actor_ref.send(Cmd.Set { value: payload })
return 0
end
pub func main() -> i32 do
let sys = cluster.local_new(42 as u64, cluster.STRATEGY_ONE_FOR_ONE, 1 as u64)
let actor_id = Counter_start_supervised(sys, 0 as u64, cluster.POLICY_PERMANENT)
// Low-level supervised bridge sends scalar tags.
cluster.local_try_send(sys, 0 as u64, 0 as i64) // Tick tag
cluster.local_try_send(sys, 0 as u64, 1 as i64) // Set tag
cluster.local_shutdown(sys)
cluster.local_destroy(sys)
return 0
end