READ THIS FIRST — ground truth vs. aspiration. This reference describes both
what the Janus compiler implements today and what is specified or planned.
The two are easy to confuse. Every section is tagged with a status so agents never
mistake a future/design surface for a usable one.
Status legend (used throughout this document):
Tag
Meaning
[AVAILABLE]
Implemented in the compiler and working today (AOT janus build).
[PARTIAL]
Partly implemented — draft lowering or known gaps.
[SPEC-ONLY]
Doctrine/spec ratified or drafted, but no working implementation yet. Do not write code against it.
[FUTURE]
Roadmap/design only.
Compiler state:v2026.8.3-alpha. Completed profiles::script, :core,
:service, comptime (the compile-time phase). :cluster, :compute, :sovereign
are recognized by the parser; :cluster and :compute are substantially implemented (local actor/grain model + vector/coordination), while :sovereign is grammar-only.
[PARTIAL] — typed actors, grains, message protocols, supervision trees, Promise/PromiseResolver (SPEC-236), and local GrainStore persistence are complete (SPEC-021); cross-node distribution (Cluster.join/.migrate/.locate) and memory-placement (alloc[...]) are not yet landed
[PARTIAL] — SPEC-080 Phase A grammar only; requires/ensures/ghost lexed but not enforced; Phase B/C pending
The short version, so you don’t get misled:
Write production code in :core / :service / :script — these lower to a runnable binary today.
The “Janus 1.0 Semantic Contract — Six Laws” (§2.1) is the ratified target model, not yet fully enforced: explicit effect rows (!{IO}), capability tokens, refinement types, and totality proofs are [FUTURE]/[PARTIAL] — do not assume the compiler checks them yet.
:cluster (local actor/grain/supervision model) and :compute (vector/coordination/polar) are substantially implemented; their distribution / GPU-NPU / comptime-shape-tensor layers are the future parts. :sovereign (§19, requires/ensures/ghost) is grammar-only today.
A live list of what is broken or blocked is tracked in the Janus compiler issue tracker.
Purpose: Single-source reference for humans and AI agents writing Janus code.
The Desugaring Law:§ lowers to compile-time AST nodes. $ lowers to runtime pattern values (and $"…" string interpolation). @ attaches declarative metadata. No cross-phase aliasing exists.
Migration: the comptime builtins moved from $size_of to §size_of (the $-form is deprecated, W5001). Today $ is used for string interpolation ($"…") and runtime extraction captures ($1, $2, $*) — not for compile-time builtins.
Canonical since: 2026-06-06. This section is Janus Doctrine — non-negotiable.
Janus has two block worlds. A reader must know what a construct is before understanding its contents. A parser must not guess whether { ... } means executable code or data.
The ONLY braced form that contains executable statements is a named expression block. The label tells reader and parser: this is an expression block, not a struct literal.
letvalue=choose: {
ifreadydo
break :choose42
end
break :choose0
}
Named expression blocks are rare and explicit. They are the sole exception to Law 2.
Status: target model, NOT yet enforced. These six laws are the ratified
1.0 semantic target. The compiler does not yet enforce explicit effect
rows (!{IO}), capability tokens, refinement types, or totality proofs. Write
against them as doctrine, not as compiler-checked guarantees. See §4.7–§4.12
for the per-feature status.
Janus is strict by default, pure by contract, effectful by row, authorized by capability, bounded by refinement/totality, and zero-cost unless the source explicitly asks for runtime machinery.
These six laws are non-negotiable. They are the semantic floor for all Janus 1.0 profiles. No profile may weaken them; profiles may only strengthen proof obligations within them.
->Config!FsErrordo// E2610: public API with inferred effects
letbytes=std.fs.read(fs, path)?
returnparse_config(bytes)?
end
This gives Janus a better API audit surface than Haskell. Haskell tells you “this is IO.”
Janus tells you “this may read files, touch the network, allocate, render UI, or use randomness”
and requires actual authority tokens for those effects.
Janus evaluates function arguments strictly.
Laziness exists only through named types such as Lazy[T] and Stream[T].
A lazy value MUST carry the effects required to force it.
runtime — explicit allocation, pointer, vtable, callback, actor, channel, or handler value
No fourth category. No invisible heap. No invisible ARC. No invisible dynamic dispatch.
No “the compiler might allocate because convenient.”
The compiler MUST expose cost through janus explain-cost:
$ janus explain-cost module.symbol
canonical_hash
effects: erased
refinements: erased
dispatch: static
allocation: none
dynamic calls: none
result passing: register
$ janus explain-cost button_on_press
button_on_press
dispatch: callback indirect call
environment: borrowed
allocation: none
context: explicit
effects: Ui
This takes C++‘s zero-overhead principle and makes it mechanically auditable.
No more inferring cost from folklore, optimizer behavior, and ABI details.
returnstd.app.run(ctx, app, ui, CounterView { state: state })
end
Applications are :service programs with an application runtime and UI/resource capabilities.
The same language, the same semantics, the same promotion path from :script prototype
to :service production.
A callback environment may contain state. A callback environment MUST NOT contain authority.
Effectful callbacks receive Context and capabilities explicitly.
GUI programming is callback-heavy. Janus must not let callbacks smuggle authority
through captured closures. Context carries cancellation, deadline, allocator,
and dependency values explicitly — it is the “thread of trust,” not thread-local magic.
// ❌ No invisible retain/release in ordinary Janus code.
Core language: ownership + Destroy + explicit allocation.
Application toolkit: region/arena + Rc/Weak/Signal as explicit library types.
UI runtime: may use ORC-like cycle cleanup internally, behind UiCap.
No global ARC. No hidden retain/release in ordinary Janus code.
This prevents every program from paying app-runtime costs when writing a codec,
kernel service, crypto primitive, or data pipeline.
Profiles and the Six Laws. These laws apply uniformly across all profiles.
:script may relax ambient defaults (implicit capabilities for ergonomics)
but never the underlying semantic contracts. A :script function’s public API
still requires explicit effect rows. The profile determines what’s available,
not what’s provable.
SPEC-250 amendment (2026-06-26, PROPOSED): Endian-qualified
integer types (be*, le*) carry an explicit byte-order annotation
for the type’s memory representation. Native u*/i* types are
CPU-native-endian for fast arithmetic; be* types store MSB-first
for protocol/wire compatibility; le* types store LSB-first for
x86/PE/disk compatibility. All three load to the same native value
— the annotation governs only memory layout, not arithmetic
semantics. Structs may use @repr(network), @repr(little), or
@repr(native) to set a default byte order for all multi-byte
fields. See SPEC-250 for the full type-system semantics.
:core universal integer model: i64 is the default integer type.
A plain struct defaults to @layout(stable) — deterministic,
correctness-preserving, Janus-defined offsets. Foreign-ABI and
binary-contract layouts are declared explicitly via the @layout(...)
attribute (full set: see SPEC-246 and the Representation & Layout
doctrine):
// C-ABI layout for FFI. @layout(c) pairs with @repr(native) by default.
@layout(c)
structRequestFrame {
version: u8,
msg_type: u8,
_pad: [2]u8= .{ 0, 0 },
payload_len: u32,
seq: u64,
}
@layout(c) guarantees field order, alignment, and size match C ABI rules. Use §size_of, §align_of, §offset_of for comptime layout verification.
For types that cross a persistence or network boundary (SBI frames,
SSTable headers, protocol structs), use @layout(wire) with a
non-native @repr — it opts into an architecture-independent binary
contract and the compiler proves the type WireSafe:
@layout(wire) @repr(network)
structWireFrame {
version: u8,
msg_type: u8,
payload_len: u32,
seq: u64,
}
Deprecation note (SPEC-246): older Janus versions wrote the
first example as extern struct RequestFrame { ... }. That syntax
is deprecated. The parser rewrites it to @layout(c) @repr(native)
during parsing and emits W1204. extern struct will be removed
in Janus 2.0. There is exactly one semantic representation of
layout in the AST — @layout(...).
Inspired by Odin’s vector arithmetic. Janus provides first-class small vector types
for data-oriented compute, graphics, and game development. Vectors are value types —
no hidden allocation, no heap, no GC. They live on the stack or in structs.
Type constructor:
// vec[T, N] — vector of N elements of type T
typeVec2i=vec[i32, 2]
typeVec3f=vec[f32, 3]
typeVec4f=vec[f64, 4]
typeVec4u=vec[u8, 4]
Literal construction:
letv: Vec2i= .{ 10, 20 }
letw: Vec3f= .{ 1.0, 2.0, 3.0 }
Scalar arithmetic. Vectors support element-wise operations with scalars:
letpos: Vec2i= .{ 640, 480 }
lethalf=pos/2// Vec2i { 320, 240 }
letdoubled=pos*2// Vec2i { 1280, 960 }
letcolor: Vec3f= .{ 0.5, 0.5, 0.5 }
letbright=color*2.0// Vec3f { 1.0, 1.0, 1.0 }
letdim=color/4.0// Vec3f { 0.125, 0.125, 0.125 }
Element access. Indexed access via [N]:
letx=v[0]
lety=v[1]
Conversion to struct:
structCoord {
col: i32,
row: i32,
}
funccoord_from_vec2(v: Vec2i) ->Coorddo
returnCoord {
col: v[0],
row: v[1],
}
end
Named lanes (future, pending :compute SIMD). The long-term target is
first-class named lane access with no hidden cost:
// Future — named lane access (requires SIMD lane tracking)
Named lanes are a syntax target, not the current surface. Until the compiler
can prove zero-cost lane naming (identical codegen to v[0]/v[1]), explicit
index access is the required form. No illusion. No language-design perfume sprayed
over a missing feature.
Profile availability:
Profile
Vector types
Scalar ops
Named lanes
SIMD lowering
:core
vec[T, N] declaration
Scalar multiplication/division
Future
No
:service
Full vec types
Full scalar ops
Future
No
:compute
Full vec types
Full scalar + vec-vec ops
Yes (future)
simd[T; N] via SPEC-237
Guarantees:
No hidden allocation.Vec2i is 8 bytes on the stack — same as [2]i32.
Value semantics. Assignment copies; no sharing, no ref-counting.
Strict element types.vec[f32, 3] ≠ vec[i32, 3] — no implicit cross-type arithmetic.
Static dispatch. All operations resolve at compile time. No runtime vtable.
Odin’s compression, Janus’s honesty. Scalar division pos / GRID_PX_SIZE compresses
intent beautifully — but only because the type system knows exactly what kind of
creature the result is.
The Odin line is beautiful because it compresses intent. Janus steals the compression —
after the type system knows exactly what pos / GRID_PX_SIZE is.
4. Declarations [AVAILABLE] — subsections §4.7–§4.12 are [SPEC-ONLY]
letx=@ffi("foo.c") // WRONG — @ does not evaluate values
@ introduces declarative attributes. It does NOT evaluate values and does NOT participate in normal expression syntax.
Exported symbols and C function pointers (SPEC-253). The boundary runs both ways. export func emits a Janus function as an unmangled, external-linkage symbol with the C calling convention, callable from C or Rust. export is a contextual identifier (like pub), not a reserved keyword; the parser claims only the export func sequence.
exportfuncjanus_score(x: i64) ->i64do
returnx*2
end
An exported signature must be C-ABI-representable: integer/float scalars, bool, *T, [*]T, [N]u8, void, and func (C fn pointer). Slices, strings, sum types, trait objects, and generics are rejected with E2530.
In an extern func signature, a func(...) parameter type denotes a C function pointer, not a Janus closure value. At the call site you may pass a reference to a top-level func declaration, or a non-capturing function literal — the literal erases to a bare C fn pointer (stateless erasure, SPEC-110 Phase 3):
A capturing closure at the extern boundary is rejected with E2531 (C fn pointers carry no environment; deferred to SPEC-058 Phase 3). A signature mismatch between the passed function and the extern parameter’s func type is E2532 (widths, signedness, pointer-ness must match exactly).
Rust interop requires no new machinery: per SPEC-240, Rust crosses only through the C-ABI membrane (graft rust remains E2400-banned). A Rust staticlib exporting #[no_mangle] pub extern "C" fn is declared in Janus with ordinary extern func; Janus export func symbols are declared in Rust as extern "C" { fn ... }.
Function parameters carry an intent qualifier declaring how the function uses the argument. The compiler chooses the optimal lowering (register-passed value, const-pointer, noalias-pointer, sret-pointer) based on intent + type + size. Programmers express what the function does; the compiler chooses how the bytes move. There are no references and no lifetime annotations in user-facing code.
The four intents:
Intent
Meaning
Caller Retains?
Callee Mutates?
Aliasing?
view
Read-only borrow (default)
Yes
No
Yes
edit
Exclusive mutable borrow
Yes (frozen)
Yes
No
take
Ownership transfer (sink)
No (consumed)
Yes
N/A
make
Uninitialized output
After call: Yes
Yes (must initialize)
No
Default intent is view. Omitting the qualifier means read-only borrow. This matches the immutability-by-default doctrine.
// take — ownership transfer (caller binding marked Dead after call)
funcclose(takef: ~File) do
f.flush()
f.release()
end
// make — uninitialized output; callee MUST fully initialize via bulk-write primitive
funcinit_zero(makebuf: [256]u8) do
@memzero(buf)
end
make discipline (v0.1 lockdown). A make parameter is uninitialized on entry. Initialize via bulk intrinsic (@memset, @memcpy, @memzero), whole-binding assignment, or declarative literal. Element-level writes (buf[i] = x) are forbidden (E2709); reads at any point are forbidden (E2704). The bulk-write discipline reflects what the v0.1 escape analyzer can prove. SPEC-085 v0.2 may relax E2709 once flow-sensitive init tracking lands.
Composition with reference capabilities (SPEC-029). Intents and capabilities are orthogonal axes. Capabilities govern cross-actor sendability (iso/val/ref/tag); intents govern parameter passing at call sites. They compose freely:
funcinspect(viewbuf: isoBuffer) do ... end// read-only borrow of unique ref
funcmutate(editdata: refWorkingSet) do ... end// exclusive write to local mutable
funcswallow(takebuf: isoBuffer) do ... end// consume iso (≡ SPEC-029 `consume`)
Forbidden combinations produce specific errors: edit val (cannot edit immutable, E2706), take ref across actor boundary (E2707), etc. See SPEC-085 §4.1 for the full composition matrix.
⚠️ Footgun — Fixed-size arrays ([N]u8) passed by value silently drop writes.
Both view buf: [4096]u8 and a plain buf: [4096]u8 (implicit view)
declare the parameter by value: the helper operates on a stack
copy of the array, and writes inside the helper are lost. The natural
mutation form
compiles, runs, and produces no effect. The same signature can also
lower to LLVM emit error: MissingOperand at compiler/qtjir/llvm_emitter.zig,
with no Janus-level diagnostic.
Fix: declare the parameter as edit buf: *[4096]u8 (pointer-to-fixed-array)
and pass &caller_buf at the call site:
As of 2026-07-10 the compiler emits W9901 for this pattern, e.g.:
parameter 'buf' is fixed-size array [4096]u8 passed by value; writes inside the function will be lost.
The by-value form still compiles, but the warning surfaces the footgun before
writes are silently lost. Tracked as Gap 99 in COMPILER_GAPS.md (closed).
Composition with affine types (SPEC-015 §3, retained). Linear types ~T interact with intents naturally. SPEC-015 §3 (Affine Types) is retained in full; SPEC-015 §4 (Borrowing &T/&mut T) and §5 (Lifetimes 'a) are superseded by SPEC-085.
funcuse_open(viewf: ~File) do ... end// non-consuming borrow of linear resource
funcamend(editf: ~File) do ... end// exclusive mutable borrow
funcclose(takef: ~File) do ... end// canonical consumption point
FFI boundary. Raw pointers *T and [*]T survive at the FFI boundary. extern declarations retain pointer syntax — intents do NOT propagate across the seam:
// Static shape (rank + dims known at compile time)
funcdot(viewa: tensor[f32, 128], viewb: tensor[f32, 128]) ->f32do ... end
// Rank known, dimensions runtime
funcrelu(editt: tensor[f32, _, _]) do ... end
// Rank-erased — accepts any-rank f32 tensor
functotal(viewt: tensor[f32, *]) ->usizedo
varn: usize=1
forkin0..t.rankdon=n*t.dim(k) end
returnn
end
// Polar embedding with dynamic N
funcsimilarity(viewa: polar[_], viewb: polar[_]) ->f32do ... end
Constraints: * SHALL NOT combine with explicit dimensions or _ (E2710). polar[*] is structurally undefined (E2711) — polar embeddings have no rank concept per SPEC-070 §2.1. Named-identifier dimensions (e.g., tensor[f32, batch, ch, h, w]) are reserved for SPEC-086 (Dimensional Algebra) and rejected in v0.1 (E2712).
Migration from SPEC-015 / SPEC-017-P (deprecated):
Lifetime annotations ('a) are eliminated from user-facing code. The &T / &mut T reference syntax is deprecated — & survives as the bitwise-AND operator and as the SPEC-017-P trait-union operator only.
Deprecation schedule (SPEC-085 §17.5):
v2026.5.X — both old and new syntax accepted; old emits deprecation warning
v2026.6.X — old syntax errors uniformly across all profiles
v2026.7.X — old syntax removed from parser; migration tool ceases recognition
The aggressive timeline is justified by the empirical migration footprint: zero functional code changes required across the Janus stdlib + Graf as of 2026-05-07 (SPEC-085 §17.2 grep evidence).
Inspired by Odin’s proc{...} overload sets. Janus adopts explicit overloading
with clear scope, referential transparency, and the ability to reference a specific
procedure when needed. No ambient overload soup.
Declaration:
// Overload set declaration — explicit, named list
overloadto_string= {
bool_to_string,
int_to_string,
float_to_string,
}
// Usage — dispatch by argument type
lets1=to_string(true) // -> bool_to_string
lets2=to_string(42) // -> int_to_string
lets3=to_string(3.14) // -> float_to_string
// Specific procedure reference
letf=to_string.int_to_string
letn=f(99)
Rules:
An overload set is a named group of functions that share the same semantic operation.
Dispatch is static, resolved at compile time by argument types. No runtime vtable.
Each member function MUST have a distinct signature — ambiguous overloads are
rejected at declaration time (E2710).
Overload sets are first-class values — they can be passed as parameters, stored
in structs, and re-exported through sovereign indexes.
The set name is the dispatch name; individual members are accessed via
set_name.member_name dot syntax.
// Re-export overload set through sovereign index
puboverloadto_string// re-exports the set
// Pass overload set as parameter
funcformat[T](val: T, fmt: overload) ->strdo
returnfmt(val)
end
lets=format(42, to_string) // dispatches to to_string.int_to_string
lett=process(consumemy_val) // consume matches take -> process_take
Overload sets vs trait dispatch. An overload set is a static name-resolution
mechanism. A trait is a type-class contract with generic dispatch. They serve
different purposes:
// Overload set: compile-time name resolution, same semantic operation
// Trait: type-class contract, generic dispatch over Self
traitSerializable {
funcserialize(self) -> [u8] !SerialError
}
Overload sets are NOT a replacement for traits. They are a replacement for
“multiple functions with different types but the same semantic name” — the
pattern that C programmers fake with foo_int, foo_float, foo_string naming
conventions. Janus makes this pattern first-class and compiler-checked.
Functions declare the side-effect classes they can transitively perform. The compiler tracks effects through the call graph and rejects programs that drop unhandled effects on the floor. Effects are to function signatures what intents are to parameters.
Governed by Law 1 (§2.1): All exported public functions MUST write their effect row explicitly. Public pure APIs write !{}. Public effectful APIs write !{IO}, !{Net}, !{Ui}, etc. Private functions may still infer. A public function contract is incomplete unless its effect row is explicit — no public API may hide world contact behind inference.
This is the third leg of the static-analysis tripod (alongside SPEC-085 parameter intents and SPEC-029 reference capabilities). It supersedes SPEC-030’s language surface; SPEC-030’s semantic foundation (static dispatch, monomorphization, capability bridging, visitor API) is retained verbatim by SPEC-090.
Effect declaration. Effects are declared with the effect keyword, mirroring error:
effectIO {
funcstdin_read(editbuf: [u8]) ->usize
funcstdout_write(viewbytes: [u8]) ->void
}
effectRandom {
funcnext_int(max: i64) ->i64
}
Effect operations use SPEC-085 parameter intents natively. Default implementations are forbidden — effects are pure contracts. Effect names are PascalCase; operation names are snake_case.
The !{Effects} return-type modifier. A function that performs effects declares them on its return type, parallel to !Error:
// Explicitly pure — !{} is a contract that introduces effects later trigger E2509
pubfunccanonical_hash(viewbytes: [u8]) -> [32]u8!{} do
returnblake3(bytes)
end
The braces follow Law 2 ({ } for data structures — the row is a set of effect tags). Order is not significant; duplicates dedupe silently.
The handle...with E do...end form. Handlers use do...end block form (Law 1, control flow), with each handler operation as a func definition:
letcfg=handledo
read_config("janus.toml")?
endwithIOdo
funcstdin_read(editbuf: [u8]) ->usizedo
returnstd.os.posix.read(0, buf)
end
funcstdout_write(viewbytes: [u8]) ->voiddo
discardstd.os.posix.write(1, bytes)
end
end
Multiple effects compose via repeated with EffectName do ... end clauses. Each with clause handles exactly one effect (combining is a parse error E2510).
handledo
game_turn("Alice")
endwithIOdo
funcstdout_write(viewbytes: [u8]) ->voiddolog.append(bytes) end
A handle is an expression; its value is the value of the handled expression. Handlers MUST cover every operation of each handled effect (E2502); innermost handler wins for a given effect; partial handling propagates unhandled effects to the enclosing scope.
Profile ambient handlers. Effects are available in every profile. What varies is the ambient handler table — which effects come with default runtime handlers automatically in scope:
Effect-clean by default. All effects must be handled explicitly.
:service
IO, Alloc, Net, Time
Standard service effects from runtime
:cluster
:service + Send, Recv, Spawn
Actor-related effects
:compute
:core + GPU, NPU
Hardware-acceleration effects
:sovereign
All-of-above + Boot, MMU, Hardware
Bare-metal effects
Effects outside the profile’s ambient set MUST be handled explicitly before reaching main (E2511). Effects are not bound by profile capability — any profile may declare and handle any effect; profile differences live in ambient defaults, not in language gating.
:core is effect-clean by ambient table, not by gate. A :core program may still declare and handle custom effects in lexical scope; the ambient set is empty so unhandled effects at main produce E2511.
Effect propagation. Unhandled effects propagate to the caller’s !{} row:
// greet performs IO — propagates
funcgreet(viewname: [u8]) ->void!{IO} do
IO.stdout_write("Hello, ")
IO.stdout_write(name)
end
// run_greeting calls greet — must declare !{IO} or handle it
funcrun_greeting() ->void!{IO} do
greet(b"World")
end
Calling an effectful function without handling or declaring its effects is E2501.
Effect polymorphism (?E, narrow form). A function may propagate effects from higher-order parameters via the ?E polymorphism variable:
// map propagates whatever effects f performs
funcmap[T, U](viewxs: [T], f: func(T) ->U!{?E}) -> [U] !{?E} do
varout: [xs.len]U=undef
foriin0..xs.lendo
out[i] =f(xs[i])
end
returnout
end
// Pure call site — map's monomorphized row is !{}
// IO call site — map's monomorphized row is !{IO}
letlogged=map(numbers, func(x: i64) ->i64!{IO} do
IO.stdout_write(str(x))
returnx
end)
?E is propagation through higher-order parameters only — not Koka-style row polymorphism. Mixing ?E with concrete effects in a single row (!{IO, ?E}) is forbidden (E2512). Multiple higher-order parameters use distinct names (?Ef, ?Eg) and compose by union.
Capability bridge. Effects describe what side effect class; capabilities (SPEC-029 + SPEC-080) describe who is permitted to access. The function declaring !{IO} does NOT require IO capabilities — those shift to the handler site:
// process declares !{IO} — no capability tokens needed
funcprocess(viewinput: [u8]) ->ProcessResult!{IO} do
IO.stdout_write("processing")
returnProcessResult.ok
end
// At the handler site, the body needs CapWrite for actual filesystem write
This separation enables purely-tested handlers (no capability ceremony) to coexist with production handlers (full capability requirements).
Migration from SPEC-030 (deprecated):
Old (deprecated)
New (canonical, SPEC-090)
func f() -> T with E do
func f() -> T !{E} do
func f() -> T with E1 + E2 do
func f() -> T !{E1, E2} do
func f() with E do
func f() -> void !{E} do (void made explicit)
handle expr with { E.op(args) => body, ... }
handle do expr end with E do func op(args) do body end ... end
Effects forbidden in :core/:script (E2503)
Effects available in all profiles; ambient table determines defaults
Lifetime annotations and reference syntax (SPEC-085 deprecation) compose with effect-syntax migration: a function previously written func f<'a>(buf: &mut [u8]) -> &'a [u8] with IO do becomes func f(edit buf: [u8]) -> [u8] !{IO} do.
Deprecation schedule (SPEC-090 §9.4):
v2026.5.X — both with E and !{E} accepted; with emits W2509 deprecation warning; profile gate E2503 demoted to warning
v2026.6.X — with syntax errors uniformly; E2503 fully removed
v2026.7.X — old syntax removed from parser
The aggressive timeline is justified by SPEC-030’s RATIFIED-but-NOT-IMPLEMENTED status: there is no production code using the SPEC-030 surface to migrate.
Resource capabilities gate access to resources (filesystems, networks, randomness, GPU/NPU contexts, hardware) at the language level. They are the authority leg of the static-analysis tripod: SPEC-090 effects describe what side effect can occur; SPEC-091 capabilities prove the right to perform it on a specific resource.
The defining principle:No ambient authority. Below :script, no resource-touching stdlib API is callable without an explicit capability token in scope. The token comes from the runtime, the hinge.kdl manifest, or a trusted authority-narrowing helper. Never from thin air.
Capability declaration. Resource capabilities are structs marked with @capability:
The @capability attribute (Three Sigil Worlds: @ is metadata) marks the struct as a capability token. The closed class universe is normative: .filesystem, .network, .cryptographic_random, .system_time, .environment, .process_control, .gpu, .npu, .hardware, .allocator (experimental), .application, .ui. Per-class scope universes (.read, .write, .read_write, .lifecycle, .main_surface, etc.) are also normative.
Application authority classes (Law 4, §2.1). Two capability classes unlock application development on :service:
Applications are :service programs with an application runtime and UI/resource capabilities. No separate :app profile exists. The same promotion path from :script prototype to :service production applies.
Construction restriction. Capability tokens SHALL be constructed only by the runtime (manifest-bound entry), authority-narrowing helpers in std.os.caps, or hazard-flagged forges (std.os.caps.unsafe_forge_*) with ⚠ sigil. All other construction is E2601:
// ❌ E2601: capability construction outside trusted context
letbad=FilesystemCap { _token: 42 }
Intent-only passing. Capability parameters MUST carry SPEC-085 intent qualifiers:
// ✅ view — read-only borrow of capability
funcread_file(viewfs: FilesystemCap, viewpath: [u8]) -> [u8] !FsError!{IO} do
returnstd.fs.read(fs, path)
end
// ❌ E2602: capability parameter without intent qualifier
funcbad(fs: FilesystemCap) do ... end
Effect-capability pairing. A function declaring !{IO} MUST hold a FilesystemCap (or other .filesystem-class capability). A function declaring !{Net} MUST hold a NetCap. Mismatch is E2603:
// ❌ E2603: !{IO} without FilesystemCap
funcleak(viewpath: [u8]) -> [u8] !{IO} do ... end
// ✅ effect-capability pair satisfied
funcgood(viewfs: FilesystemCap, viewpath: [u8]) -> [u8] !{IO} do
returnstd.fs.read(fs, path)
end
Manifest binding. A binary’s hinge.kdl declares its requested capabilities; the runtime materializes them and passes them to main:
// Binary received exactly the capabilities its manifest declared.
letcfg=read_file(fs, "/etc/janus/config.toml")?
letconn=connect(net, "forge.janus-lang.org:443")?
return0
end
A binary cannot acquire a capability it didn’t declare in its manifest. Link-time cross-validation (E2604) enforces this. The manifest is the audit surface — a reviewer reading it knows the binary’s full authority before reading code.
Authority narrowing. The stdlib provides std.os.caps.narrow_* helpers for monotonic capability narrowing:
Narrowing is one-way; widening is structurally impossible (no narrow_to_read_write exists). Narrowing helpers take view of the original capability — the original remains live; the narrower capability is a separately materialized token.
Composition with SPEC-029 reference capabilities. A capability MAY be wrapped in iso/val/ref/tag for cross-actor sending:
// Send a fs cap to another actor as an iso reference
The wrapper’s reference-capability rules apply normally; the underlying capability’s construction-restriction is independent of the wrapper.
Composition with SPEC-080 sovereign pledge/unveil. SPEC-091 sits above SPEC-080. SPEC-080 governs the OS-syscall surface (pledge { wpath } auto-materializes WpathCap); SPEC-091 governs user-space resource authority (FilesystemCap from manifest). A function may hold both:
pubfuncwrite_log(viewfs: FilesystemCap, viewbytes: [u8]) ->!void!{IO} do
Profile gating. Per the uniform-enforcement principle, the language surface (@capability declarations, capability-typed parameters) is available in every profile. What varies is which profile requires capability discipline in the stdlib:
Profile
Capability machinery
:script
Disabled by ambient table — implicit capabilities for ergonomics
:core
Available but not required (foundational types only)
:service
Required — all stdlib resource APIs take capability parameters
:cluster
:service + actor-bound capabilities (capabilities default to iso)
:compute
:core + GPU/NPU capability tokens
:sovereign
All — including raw hardware (.hardware class — MMU, interrupts, MMIO)
The :service row is where Janus claims operational sovereignty: every resource-touching :service stdlib function takes a capability parameter. There is no ambient std.fs.read(path) in :service. Only std.fs.read(fs, path). That is the operational difference between Janus and every other production language.
Refinement types extend the existing where clause (SPEC-026 trait bounds) to value predicates — invariants the compiler proves via an embedded SMT solver (Z3) at compile time. Pay-for-what-you-use: the solver runs only on functions with where clauses on values. Code without refinements pays nothing.
The where clause on values:
// Refinement on a primitive
typePositiveInt=i64whereself>0
// Refinement at a parameter
funcsqrt(viewn: f64wheren>=0.0) ->f64do
// Compiler proved n >= 0; no runtime check needed
end
// Cross-parameter binding (refinement of one param refers to another)
The where clause was already in the language for trait bounds. Refinements extend the same syntax to value predicates — no new keyword, no new structural shape.
The result keyword. For return-type refinements, result is the implicit binding for the returned value (mirroring self for receivers). It is bound only inside return-type where clauses; using it elsewhere is E2902.
Pure (effect row !{} per SPEC-090); calling effectful functions is E2903
Side-effect-free (no = assignments, no edit operations)
Straight-line expressions (no if/while/for inside the predicate; E2905)
SMT solver integration. The compiler ships with embedded Z3 (CVC5 fallback). Solver is invoked only for proof obligations generated by refinements. Solver invocations are cached across builds. When a proof fails, the compiler emits E2900 with the unprovable predicate, the source location, and a counterexample extracted from the SMT model:
E2900: refinement unprovable
at src/main.jan:42:8
expected: idx < arr.len
counterexample:
arr.len = 0
idx = 0
hint: the call site must establish idx < arr.len before this call
Three resolution paths for E2900:
Strengthen the type — convert the receiving variable to refinement-typed; pushes the obligation upward to the originating call site.
Add an explicit runtime check — if x_satisfies_predicate do call(x) end; conditional narrowing inside the then branch makes the predicate provable.
Mark with @trusted — downgrade E2900 to W2900; user accepts proof obligation explicitly. In :sovereign, @trusted produces a runtime panic if the predicate is observed false.
Conditional narrowing (occurrence typing). Inside a branch, the compiler narrows the refinement based on the branch condition:
funcsafe_sqrt(viewn: f64) ->f64do
ifn>=0.0do
returnsqrt(n) // n's refinement narrowed to `n >= 0.0` here; SMT proves it
else
return0.0
end
end
Composition with intents:
funcwrite_at(
editbuf: [u8] wherebuf.len>0,
viewidx: usizewhereidx<buf.len,
viewbyte: u8,
) do
buf[idx] =byte// both bounds proven, both checks elided
funcread_audit_log(viewfs: ReadOnlyFs, viewpath: [u8]) -> [u8] !{IO} do ... end
Profile gating — uniform language surface, profile-specific enforcement strictness:
Profile
Refinement enforcement
:script
Accepted, warnings only (E2900 → W2900) — ergonomic exception
:core
Optional, fully enforced when present
:service
Optional, fully enforced when present
:cluster
Optional, fully enforced when present
:compute
Recommended — tensor shape predicates valuable
:sovereign
Recommended + @trusted triggers runtime panic on violation
Refinements are never required. Code can compile without any where clauses on values; the SMT solver is invoked only when refinements are present. The pragmatic ruling per [REF:10.1.2]: profile gating differs from SPEC-085/SPEC-090 uniform-enforcement because refinements are an opt-in proof discipline — uniform enforcement of an optional feature would be incoherent.
What refinements subsume. Rust’s NonZeroU32, NonNull<T>, NonEmpty<T>, alignment newtypes, and ad-hoc wrapper-per-invariant patterns all reduce to ordinary types plus refinements. One general mechanism replaces a wardrobe of special cases.
SPEC-093 formalizes the three distinct compile-time concerns that were previously aliased in SPEC-027b. Three positions, three forms, one sigil-world (§):
Governed by Law 3 (§2.1): Every abstraction lowers as proof-only (erased after checking), specialized (monomorphized/inlined/statically dispatched), or explicit-runtime. No invisible heap, ARC, or dynamic dispatch. The compiler MUST expose cost through janus explain-cost.
Position
Form
Role
Lowering
Expression
§{ expr } or §builtin(args)
Returns a value at compile time
proof-only (erased)
Statement
comptime do ... end
Performs comptime side effects (asserts, codegen, sema checks)
proof-only (erased)
Parameter
comptime <name>: <type>
Marks a parameter as compile-time known
specialized (monomorphized)
Mixing positions is rejected: §{ assert(...) } as a top-level statement is E2950 (use comptime do ... end); let x = comptime do compute() end is E2951 (use §{ ... }).
The !{Comptime} effect class. Comptime blocks declare effect row !{Comptime}, which is mutually exclusive with runtime effect classes. IO, allocation, networking, randomness, time queries, actor effects, device dispatch are all forbidden inside comptime — composing the existing SPEC-090 effect-graph machinery with the comptime VM:
comptimedo
// Allowed: pure computation, type introspection, §-builtins
letinfo= §type_info(T)
letsize= §size_of(T)
// Forbidden:
// let f = open_file("foo.txt") // E2940: IO in comptime
// let buf = allocator.alloc(64) // E2941: Alloc in comptime
end
!{Comptime} implies total (per SPEC-094 [TOT:6.1.3]) — the comptime VM has bounded recursion limits, so any !{Comptime} function is automatically termination-clean. This makes comptime functions callable from total contexts without the infection-rule violation.
Profile gating: comptime is foundational language machinery, available in every profile.
A total function is one the compiler proves terminates on every input. The modifier appears before func, parallel to pub:
// Compiler-verified terminating
totalfuncfactorial(viewn: usize) ->usizedo
ifn==0doreturn1end
returnn*factorial(n-1) // structural recursion on n
end
// Explicit non-totality
partialfuncevent_loop() ->neverdo
whiletruedo
// ...
end
end
// Default — termination not asserted (backwards compatible)
funcparse_loop(reader: *Reader) ->!voiddo
whiletruedo ... end
end
Three termination proof strategies:
Structural recursion — recursive call has a strictly smaller argument by a well-founded ordering (n - 1, xs.tail(), subterm). Free; no SMT invocation.
Bounded loops — for i in 0..N do where N is comptime-known or refinement-bounded. Free.
Explicit termination measure — total func f(...) -> T by <expr> declares the decreasing measure. Verified via SPEC-092 SMT solver.
Total infection rule. A total function MAY NOT call a non-total function (E2951). Same propagation as SPEC-090 effect rows — totality is a property the compiler refuses to silently lose.
Composition. Total composes orthogonally with effects: total !{} (pure-total — most restrictive, for cryptographic primitives), total !{IO} (terminating with IO — for SLA-critical paths), partial !{} (pure but may diverge — functional iterators), partial !{IO} (default — most permissive). Refinements provide preconditions; totality provides termination; both verified independently.
Profile gating — uniform language surface, profile-specific mandates:
Profile
Total guarantees
:script
Optional, warnings only
:core
Stdlib hot paths SHALL be total where feasible (std.core.conv, std.core.mem, std.math primitives)
:service
Optional but recommended for SLA-critical paths
:cluster
Required for any @realtime-tagged actor (per SPEC-021)
:compute
Tensor primitives SHALL be total (shape-bounded loops always terminate)
:sovereign
Cryptographic primitives MUST be total (side-channel discipline; constant-time guarantee)
When the compiler cannot construct a termination proof, it emits E2950 with diagnostic guidance: strengthen the recursion structure, add a by <expr> measure clause, or downgrade to non-total. The @trusted total annotation downgrades E2950 to W2950 for migration cases; in :sovereign, @trusted produces a runtime panic if non-termination is observed.
Shape types are structural type constraints over records. A function declares which fields it depends on; any record containing those fields satisfies the parameter type.
// Function depends on `name: string`; accepts any record with that field
// Both compile — each call site monomorphizes shape against the concrete type
The ..r row variable captures additional fields beyond the listed ones. Declared in the type-parameter list alongside type parameters (lowercase convention to distinguish: r, s for rows; T, U for types). At each call site, r instantiates to the concrete extra-field set; the function is monomorphized per instantiation.
Strict matching — field-type matching is exact; no implicit subtyping (E2961). Order of fields is not significant. A shape without ..r rejects records with extra fields (E2962); the lenient form (with ..r) is the common case for schema-evolution-friendly APIs.
Schema evolution use case — the killer application:
structConfigV1 { host: string, port: u16 }
structConfigV2 { host: string, port: u16, tls: bool } // new field
The §row_fields builtin returns the comptime list of row-variable-captured fields, composing with SPEC-093’s expression-form §-builtins.
Profile gating — the single explicit exception to uniform language surface. Shape types are available in :service and above; forbidden in :script and :core (E2960). The exclusion is documented honestly in SPEC-095 §10.1.2: row-polymorphic type inference adds compile-time complexity that foundational profiles deliberately omit. This is the single profile-availability exception in tonight’s eight-axis design batch — every other axis preserves uniform language surface.
// Exhaustive pattern matching (uses { } not do...end)
matchkind {
.blob=>decode_blob(data),
.tree=>decode_tree(data),
.checkpoint=>decode_checkpoint(data),
else=>returnerror.UnknownKind,
}
// With guards
matchrequest {
.Create(data) whendata.is_valid=>process(data),
.Delete(id) whenid>0=>delete(id),
else=>reject("invalid"),
}
Doctrine: else, _, and discard — one symbol, one meaning
Symbol
Level
Purpose
Valid context
else
Expression
Match fallback (catch-all)
match x { else => ... }
_
Pattern
Discard binding
let _ = expr, catch |_|, (_, 0, _) in destructure
discard
Statement
Discard expression result
discard some_function()
Use else for match fallbacks (catch-all arm)
Use _ when you don’t need a value in a pattern context (variable binding, catch, destructuring)
Use discard when calling a function for side effects and ignoring its return value at statement level
.Variant patterns (e.g., .blob, .Create(data)) are type-inferred from the match subject and encouraged — the compiler already knows the enum type, so repeating it is noise.
See also: §21 Algebraic Effects (SPEC-090). Error unions (!ErrorType) and effect rows (!{Effects}) both sit at the return-type position and stack together: func f(...) -> T !ErrorType !{Effects} do ... end. Errors are control/result alternatives; effects are world-contact obligations. They share return-type position but not ontology.
The discard keyword explicitly discards the result of an expression. This is used when calling a function for its side effects but ignoring its return value.
// Discard a return value
discardsome_function()
// Discard with error handling
discardmay_fail() catch|err|do
log_error(err)
end
// Common use case: logging
discardlogger.write("message")
// Multiple discards in sequence
discardposix.close(fd)
discardallocator.free(buf)
Note: Unlike Zig’s _ = expr syntax, Janus uses discard expr for explicit clarity. The discard keyword makes the intent obvious: “I am intentionally ignoring this result.”
Compile-time evaluation. Code runs in the compiler, vanishes before the binary exists.
Status:comptime blocks, comptime params, and the §-builtins (§size_of,
§align_of, §offset_of, §is_integral, §is_float, §fmt, §compile_error, and
the :service-tier §type_info/§type_name/§fields/…) work today (cross-module
included). inline for / inline switch unrolling (§8.5) and comptime float
arithmetic are NOT yet wired into QTJIR lowering (tracked in
compiler/comptime/README.md “Phase 2 TODO”).
Same bits, different type. Source and target must have identical size and alignment.
letf=bitcast[f32](0x42280000) // 42.0
bitcast[T] is for value reinterpretation only. Pointer-like types (*T, [*]T, *const T, etc.) are rejected in bitcast — pointer reinterpretation belongs to std.mem.reinterpret[T].
Compiler diagnostics:
E_CONV_POINTER_TARGET — bitcast target is a pointer type (*u8, *const T, etc.). Use std.mem.reinterpret[T].
E_CONV_POINTER_BITCAST — bitcast target is pointer-like ([*]u8). Pointer reinterpretation belongs to std.mem, not std.conv.
The [*]T bracket trap.[*]T is the many-item C pointer type. When nested inside generic brackets, it becomes bitcast[[*]u8] — visually dense and easy to misplace the closing bracket. Use a local type alias for readability:
parse is strict — trailing input is an error. parseConsume advances the slice past the consumed prefix; the edit intent (SPEC-085) declares exclusive mutable access to the input slice for the duration of the call.
format writes into a caller-owned buffer via the edit intent (SPEC-085). Returns Err(BufferTooSmall { needed }) if buffer too small — buffer is unchanged on error (atomic). formatLen returns exact byte count without touching any buffer.
use is the Janus-native module import mechanism. It is the ONLY way to import
Janus modules. @import("path") is Zig’s private plumbing and MUST NOT appear in
Janus source code outside of use zig graft declarations.
The use statement creates a namespace binding named after the last path component.
The compiler resolves the path by joining identifiers with / and appending .jan,
relative to the source file’s directory.
// Import a module — binds the name "types" to the module's exports
useidentity.types
// Access symbols through the namespace
letdid=types.parse_did("did:key:z6Mk...")
leterr=types.ResolveError.NotFound
// Import from same directory
useresolver
useenvelope
// Import from subdirectory
usemethod.key
usevc.normalize
// Import from Janus stdlib
usestd.encoding.cbor
usestd.crypto.mldsa65
Resolution algorithm (pipeline Stage 2.5):
Join path components with /, append .jan → e.g., identity/types.jan
Resolve relative to source file’s directory
Fallback: resolve relative to CWD
Future (SPEC-041): resolve by CID via content-addressed registry
use zig "path" is resolved by Janus using five strategies:
absolute path
path relative to the current Janus source file directory
std/... paths under Janus root (JANUS_RUNTIME_DIR is used to infer Janus root as its parent)
bare names:
std/<name>/mod.zig if it exists
std/<name>.zig fallback
JANUS_ZIG_ROOTS env roots (Gap 111): a colon-separated list of absolute external repository roots (e.g. JANUS_ZIG_ROOTS=/repo/kernel/src:/repo/other); <root>/path is tried for each root in declared order, first existing file wins
cwd fallback as the final fallback
The resolved absolute file is parsed into the extern registry before codegen.
Concrete example (assuming JANUS_RUNTIME_DIR=/repo/janus/runtime):
// Janus source
usezig"std/net/http/client.zig"
Janus resolves this as:
JANUS_RUNTIME_DIR points at /repo/janus/runtime
Janus root is /repo/janus
final Zig source path becomes /repo/janus/std/net/http/client.zig
In Stage 6b, Janus compiles each gathered Zig file with zig build-obj and links the resulting object back into the final binary.
For the Zig file itself, normal Zig import rules apply at compile time:
// In the gathered Zig source (or another nearby module)
constclient=@import("std/net/http/client.zig");
Because the module is already being compiled from Janus std tree roots, this import resolves via Zig’s own module rules to the same Janus runtime-standard layout.
Janus additionally injects a small compatibility module wiring set during this compile step for known bridge dependencies (noise, sys_random, compat_fs, compat_time) so those imports keep deterministic object-caching behavior.
Grafting Doctrine (GD-1): Foreign libraries are explicit, contained, and replaceable.
Separate grammar forms for each graft type. The = assignment syntax binds a local alias;
the right-hand side declares the foreign source.
This is Odin’s best idea, stolen and hardened for Janus. A vendor package is NOT
“random thing downloaded from the internet.” It is a curated graft capsule —
convenience with CID pinning, generated wrappers, profile tags, and capability manifests.
Syntax:
// Curated vendor graft — alias = vendor "namespace:name@version"
Vendor Library Resolution.graft vendor does NOT download libraries from the internet.
It resolves against system-installed libraries already present on the host. The KDL
manifest provides metadata and integrity verification; the actual .so/.a/.dylib bytes
live on the filesystem, installed through the platform’s native package manager.
Manual path override — --vendor-path=/custom/lib compiler flag
The Janus compiler never reaches out to the network during vendor resolution.
The source field in the manifest records the upstream origin for audit and rebuild
verification — it is NOT a download URL.
Integrity verification. Before linking, the compiler hashes the resolved library
and compares against the manifest’s sha256. Mismatch produces E2720 — the
system-installed library does not match the curated capsule. The user must either
install the correct version or pin a different version in the graft declaration.
Fallback to prebuilt (opt-in only). If no system library is found, the compiler
emits W2721 with the upstream source URL. The user may then explicitly request
a prebuilt download:
Terminal window
janusbuild--vendor-fetch=allowapp.jan
This downloads the prebuilt from a Janus-curated mirror, verifies the hash, and
caches it under ~/.cache/janus/vendor/. Network access is gated behind an explicit
flag — no silent downloads, no ambient internet dependency.
This is the operational difference from Odin: Odin’s vendor library is bundled with
the compiler distribution. Janus’s vendor system trusts the host’s package manager
and cryptographically verifies what it finds. The capsule manifest is the bridge
between “the system already has raylib” and “the compiler can prove it’s the right one.”
Generated wrapper namespace. After grafting, use the alias directly or via
the graft namespace:
graftfoo=package"hinge:somebody/[email protected]"// external Hinge package, signed/CID-pinned, less trusted
Property
vendor
package
Curation
Janus-reviewed
Community-submitted
Wrapper
Checked-in, maintained
Auto-generated
Stability
Stable, versioned API
Best-effort
Capabilities
Manifest-declared, verified
Self-declared
Use case
Graphics, game dev, crypto, compression
Ecosystem libraries
This matters because Raylib, SDL, curl, Lua, Vulkan bindings are things we can bless.
“Somebody’s AI-generated websocket library” should not wear the same priest robe.
11.3.4 Odin Theft — What We Steal and What We Don’t
Vendor batteries for graphics/game/app development
Explicit overload sets (§4.6.1)
Data-oriented ergonomics (vec types, §3.9)
Zero-drama C interop
Fast “first 30 minutes” experience
Do NOT steal blindly:
using as silent field promotion everywhere
Ambient vendor trust (npm with a nicer coffin)
Public-by-default package culture
“Manual memory is fine because adults are present” as the whole safety story
Janus does better: explicit capability tokens, manifest authority, profile escalation,
and generated wrappers where FFI danger is visible. SPEC-091’s direction is exactly that:
resource access is gated by manifest-bound capability tokens, not ambient calls from anywhere.
Odin is worth studying because it is joyful. Janus must not become so doctrinally
armored that nobody wants to touch it. A sovereign language still needs a
“build the damn thing” path. graft vendor is that path.
Mutex[T] protects a Janus value with Drepper Take-3 futex semantics: uncontended lock acquisition uses an atomic compare-and-swap, contended acquisition parks through Parker, and release unparks one waiter when the waiter bit is present.
After mutex_release(&guard) returns, using that guard again is undefined behavior.
A Mutex[T] address must remain stable while tasks may park on it. Heap-allocate or stack-pin by call-site discipline until Pin / move-on-drop support lands.
Status: ./scripts/zb test-mutex-smoke and ./scripts/zb test-parker-roundtrip lock the single-threaded smoke surface. Multi-threaded proof, adaptive spin-then-park, poisoning, and type-system-enforced pinning are deferred.
// NOTE: Zig-style `.field = value` with dot prefix is NOT Janus syntax.
// Janus uses `field: value` (colon, not equals, no dot).
Struct Initialization Completeness (Law 10):
Struct initializers MUST list all fields unless ..defaults is present. This eliminates silent bugs when fields are added to structs — every callsite is forced to either provide the new field or explicitly opt into defaults.
:cluster ([PARTIAL]) — typed actors, grains, message protocols, supervision trees, Promise/PromiseResolver, and local GrainStore persistence are complete; cross-node distribution (Cluster.join/.migrate/.locate) and memory-placement (alloc[...]) are the future parts. See §18.
:compute ([PARTIAL]) — std.compute.vector (SPEC-237), coordination, polar, splat_pipeline shipped; tensor is v1-minimal (runtime shape); GPU/NPU dispatch not implemented. See SPEC-233.
:sovereign ([PARTIAL]) — grammar only (requires/ensures/ghost lexed, not enforced); pledge/unveil Phase B/C pending. See §19 / SPEC-080.
(:script IS available today — SPEC-045 v1.4.0, desugar Pass 1+2 + AOT run contract. janus run --jit is a dev-only path with known bugs; type introspection is :service-gated.)
18. :cluster Profile — Actors, Grains, and Distribution [PARTIAL]
Status: SPEC-021 v0.6 DRAFT. The local actor/grain model is implemented:
typed actors and grains, message protocols, supervision trees, Promise/PromiseResolver
(SPEC-236), and GrainStore persistence all lower to :service primitives. What is
not implemented is the distribution runtime (Cluster.join, .migrate, .locate)
and the memory-placement policies (alloc[Local.Exclusive], alloc[Session.*], …) —
those are the intended-but-future surface.
The :cluster profile provides distributed systems primitives: typed actors, grains with virtual identity and owned state, supervision trees, and capability-gated placement. Every construct desugars to :service primitives. No hidden runtime.
Terminal states: Pending → Resolved | Failed | Cancelled. Fire-and-forget unit
variants still use .send(). Pipeline segments on unresolved promises and SBI
PromiseId layout are later SPEC-236 phases.
Grains are virtual identities with owned state. They activate as typed actors when work arrives, but the identity and its state outlive any one activation. Persistence is the first v1 capability; migration and placement are later runtime behavior under the same source contract.
Normative shortcut: grain Name(msg: T) remains valid for runtime-assigned identity, but namespace lookup and durable ownership use grain Name(id: Id, msg: T). A runtime must enforce exactly one active activation per durable identity.
“A pledge is not a wish. It is a contract the compiler enforces before the binary exists, and the kernel enforces after it runs.”
Status: SPEC-080 v0.1.3 Phase A grammar only. The pledge { ... } / unveil { ... }
compiler proof and OS enforcement (Phase B/C) are not implemented — do not write
code that relies on them yet.
Normative source: SPEC-080 v0.1.2. This section is a quick reference; when in doubt, read the spec.
exec is a full-bypass promise. It exists for fidelity with the underlying OS surface, but operators should treat it as dangerous rather than routine (PU-W004).
Extension promises live in Promise_ext (ext::audio, ext::video, ext::bpf, ext::dpath, …). Platform-gated; see SPEC-080 §4.2.2.
ext::error requires explicit build-manifest opt-in; forbidden in release builds (PU-E006).
Permissions:.read, .write, .execute, .create – enum-set braces, symmetric with the pledge promise set.
Commit semantics – whitelist is committed at function entry, irrevocable for process lifetime (OpenBSD semantics).
Path validation – literal paths canonicalized at compile time; .., null bytes, and conflicting duplicates rejected (PU-E005).
Migratable grains MUST use capability-handle unveil – paths do not survive migration. Use cap::graf_blob(...), cap::skv_bucket(...), cap::did_artefact(...) instead of string literals (PU-E007 otherwise).
Pledge/unveil diagnostics use the PU- prefix. E is an error, W is a warning, P is a placement failure, R is runtime, F is a fuzzer-discovered drift. Full table in SPEC-080 §8.3.
Symptoms: Trait hierarchies with more than two levels; impl blocks that mirror a human ontology (trucks are subclasses of vehicles, therefore Truck extends Vehicle).
The problem: Compile-time hierarchies that mirror the human-perceivable domain model are almost always wrong. The domain model describes what the user experiences. The code’s organization should answer to the machine’s leverage points.
The rule:Operations are added more often than entity types. Code organized around verbs scales; code organized around nouns calcifies.
Symptoms:for entity in entities { entity.update(dt) } inside a tight for loop or hot while.
The problem: Virtual dispatch through a trait method adds a branch misprediction and an indirect call per entity. At millions of entities, this is the primary bottleneck.
The rule: See SPEC-082 Decomposition Doctrine §4. Move hot loops to component array iteration. Reserve trait dispatch for configuration, policy objects, and non-performant paths.
Symptoms: Struct with private fields and getter/setter methods, used as the unit of iteration in a tight loop.
The problem: Getters and setters prevent the compiler from placing struct fields in contiguous arrays. Cache locality collapses.
The rule: Encapsulation has a placement problem, not a quantity problem. Draw the encapsulation boundary where the leverage lives. For compute-heavy code, this means component arrays with public fields and explicit mutation.
Symptoms: Every struct becomes an actor or grain. Messages for every small operation. Supervision trees for tasks that do not need restart semantics.
The problem: Actors carry overhead: mailbox discipline, message serialization, supervision latency. The floor cost exists regardless of entity count.
The rule: Actors are for state that must survive messages, restart, or distribution. Use them when physics enforces encapsulation. Use components and systems when the problem is data transformation inside a single execution context. See SPEC-082 §3 for the decision heuristic.
Symptoms: Adding a new operation requires adding an impl to every existing type in the hierarchy.
The problem: The classic expression problem. Every new operation creates N changes across N types.
The rule: Design for verb growth, not noun growth. A Query<(Position, Velocity)> { } system can be added once and automatically applies to every entity that has those components.
Status: the internal effect graph + visitor analysis landed, but the user-facing
language surface — !{Effects} rows on return types, effect declarations, and
handle … with … handlers — is not lowered/enforced by the compiler today. Write
against SPEC-090 as doctrine, not as a working feature.
Doctrine:doctrines/three-leg-tripod.md. Effects are the third leg of the static-analysis tripod: what does this function do to the world?
// Multi-effect — order within the braces is not significant.
funcgame_turn(viewplayer: [u8]) ->i64!{IO, Random} do
letroll=Random.next_int(6) +1
IO.stdout_write(player++" rolled: "++str(roll))
returnroll
end
The !{...} syntax is parallel to !Error. The braces follow Law 2 ({ } for data structures) — the effect row is a set of effect tags, not control flow. Duplicates within a row are deduplicated and emit warning W2516 (silent dedup would violate Revealed Complexity).
A function with !{} and a function omitting the row are not semantically identical:
// Pure-by-default — adding `IO.stdout_write(...)` later just changes the inferred row.
funcprocess(viewbytes: [u8]) ->Resultdo ... end
// Explicit purity assertion — adding any effect later produces E2509.
pubfunccanonical_hash(viewbytes: [u8]) -> [32]u8!{} do
returnblake3(bytes)
end
!{} is for the contract surface (public API, audit-critical functions, pinned-purity utilities); the no-row form is for everyday composition. Two ways to say “pure” exist precisely because the intent differs — Revealed Complexity, not sugar.
Effect handlers use do...end block form (Law 1, control flow), not the brace-delimited handler list of SPEC-030. Each with EffectName clause handles exactly one effect (multi-effect-per-clause is E2510):
handledo
handled_expression
endwithEffectNamedo
funcoperation_name(params) ->ReturnTypedo
handler_body
end
end
The handler body is a collection of func definitions — one per operation declared in the effect. Each handler is imperative code, not a data table. handle do ... end is an expression — it produces the value of the handled expression and composes inside let, function arguments, etc.
An effect that reaches main and is not in the active profile’s ambient table must be wrapped in an explicit handle...with block; otherwise E2511 fires.
Doctrine: profiles scale proof obligations and ambient powers, not truth. Effect syntax is uniform across every profile. The :script lesson from SPEC-085 §2.8.2 is reapplied — language-surface constructs must not lie. Profile differences live in ambient tables and capability availability, not in language gating.
When an effect operation declares a take parameter (or operates on an iso T / ~T argument), the caller’s affine binding is consumed at the effect-call site, not at the handler-body invocation site. The handler is opaque to the caller:
effectAlloc {
funcfree(takeptr: *u8) ->void
}
funcrelease(takeptr: *u8) ->void!{Alloc} do
Alloc.free(ptr) // ptr's ledger transitions to Dead at THIS call.
// ptr.read() // ❌ E2602: use of consumed binding (SPEC-029)
Capabilities answer: who authorized this realization of that effect?
A function may declare !{FileRead} without holding CapFsRead. A handler that realizes FileRead against the actual filesystem requires CapFsRead. A test handler reading from an in-memory table requires no capability.
This separation is the doctrinal core: what a function does (effects) is decoupled from how those effects are fulfilled (capabilities). The same function tests purely and runs in production with real authority.
Effects name the debt. Capabilities authorize payment. Totality proves the ledger closes.
The SPEC-030 with E clause syntax is deprecated. During the migration window (v2026.5.X → v2026.7.X), the parser accepts the legacy form and rewrites to !{E} with W2509:
// Old (SPEC-030, deprecated, surfaces W2509):
funclegacy() ->i32withIOdo ... end
// New (SPEC-090, canonical):
funccanonical() ->i32!{IO} do ... end
After v2026.7.X the with clause produces a parse error.
Effects compile via static dispatch through monomorphization. No continuations, no coroutines, no CPS transform, no runtime effect stack. The handler is resolved at compile time and inlined as a direct function call.
After lowering, effect-typed code is indistinguishable from hand-written direct-call code. Effect rows are fully erased — they exist only for the compile-time proof.
This zero-runtime-cost stance is doctrinal. Janus does not adopt OCaml-style runtime continuation machinery; that path is closed without an explicit future SPEC reopening it.
The compiler exposes two query entry points over the per-function effect data ([EFF:7.1.4]):
declaredEffectRowOf(f) — the contract surface. The !{...} row the user wrote. What callers see; what auditors read.
inferredEffectSetOf(f) — the deep view. Post-handle-elimination callee-propagated set unioned with profile-gate-satisfied visitor contributions. What tooling consults when peering past the contract.
Conflating them would lie to either the user (declared row hides handler-relocated effects) or the auditor (inferred set leaks pre-handle internal state into public APIs).
The compiler exposes a stable visitor API at effect_graph.zig::EffectVisitorRegistry for compiler-internal consumers. The first concrete consumer is SPEC-080’s @syscall_class aggregator:
@syscall_class(stdio)
pubfuncwrite(fd: Fd, buf: []constu8) ->Result[usize, Errno] do ... end
// Multi-class disjunction (§8.1.2) — readlink is rpath + stdio-adjacent.
@syscall_class(rpath, stdio)
pubfuncreadlink(path: []constu8, buf: []u8) ->Result[usize, Errno] do ... end
Under :sovereign (the visitor’s profile gate), every call to a @syscall_class-annotated function contributes Syscall.<class> to the calling function’s inferred effect set. Under lower profiles the visitor is silently inactive — no diagnostic, no overhead.
The Janus-side surface (std.compiler.effects) declares the contract types and the registration entry point; Janus-bodied visitor execution lands with Phase D’s comptime evaluator integration. Compiler-internal consumers register Zig-side via EffectVisitorRegistry.register().
“The fastest serialization is no serialization. The clearest code needs no comments. The best compiler is the one that catches your mistakes before they exist.”