Skip to content

Cluster Source Contract Enforcement

@requires(cap: [...]) on grain declarations is now executable compiler policy, not only placement metadata.

For the current :cluster local-grain slice, janus build checks calls inside the grain body against the grain’s declared capability symbols. A grain that declares .network can call APIs requiring CapNetRead or CapNetWrite. A grain that only declares .storage_nvme cannot call a CapNetRead API.

func read_socket() requires CapNetRead do
end
@requires(cap: [.network])
grain NetGrain(msg: NetMsg) do
receive do
NetMsg.Ping => do read_socket() end,
else => do end,
end
end

The enforcement is intentionally local and compile-time. NexusOS placement against NodeManifest.capabilities, migration checks, and cross-node routing remain runtime layers. The compiler now protects the source contract before a grain can enter those layers. The annotation shape is closed too: @requires accepts cap: [...], not rival spellings such as caps: [...]; malformed metadata fails with E_CLUSTER_REQUIRES.

The same compiler pass now rejects alloc[Local.Shared](...) inside a grain:

grain BadStore(msg: NetMsg) do
receive do
NetMsg.Ping => do
let slot = alloc[Local.Shared](0 as u64) // E_CLUSTER_MEMTAG
_ = slot
end,
else => do end,
end
end

Volatile.Ephemeral grain memory now has the matching visible-rebuild rule. alloc[Volatile.Ephemeral](...) is legal only when the grain declares reconstruct():

grain ScratchStore(msg: NetMsg) do
reconstruct() do
end
receive do
NetMsg.Ping => do
let scratch = alloc[Volatile.Ephemeral](0 as u64)
_ = scratch
end,
else => do end,
end
end

The semantic unit tests cover the accepted reconstruct() case. The AOT test-cluster-memory-tags gate covers the executable rejection paths: Local.Shared inside a grain, and Volatile.Ephemeral without reconstruct(). Full replication, passivation, and migration behavior still belongs to the runtime slices, but the source-level invariants are enforced before lowering.

The same source-contract pass also makes @mailbox policy honest. The v1 local runtime implements bounded non-blocking reject semantics:

@mailbox(capacity: 4, overflow: .reject)
actor Worker(msg: WorkMsg) do
receive do
WorkMsg.Ping => do end,
end
end

Omitting overflow keeps the same .reject behavior. Policies such as .drop_oldest, .drop_newest, and .block_sender now fail with E_CLUSTER_MAILBOX until those policies are executable in the runtime. The annotation is also closed over its canonical fields: only capacity and optional overflow are accepted, so typos such as prefetch: fail before lowering instead of becoming inert metadata.

Arena lifecycle metadata is now checked the same way. @arena is executable source contract, not ignored annotation text. If an actor or grain declares an arena, the declaration must name the matching scope, a known reset mode, and any optional byte ceiling as a positive compile-time literal in the current v1 surface:

@arena(scope: .actor, reset: .on_restart, max_bytes: 4096)
actor Worker(msg: WorkMsg) do
receive do
WorkMsg.Ping => do end,
end
end

For grains, scope must be .grain:

@arena(scope: .grain, reset: .on_deactivate)
grain User(id: u64, msg: UserMsg) do
receive do
UserMsg.Ping => do end,
end
end

Scope mismatches, unknown reset modes, zero or non-literal max_bytes, unknown fields, and reset: .manual without a reason now fail with E_CLUSTER_ARENA.

For compiler-generated local actors and grains, max_bytes is also executable for the generated scalar state-slot allocation. The compiler forwards the literal into the local start helper; setup fails before activation if the generated u64 state slots need more bytes than the ceiling allows. That keeps the current runtime boundary honest without pretending that arbitrary actor-local allocations are fully accounted yet.

let ref = Worker_start_supervised_ref(system, 0 as u64, cluster.POLICY_PERMANENT)
let limit = cluster.local_ref_arena_max_bytes(ref)

Raw local-system code can inspect the same configured ceiling by slot:

let limit = cluster.local_arena_max_bytes(system, 0 as u64)

Full allocator-domain enforcement beyond compiler-generated scalar state slots remains a later runtime slice.

Reduction metadata now has the same canonical-form gate. The accepted source shape is @reductions(limit: N) or @reductions(limit: N, yield: .loop_backedge). The older budget field is not a synonym and fails with E_CLUSTER_REDUCTIONS; invalid limits, unsupported yield policies, and unknown fields fail the same way. The compiler validates the policy metadata now. Full reduction-check insertion remains runtime/compiler scheduling work.

Reload metadata is now validated as source contract too. The accepted boundary values are .message, .idle, .supervised_restart, and .forbidden. state and migrate must appear together. Unknown fields and invented boundaries fail with E_CLUSTER_RELOAD. This validates dispatch-table metadata; signed module loading, ABI/state hash comparison, dispatch-entry swap, and Cap.cluster.hot_reload authorization remain runtime work.

Observation metadata now uses the SPEC-021 runtime-status shape: @observe(mailbox: .summary, state: .none, current_message: .type_only). mailbox, state, and current_message are the only accepted fields. The older events field is rejected with E_CLUSTER_OBSERVE; lifecycle events belong to lifecycle and tombstone streams, not observation-level metadata.

Tombstone metadata now follows the explicit hot-index policy shape: @tombstone(digest_includes: [.payload], retention_window: 60_000, deadly_threshold: 3). The accepted fields are enabled, digest_includes, retention_window, and deadly_threshold. The older classifier field is rejected with E_CLUSTER_TOMBSTONE; deterministic-deadly behavior is derived from the threshold, retention window, and match key.

Behaviour metadata now has a build-time shape gate. @behaviour(.server) requires a visible init hook in the v1 source contract, unknown behaviour symbols fail, and .supervisor is routed to supervisor ... end syntax rather than actor/grain annotations. Shape violations report CL-E1413.

Grain persistence and lifecycle metadata are also executable source contract now. @persist(via: GrainStoreBytes) is the only v1 persistence substrate and is valid only on grains; actor use, unknown fields, missing via, or future store names fail with E_CLUSTER_PERSIST. @lifecycle is grain-only, requires activation: .lazy, and accepts either omitted deactivation metadata, .never, or .idle_timeout(ms) with a positive compile-time millisecond literal. Invalid lifecycle metadata fails with E_CLUSTER_LIFECYCLE.

Replication metadata is checked as Phase-B source contract as well. The accepted shapes are @replicate(scope: .wing), @replicate(scope: .cluster), and @replicate(scope: .swarm, protocol: .pbft). Runtime replication still belongs to the cluster runtime, but unsupported source metadata now fails with E_CLUSTER_REPLICATE before lowering.

Verified gates:

Terminal window
./scripts/zb test-capability-requires
./scripts/zb test-cluster-persist-policy
./scripts/zb test-cluster-lifecycle-policy
./scripts/zb test-cluster-grain-requires
./scripts/zb test-cluster-memory-tags
./scripts/zb test-cluster-replicate-policy
./scripts/zb test-cluster-mailbox-policy
./scripts/zb test-cluster-arena-policy
./scripts/zb test-cluster-arena-runtime
./scripts/zb test-cluster-reductions-policy
./scripts/zb test-cluster-reload-policy
./scripts/zb test-cluster-observe-policy
./scripts/zb test-cluster-tombstone-policy
./scripts/zb test-cluster-behaviour-policy
./scripts/zb test-cluster-actors