Skip to content

Janus Language Reference

Version: 2026-06-07b — Vendor resolution clarified. graft vendor resolves against system-installed libraries (pkg-config, /usr/lib/) with sha256 verification against curated capsule manifests. No network access during resolution; --vendor-fetch=allow opt-in for prebuilt fallback. Diagnostic codes E2720 (hash mismatch), W2721 (library not found). 2026-06-07a retained: Odin Theft (vendor batteries, overload sets, vec types). Profiles covered:

  • :core — complete (locked 2026-03-07)
  • :service — complete (channels, select, nursery, spawn, row polymorphism, std.sync parker/mutex)
  • :script — almost complete - SPEC-045 ratified; stdlib build-out in progress (SPEC-048 → 046 → 047 → 049) Lowering progress ~80%
  • :cluster — DRAFT (RFC-021); P0 prerequisites: numeric tower in llvm_emitter.zig, std.sync. Lowering progress ~20%
  • :compute — DRAFT (SPEC-050 polar/relative types shipped; tensor + GPU/NPU kernels WIP)
  • :sovereign — DRAFT (SPEC-080 Phase A.0 held by Virgil verdict; pull-on-demand on dated OpenBSD target)

Purpose: Single-source reference for humans and AI agents writing Janus code


This is the foundational law of Janus syntax. Every sigil encodes a phase. No sigil crosses phase boundaries.

SigilWorldPurposeExamples
§Compile-timeStructural computation, type reflection, code generation§size_of(T), §type_info(T), §{ ... }
$ExtractionRuntime pattern capture (regex, PEG, structured parsing)$1, $2, $*
@MetadataFFI bindings, attributes, capability declarations@ffi(...), @requires(...), @replicate(...)

The Desugaring Law: § lowers to compile-time AST nodes. $ lowers to runtime pattern values. @ attaches declarative metadata. No cross-phase aliasing exists.

Migration: $size_of§size_of (deprecated, W5001 warning). The $ sigil is now exclusively reserved for runtime extraction.


IDENT <- [_A-Za-z][_A-Za-z0-9]*
// Integers
42 // decimal
0xFF // hexadecimal
0b1010 // binary
0o77 // octal
1_000_000 // underscores for readability
// Floats
3.14
2.5e10
1.0e-3
// Strings
"hello" // UTF-8
"line\nnewline" // escape sequences
§"Value: {x}" // string interpolation
§"Pi is {pi:.2f}" // with format spec
// Boolean
true
false
// Line comment
//! Module-level doc comment (describes the file)
/// Item-level doc comment (describes the next declaration)

1.4 Documentation Comments (Sovereign Docs)

Section titled “1.4 Documentation Comments (Sovereign Docs)”

Documentation comments use key: syntax (no @ sigil):

/// Opens a file for reading.
///
/// param: path Filesystem path to open
/// returns: File handle or error
/// error: FsError.NotFound Path does not exist
func open_file(path: str) !File do
// ...
end

Reserved keys: param:, returns:, error:, capability:, since:, see:, deprecated:, safety:, complexity:


2. The Two Structural Laws — Haiku Block Discipline

Section titled “2. The Two Structural Laws — Haiku Block Discipline”

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.

All control-flow and executable bodies use do...end. No exceptions.

func add(a: i64, b: i64) -> i64 do
return a + b
end
if count > 0 do
process()
else
skip()
end
while i < len do
i = i + 1
end
for entries do |entry|
process(entry)
end
comptime do
assertsize_of(Header) == 32, "bad layout")
end
test "round-trip" do
try round_trip(test_data)
end

The following is ILLEGAL:

{
return 42
}

Anonymous braced executable blocks do not exist in Janus.

Braces are exclusively for data-shaped constructs — structs, enums, match arms, struct literals. Never executable code.

struct Coord {
col: i32,
row: i32,
}
enum ObjectKind {
blob,
tree,
change,
}
let c = Coord {
col: 4,
row: 2,
}
match kind {
.blob => decode_blob(data),
.tree => decode_tree(data),
else => fail DecodeError.UnknownKind,
}

Law 2a: Struct Literals Require a Type Head

Section titled “Law 2a: Struct Literals Require a Type Head”

A struct literal MUST begin with a type path. There are no anonymous struct literals.

Valid:

Coord { col: x, row: y }
std.crypto.Hash { algorithm: .blake3 }

Invalid:

{ col: x, row: y }

Law 2b: Named Expression Blocks (The Escape Hatch)

Section titled “Law 2b: Named Expression Blocks (The Escape Hatch)”

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.

let value = choose: {
if ready do
break :choose 42
end
break :choose 0
}

Named expression blocks are rare and explicit. They are the sole exception to Law 2.

A statement ends at the first of: newline, ;, end, else, }, EOF.

An expression may continue across lines only when the line is visibly incomplete:

  • After an operator
  • Inside parentheses, brackets, or braces
  • After a comma, |, or =>
return x + y // one statement
return x + // expression continues — line ends with operator
y // completes the return
return // complete statement — bare return
foo() // separate statement — never return foo()
verbs stand upright (do...end)
data is held in braces ({ })
escape hatches have names (label: { })

No anonymous fog. No inherited brace swamp. One visual shape, one meaning.

Parser test:

  • { return 1 } → REJECT (anonymous executable block)
  • blk: { return 1 } → ACCEPT (named expression block)
  • Foo { x: 1 } → ACCEPT (struct literal with type head)
  • { x: 1 } → REJECT (anonymous struct literal)

2.1 Janus 1.0 Semantic Contract — The Six Laws

Section titled “2.1 Janus 1.0 Semantic Contract — The Six Laws”

Ratified: 2026-06-07. Authority: Markus Maiwald. Doctrine tier: Constitutional. Governed by: SPEC-101 (Janus 1.0 Semantic Contract Amendment).

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.


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.

// ✅ Correct — public API declares purity
pub func canonical_hash(view bytes: [u8]) -> [32]u8 !{} do
return blake3(bytes)
end
// ✅ Correct — public API declares effects
pub func load_config(view fs: FilesystemCap, view path: [u8])
-> Config !FsError !{IO} do
let bytes = std.fs.read(fs, path)?
return parse_config(bytes)?
end
// ❌ Illegal for public API — effect row inferred
pub func load_config(view fs: FilesystemCap, view path: [u8])
-> Config !FsError do // E2610: public API with inferred effects
let bytes = std.fs.read(fs, path)?
return parse_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.

// Strict: argument evaluated before call
let x = compute()
use(x)
// Explicit laziness — Lazy[T !{Effects}] carries forcing effects
let delayed: Lazy[Config !{IO}] = Lazy.defer do
read_config(fs, "app.toml")
end
// ❌ Illegal — hidden IO behind lazy type
func pure_looking() -> Lazy[Config] !{} do
return Lazy.defer do
read_config(fs, "app.toml") // E2620: hidden effect in lazy value
end
end
// ✅ Correct — lazy type carries its forcing effects
func delayed_config(view fs: FilesystemCap)
-> Lazy[Config !{IO}] !{} do
return Lazy.defer do
read_config(fs, "app.toml")
end
end

This takes Haskell’s purity without inheriting lazy cost opacity. No thunk fog. No space-leak surprises hidden behind “pure” syntax.


Every abstraction has one of three lowering classes:

  1. proof-only — erased after checking
  2. specialized — monomorphized / inlined / statically dispatched
  3. 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.


Application development uses the same :service profile, extended with application-specific capabilities and effects. No new :app profile exists.

Standard application capability/effect classes:

@capability(class: .application, scope: .lifecycle)
struct AppCap {
_token: u64,
}
@capability(class: .ui, scope: .main_surface)
struct UiCap {
_token: u64,
}
effect Ui {
func invalidate(view ui: UiCap) -> void
func render(view ui: UiCap, take tree: ViewTree) -> void
}
effect AppLifecycle {
func quit(view app: AppCap, code: i32) -> void
}

Entry point:

pub func main(
ctx: Context,
view app: AppCap,
view ui: UiCap,
view fs: FilesystemCap,
) -> i32 !{AppLifecycle, Ui, IO} do
let state = CounterState { count: 0 }
return std.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.

// ✅ Correct — environment holds state only
struct CounterEnv {
count: *Signal[i32],
}
func increment(env: *CounterEnv, ctx: Context, view ui: UiCap)
-> void !{Ui} do
env.count.set(env.count.get() + 1)
std.ui.invalidate(ui)
end
// ❌ Illegal — authority smuggled in callback closure
func bad_callback() -> Callback do
let fs = get_fs_cap()
return Callback.new(func() -> void !{IO} do
std.fs.read(fs, "/etc/passwd") // E2630: smuggled authority
end)
end

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.


The Janus language has no implicit reference-counting memory model. ARC/ORC-like behavior is allowed ONLY as explicit stdlib/runtime types.

// ✅ Systems-grade default — ownership + Destroy + explicit allocation
func parse_packet(view bytes: [u8]) -> Packet !{} do ... end
// ✅ Application world — explicit Rc/Weak
let model = Rc[CounterModel].new(app_alloc, CounterModel { count: 0 })
let weak_model = model.weak()
// ❌ 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.


TypeSizeDescription
i8, i16, i32, i641-8 bytesSigned integers
u8, u16, u32, u641-8 bytesUnsigned integers
usizeplatformPointer-sized unsigned
f32, f644-8 bytesIEEE 754 floats
bool1 bytetrue or false
void0 bytesNo value

:core universal integer model: i64 is the default integer type.

// Arrays (fixed-size, comptime-known length)
var buf: [32]u8 = undefined;
const zeros: [4]i64 = .{ 0, 0, 0, 0 };
// Slices (runtime-length view into array)
name: []const u8 // immutable byte slice (string)
data: []u8 // mutable byte slice
// Pointers
ptr: *T // single-item pointer
ptr: *const T // const pointer
ptr: *[N]u8 // pointer to fixed-size array
ptr: [*]const u8 // many-item pointer (C interop)
// Optional
value: ?T // T or null
field: ?u64 = null // optional with default
struct TreeEntry {
name: []const u8,
entry_cid: [32]u8,
mode: u8,
}
// Optional fields with defaults
struct Change {
message: []const u8,
timestamp: i64,
sequence: ?u64 = null, // optional, defaults to null
lamport: ?u64 = null,
}

3.4 Extern Struct (Wire-Compatible Layout)

Section titled “3.4 Extern Struct (Wire-Compatible Layout)”
// Fixed memory layout, matches C ABI. Used for SBI, FFI, binary protocols.
extern struct RequestFrame {
version: u8,
msg_type: u8,
_pad: [2]u8 = .{ 0, 0 },
payload_len: u32,
seq: u64,
}

extern struct guarantees field order, alignment, and size match C ABI rules. Use §size_of, §align_of, §offset_of for comptime layout verification.

enum HexError {
InvalidChar,
InvalidLength,
}
// Enum with explicit backing type (planned — currently all enums are i64-backed)
enum ObjectKind {
blob,
tree,
change,
checkpoint,
}
// Non-exhaustive enum (catch-all for unknown values)
enum MsgType {
submit,
ping,
shutdown,
}
error DiffError {
DiffFailed,
InvalidTree,
StorageError,
}

The dedicated error keyword communicates intent: these are failure modes, not data variants.

// A function that can fail returns Error!Success
func readRef(dir: Dir, io: Io, name: []const u8) RefError![32]u8
// The error union type: left is error, right is success
HexError!u8 // returns u8 on success, HexError on failure
![]u8 // inferred error set, returns []u8 on success

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
type Vec2i = vec[i32, 2]
type Vec3f = vec[f32, 3]
type Vec4f = vec[f64, 4]
type Vec4u = vec[u8, 4]

Literal construction:

let v: Vec2i = .{ 10, 20 }
let w: Vec3f = .{ 1.0, 2.0, 3.0 }

Scalar arithmetic. Vectors support element-wise operations with scalars:

let pos: Vec2i = .{ 640, 480 }
let half = pos / 2 // Vec2i { 320, 240 }
let doubled = pos * 2 // Vec2i { 1280, 960 }
let color: Vec3f = .{ 0.5, 0.5, 0.5 }
let bright = color * 2.0 // Vec3f { 1.0, 1.0, 1.0 }
let dim = color / 4.0 // Vec3f { 0.125, 0.125, 0.125 }

Element access. Indexed access via [N]:

let x = v[0]
let y = v[1]

Conversion to struct:

struct Coord {
col: i32,
row: i32,
}
func coord_from_vec2(v: Vec2i) -> Coord do
return Coord {
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)
func cell_coord_from_position(pos: Vec2i) -> Coord do
let cell = pos / GRID_PX_SIZE
return Coord {
col: cell.x, // v[0] as named lane
row: cell.y, // v[1] as named lane
}
end

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:

ProfileVector typesScalar opsNamed lanesSIMD lowering
:corevec[T, N] declarationScalar multiplication/divisionFutureNo
:serviceFull vec typesFull scalar opsFutureNo
:computeFull vec typesFull scalar + vec-vec opsYes (future)simd[T; N] via SPEC-237

Guarantees:

  1. No hidden allocation. Vec2i is 8 bytes on the stack — same as [2]i32.
  2. Value semantics. Assignment copies; no sharing, no ref-counting.
  3. Strict element types. vec[f32, 3]vec[i32, 3] — no implicit cross-type arithmetic.
  4. Static dispatch. All operations resolve at compile time. No runtime vtable.
  5. 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.


// Immutable (preferred)
const x = 42;
const name: []const u8 = "graf";
let result = compute(); // let also works for immutable
// Mutable
var counter: usize = 0;
var buf: [64]u8 = undefined; // uninitialized fixed buffer
const CID_LEN: usize = 32;
const PROTOCOL_VERSION: u8 = 1;
const HEX_CHARS = "0123456789abcdef";
pub const GTP_CAP_SBI: u8 = 0x08;
// Functions use `func` keyword, `do...end` blocks, `-> ReturnType` arrow syntax.
func add(a: i64, b: i64) -> i64 do
return a + b
end
// Function with error return
func decodeNibble(c: u8) -> HexError!u8 do
if c >= '0' and c <= '9' do return c - '0' end
return HexError.InvalidChar
end
// Function with allocator parameter
func diffTrees(
store: *Store,
old_cid: ?[32]u8,
new_cid: [32]u8,
allocator: std.mem.Allocator,
) -> ![]Transition do
// ...
end
// Method (self parameter)
func deinit(self: *GrafRuntime) do
self.scheduler.stop()
end
// Comptime parameter
func encodeFix(comptime N: usize, bytes: *const [N]u8) -> [N * 2]u8 do
// N is known at compile time; array sizes are comptime-determined
end
// Always use `func` (not `fn`).

FFI bindings use the @ metadata sigil (SPEC-050). This is declaration-time linkage intent, not arbitrary inline foreign code execution.

// Zig bridge
@ffi(path: "crypto.zig", lang: .zig)
extern func blake3_hash(data_ptr: i64, data_len: i64) -> i64
// C bridge
@ffi(path: "sqlite3.c", lang: .c)
extern func sqlite3_open(path: *u8, db: **Sqlite3) -> i32

Incorrect (do NOT use):

let x = @ffi("foo.c") // WRONG — @ does not evaluate values

@ introduces declarative attributes. It does NOT evaluate values and does NOT participate in normal expression syntax.

// Test blocks use do...end (imperative logic, Law 2)
test "writeRef + readRef round-trip" do
var tmp = testing.tmpDir(.{})
defer tmp.cleanup()
try writeRef(tmp.dir, testing.io, "main", test_cid)
const read_cid = try readRef(tmp.dir, testing.io, "main")
try testing.expectEqualSlices(u8, &test_cid, &read_cid)
end
test "basic addition" do
assert(1 + 1 == 2)
end

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:

IntentMeaningCaller Retains?Callee Mutates?Aliasing?
viewRead-only borrow (default)YesNoYes
editExclusive mutable borrowYes (frozen)YesNo
takeOwnership transfer (sink)No (consumed)YesN/A
makeUninitialized outputAfter call: YesYes (must initialize)No

Default intent is view. Omitting the qualifier means read-only borrow. This matches the immutability-by-default doctrine.

// view (default) — read-only borrow
func length(buf: [u8]) -> usize do return buf.len end
func length(view buf: [u8]) -> usize do return buf.len end // identical
// edit — exclusive mutable borrow
func write(edit buf: [u8], byte: u8) do
buf[buf.len] = byte
end
// take — ownership transfer (caller binding marked Dead after call)
func close(take f: ~File) do
f.flush()
f.release()
end
// make — uninitialized output; callee MUST fully initialize via bulk-write primitive
func init_zero(make buf: [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:

func inspect(view buf: iso Buffer) do ... end // read-only borrow of unique ref
func mutate(edit data: ref WorkingSet) do ... end // exclusive write to local mutable
func swallow(take buf: iso Buffer) 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.

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.

func use_open(view f: ~File) do ... end // non-consuming borrow of linear resource
func amend(edit f: ~File) do ... end // exclusive mutable borrow
func close(take f: ~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:

@ffi(path: "crypto.zig", lang: .zig)
extern func blake3_hash(data_ptr: *const u8, data_len: usize, out: *[32]u8) -> i32

Within sovereign Janus, intents are the canonical surface; *T is reserved for FFI and :sovereign.

Tensor descriptors with shape sigils (:compute). Tensor and polar parameters accept shape sigils for partial shape genericity:

SigilMeaning
Integer literal (128, 768)Static dimension (compile-time known)
_Dynamic dimension — runtime value, compile-time rank
*Rank-erased — runtime rank (whole-tensor descriptor)
// Static shape (rank + dims known at compile time)
func dot(view a: tensor[f32, 128], view b: tensor[f32, 128]) -> f32 do ... end
// Rank known, dimensions runtime
func relu(edit t: tensor[f32, _, _]) do ... end
// Rank-erased — accepts any-rank f32 tensor
func total(view t: tensor[f32, *]) -> usize do
var n: usize = 1
for k in 0..t.rank do n = n * t.dim(k) end
return n
end
// Polar embedding with dynamic N
func similarity(view a: polar[_], view b: polar[_]) -> f32 do ... 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):

Old (deprecated)New (canonical, SPEC-085)
func f(x: T)func f(x: T) (no change — implicit view default)
func f(x: &T)func f(view x: T)
func f(x: &mut T)func f(edit x: T)
func f<'a>(x: &'a T) -> &'a Tfunc f(view x: T) -> T (lifetime erased; escape analysis verifies)
tensor<T, Dims>tensor[T, Dims...]
tensor<f32, [N, M]>tensor[f32, N, M]

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
overload to_string = {
bool_to_string,
int_to_string,
float_to_string,
}
// Usage — dispatch by argument type
let s1 = to_string(true) // -> bool_to_string
let s2 = to_string(42) // -> int_to_string
let s3 = to_string(3.14) // -> float_to_string
// Specific procedure reference
let f = to_string.int_to_string
let n = f(99)

Rules:

  1. An overload set is a named group of functions that share the same semantic operation.
  2. Dispatch is static, resolved at compile time by argument types. No runtime vtable.
  3. Each member function MUST have a distinct signature — ambiguous overloads are rejected at declaration time (E2710).
  4. Overload sets are first-class values — they can be passed as parameters, stored in structs, and re-exported through sovereign indexes.
  5. 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
pub overload to_string // re-exports the set
// Pass overload set as parameter
func format[T](val: T, fmt: overload) -> str do
return fmt(val)
end
let s = format(42, to_string) // dispatches to to_string.int_to_string

Why explicit overloading beats ambient overload:

PropertyOdin (ambient)Janus (explicit)
Declarationto_string :: proc{bool_to_string, int_to_string}overload to_string = { bool_to_string, int_to_string }
ScopeModule-wide, implicitExplicit set, named members
Ref transparencyMust jump to declaration to see membersMembers listed at declaration site
Specific referenceRequires workaroundto_string.int_to_string direct access
AI readabilityMembers scattered across fileAll overloads visible in one place

This fits Janus’s “Revealed Complexity” doctrine: the cost of overload resolution is explicit in the declaration, not hidden in compiler magic.

Interaction with parameter intents. Overload resolution considers the full signature including intents:

overload process = {
process_view, // func process_view(view x: T) -> U
process_edit, // func process_edit(edit x: T) -> U
process_take, // func process_take(take x: ~T) -> U
}
let v = process(my_val) // my_val matches view -> process_view
let e = process(mut &my_val) // mut & matches edit -> process_edit
let t = process(consume my_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
overload serialize = { serialize_json, serialize_cbor, serialize_sbi }
// Trait: type-class contract, generic dispatch over Self
trait Serializable {
func serialize(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:

effect IO {
func stdin_read(edit buf: [u8]) -> usize
func stdout_write(view bytes: [u8]) -> void
}
effect Random {
func next_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:

// Pure function — no errors, no effects
func add(a: i64, b: i64) -> i64 do
return a + b
end
// Single effect
func read_config(view path: [u8]) -> Config !{IO} do
let bytes = IO.stdin_read(input_buf)
return parse(bytes)
end
// Error union + multiple effects
func fetch_blob(view cid: [32]u8, allocator: std.mem.Allocator)
-> [u8] !FetchError !{IO, Net, Alloc} do
// ...
end
// Explicitly pure — !{} is a contract that introduces effects later trigger E2509
pub func canonical_hash(view bytes: [u8]) -> [32]u8 !{} do
return blake3(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:

let cfg = handle do
read_config("janus.toml")?
end with IO do
func stdin_read(edit buf: [u8]) -> usize do
return std.os.posix.read(0, buf)
end
func stdout_write(view bytes: [u8]) -> void do
discard std.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).

handle do
game_turn("Alice")
end with IO do
func stdout_write(view bytes: [u8]) -> void do log.append(bytes) end
func stdin_read(edit buf: [u8]) -> usize do return 0 end
end with Random do
func next_int(max: i64) -> i64 do return 4 end // deterministic
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:

ProfileAmbient EffectsNotes
:scriptIO, AllocPermissive default — implicit handlers, GC/RC runtime
:core(none)Effect-clean by default. All effects must be handled explicitly.
:serviceIO, Alloc, Net, TimeStandard service effects from runtime
:cluster:service + Send, Recv, SpawnActor-related effects
:compute:core + GPU, NPUHardware-acceleration effects
:sovereignAll-of-above + Boot, MMU, HardwareBare-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
func greet(view name: [u8]) -> void !{IO} do
IO.stdout_write("Hello, ")
IO.stdout_write(name)
end
// run_greeting calls greet — must declare !{IO} or handle it
func run_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
func map[T, U](view xs: [T], f: func(T) -> U !{?E}) -> [U] !{?E} do
var out: [xs.len]U = undef
for i in 0..xs.len do
out[i] = f(xs[i])
end
return out
end
// Pure call site — map's monomorphized row is !{}
let doubled = map(numbers, func(x: i64) -> i64 do return x * 2 end)
// IO call site — map's monomorphized row is !{IO}
let logged = map(numbers, func(x: i64) -> i64 !{IO} do
IO.stdout_write(str(x))
return x
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
func process(view input: [u8]) -> ProcessResult !{IO} do
IO.stdout_write("processing")
return ProcessResult.ok
end
// At the handler site, the body needs CapWrite for actual filesystem write
pub func main() requires CapWrite do
let result = handle do
process(b"data")
end with IO do
func stdout_write(view bytes: [u8]) -> void do
std.os.fs.write_text_sovereign(wpath_cap, "/var/log", bytes)?
end
end
end

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 dofunc f() -> T !{E} do
func f() -> T with E1 + E2 dofunc f() -> T !{E1, E2} do
func f() with E dofunc 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.Xwith 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:

@capability(class: .filesystem, scope: .read_write)
struct FilesystemCap {
_token: u64,
}
@capability(class: .network, scope: .outbound)
struct NetCap {
_token: u64,
}
@capability(class: .cryptographic_random)
struct RandomCap {
_token: u64,
}

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:

@capability(class: .application, scope: .lifecycle)
struct AppCap {
_token: u64,
}
@capability(class: .ui, scope: .main_surface)
struct UiCap {
_token: u64,
}

Companion effects:

effect Ui {
func invalidate(view ui: UiCap) -> void
func render(view ui: UiCap, take tree: ViewTree) -> void
}
effect AppLifecycle {
func quit(view app: AppCap, code: i32) -> void
}

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
let bad = FilesystemCap { _token: 42 }

Intent-only passing. Capability parameters MUST carry SPEC-085 intent qualifiers:

// ✅ view — read-only borrow of capability
func read_file(view fs: FilesystemCap, view path: [u8]) -> [u8] !FsError !{IO} do
return std.fs.read(fs, path)
end
// ❌ E2602: capability parameter without intent qualifier
func bad(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
func leak(view path: [u8]) -> [u8] !{IO} do ... end
// ✅ effect-capability pair satisfied
func good(view fs: FilesystemCap, view path: [u8]) -> [u8] !{IO} do
return std.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:

hinge.kdl
capabilities {
filesystem read="/etc/janus/" read_write="/var/lib/janus/"
network outbound="forge.janus-lang.org:443"
cryptographic_random
}
src/main.jan
pub func main(view fs: FilesystemCap, view net: NetCap, view rng: RandomCap)
-> i32 !{IO, Net} do
// Binary received exactly the capabilities its manifest declared.
let cfg = read_file(fs, "/etc/janus/config.toml")?
let conn = connect(net, "forge.janus-lang.org:443")?
return 0
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:

// Narrow read-write to read-only
let ro_fs = std.os.caps.narrow_fs_to_read(view fs)
// Narrow to specific subdirectory
let scoped_fs = std.os.caps.narrow_fs_to_subdir(view fs, "/var/lib/myapp/")?

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
let fs_for_actor: iso FilesystemCap = consume my_fs_cap
send worker <- fs_for_actor

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:

pub func write_log(view fs: FilesystemCap, view bytes: [u8]) -> !void !{IO} do
pledge { wpath }
// SPEC-091 fs grants user-space resource authority
// SPEC-080 wpath_cap (auto-materialized) grants OS-syscall authority
return std.os.fs.write_text_sovereign(wpath_cap, log_path(fs), bytes)
end

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:

ProfileCapability machinery
:scriptDisabled by ambient table — implicit capabilities for ergonomics
:coreAvailable but not required (foundational types only)
:serviceRequired — all stdlib resource APIs take capability parameters
:cluster:service + actor-bound capabilities (capabilities default to iso)
:compute:core + GPU/NPU capability tokens
:sovereignAll — 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.

Stdlib migration (mechanical, post-ratification):

Pre-SPEC-091Post-SPEC-091
std.fs.read(path)std.fs.read(view fs: FilesystemCap, path)
std.net.connect(addr)std.net.connect(view net: NetCap, addr)
std.crypto.random_bytes(buf)std.crypto.random_bytes(view rng: RandomCap, edit buf)
std.time.now()std.time.now(view tcap: SystemTimeCap)

:script retains ambient handlers backward-compatibly per [CAP:11.1.3]; :service and above mandate explicit passing.

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
type PositiveInt = i64 where self > 0
// Refinement at a parameter
func sqrt(view n: f64 where n >= 0.0) -> f64 do
// Compiler proved n >= 0; no runtime check needed
end
// Cross-parameter binding (refinement of one param refers to another)
func get[T](view arr: [T], view idx: usize where idx < arr.len) -> T do
return arr[idx] // bounds check elided — proved at call site
end
// Refinement on return value (using `result` keyword)
func abs(view n: i64) -> i64 where result >= 0 do
if n < 0 do return -n end
return n
end
// Refinement with §-comptime predicates
type AlignedAddr[A: usize] = usize where §{ self % A == 0 }

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.

Predicate restrictions. Refinement predicates SHALL be:

  • 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:

  1. Strengthen the type — convert the receiving variable to refinement-typed; pushes the obligation upward to the originating call site.
  2. Add an explicit runtime checkif x_satisfies_predicate do call(x) end; conditional narrowing inside the then branch makes the predicate provable.
  3. 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:

func safe_sqrt(view n: f64) -> f64 do
if n >= 0.0 do
return sqrt(n) // n's refinement narrowed to `n >= 0.0` here; SMT proves it
else
return 0.0
end
end

Composition with intents:

func write_at(
edit buf: [u8] where buf.len > 0,
view idx: usize where idx < buf.len,
view byte: u8,
) do
buf[idx] = byte // both bounds proven, both checks elided
end

Composition with capabilities:

type ReadOnlyFs = FilesystemCap where self.scope == .read
func read_audit_log(view fs: ReadOnlyFs, view path: [u8]) -> [u8] !{IO} do ... end

Profile gating — uniform language surface, profile-specific enforcement strictness:

ProfileRefinement enforcement
:scriptAccepted, warnings only (E2900 → W2900) — ergonomic exception
:coreOptional, fully enforced when present
:serviceOptional, fully enforced when present
:clusterOptional, fully enforced when present
:computeRecommended — tensor shape predicates valuable
:sovereignRecommended + @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.

PositionFormRoleLowering
Expression§{ expr } or §builtin(args)Returns a value at compile timeproof-only (erased)
Statementcomptime do ... endPerforms comptime side effects (asserts, codegen, sema checks)proof-only (erased)
Parametercomptime <name>: <type>Marks a parameter as compile-time knownspecialized (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:

comptime do
// Allowed: pure computation, type introspection, §-builtins
let info = §type_info(T)
let size = §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
total func factorial(view n: usize) -> usize do
if n == 0 do return 1 end
return n * factorial(n - 1) // structural recursion on n
end
// Explicit non-totality
partial func event_loop() -> never do
while true do
// ...
end
end
// Default — termination not asserted (backwards compatible)
func parse_loop(reader: *Reader) -> !void do
while true do ... end
end

Three termination proof strategies:

  1. Structural recursion — recursive call has a strictly smaller argument by a well-founded ordering (n - 1, xs.tail(), subterm). Free; no SMT invocation.
  2. Bounded loopsfor i in 0..N do where N is comptime-known or refinement-bounded. Free.
  3. Explicit termination measuretotal 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:

ProfileTotal guarantees
:scriptOptional, warnings only
:coreStdlib hot paths SHALL be total where feasible (std.core.conv, std.core.mem, std.math primitives)
:serviceOptional but recommended for SLA-critical paths
:clusterRequired for any @realtime-tagged actor (per SPEC-021)
:computeTensor primitives SHALL be total (shape-bounded loops always terminate)
:sovereignCryptographic 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.

4.12 Row Polymorphism / Shape Types (SPEC-095)

Section titled “4.12 Row Polymorphism / Shape Types (SPEC-095)”

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
func get_name[r](view rec: shape { name: string, ..r }) -> string do
return rec.name
end
// Different concrete records all satisfy
struct User { name: string, email: string, age: i32 }
struct Account { name: string, balance: f64 }
let n1 = get_name(User { name: "alice", email: "[email protected]", age: 30 })
let n2 = get_name(Account { name: "savings", balance: 1000.0 })
// 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:

struct ConfigV1 { host: string, port: u16 }
struct ConfigV2 { host: string, port: u16, tls: bool } // new field
func describe[r](view cfg: shape { host: string, port: u16, ..r }) -> string do
return §"Config: {cfg.host}:{cfg.port}"
end
let s1 = describe(ConfigV1 { host: "a", port: 80 }) // ✅
let s2 = describe(ConfigV2 { host: "b", port: 443, tls: true }) // ✅ extra field passes through

Composition rules:

  • Intents (SPEC-085): view/edit/take work normally; make is forbidden on shape types (E2964) — incomplete layout cannot be initialized.
  • Effects (SPEC-090): orthogonal; shapes may carry any effect row.
  • Refinements (SPEC-092): may reference shape’s listed fields (where idx < rec.items.len); cannot reference row-variable fields (E2965).
  • Totality (SPEC-094): total shape-polymorphic functions verify per monomorphization.
  • Capabilities (SPEC-091): capability types SHALL NOT appear inside shape types (E2966) — preserves SPEC-091’s construction discipline.

Comptime row introspection:

func describe_extra[r](view rec: shape { id: u64, ..r }) -> string do
var s = §"id={rec.id}"
inline for §row_fields(rec) |field| do
let value = §field(rec, field.name)
s = s ++ §" {field.name}={value}"
end
return s
end

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.

ProfileShape types
:script❌ Forbidden (E2960)
:core❌ Forbidden (E2960)
:service✅ Available
:cluster✅ Available
:compute✅ Available
:sovereign✅ Available

// No parentheses around condition. Uses do...end.
if count > 0 do
process()
else
skip()
end
// Single-line (statement separators are optional inside do-blocks;
// neither `;` nor a newline is required before `end`)
if c >= '0' and c <= '9' do return c - '0' end
// Optional unwrap (RFC-019 v0.1 — pattern-binding `with`)
// The legacy `if optional do |val| ... end` form was removed pre-1.0
// as a Syntactic Honesty violation; `|val|` everywhere else in Janus
// means closure parameter, not pattern-bind. Use `with .Some(v) <- expr`:
with .Some(otc) <- old_tree_cid do
old_tree = store.getTree(otc) catch return DiffError.DiffFailed;
end
var i: usize = 0;
while i < N do
out[i] = compute(i);
i = i + 1;
end
// Iterate with capture
for entries do |entry|
process(entry.name, entry.cid);
end
// Range iteration
for 0..N do |i|
buf[i] = 0;
end
// Exclusive range
for 0..<10 do |i|
// i = 0, 1, ..., 9
end
// Exhaustive pattern matching (uses { } not do...end)
match kind {
.blob => decode_blob(data),
.tree => decode_tree(data),
.checkpoint => decode_checkpoint(data),
else => return error.UnknownKind,
}
// With guards
match request {
.Create(data) when data.is_valid => process(data),
.Delete(id) when id > 0 => delete(id),
else => reject("invalid"),
}

Doctrine: else, _, and discard — one symbol, one meaning

SymbolLevelPurposeValid context
elseExpressionMatch fallback (catch-all)match x { else => ... }
_PatternDiscard bindinglet _ = expr, catch |_|, (_, 0, _) in destructure
discardStatementDiscard expression resultdiscard 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.

const value = blk: {
if condition do break :blk 42; end
break :blk 0;
};

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.

// try: propagate error to caller
const data = try store.read(cid);
const hi = try decodeNibble(hex[0]);
// ? suffix: same as try (alternative syntax)
const result = fallible()?
// catch with fallback value
const cid = fromHex(content) catch return RefError.InvalidRef;
// catch with error capture
const sched = Scheduler.init(alloc, 4) catch |err| do
log.error("init failed: {}", .{err});
return error.RuntimeInitFailed;
end;
// catch unreachable (assert success)
var rt = GrafRuntime.init(alloc, 2) catch unreachable;
// Return an error
return HexError.InvalidChar;
return error.DiffFailed;
// fail keyword (alternative)
fail DivisionError.DivisionByZero

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
discard some_function()
// Discard with error handling
discard may_fail() catch |err| do
log_error(err)
end
// Common use case: logging
discard logger.write("message")
// Multiple discards in sequence
discard posix.close(fd)
discard allocator.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.”

// defer: runs on scope exit (success or error)
defer allocator.free(matched);
defer rt.deinit();
// errdefer: runs ONLY on error exit
var out: std.ArrayListUnmanaged(u8) = .empty;
errdefer out.deinit(allocator);
try encodeValue(allocator, &out, value);
return out.toOwnedSlice(allocator);
// Conditional defer
defer if owns_old do freeTree(allocator, old_tree); end

Type parameters in square brackets. Always explicit at call site (no type inference).

// Definition
func identity[T](x: T) -> T do
return x
end
// Call site (type always explicit)
let n = identity[i64](42)
let s = identity[[]const u8]("hello")
func min[T: Ord](a: T, b: T) -> T do
if a <= b do return a end
return b
end
// Multiple bounds
func clamp[T: Ord + Numeric](value: T, lo: T, hi: T) -> T do
if value < lo do return lo end
if value > hi do return hi end
return value
end
trait Eq {
func eq(self, other: Self) -> bool
func ne(self, other: Self) -> bool
}
trait Ord: Eq { // Ord implies Eq (supertrait)
func lt(self, other: Self) -> bool
func le(self, other: Self) -> bool
func gt(self, other: Self) -> bool
func ge(self, other: Self) -> bool
}
trait Numeric: Ord + Eq { // Numeric implies Ord and Eq
func add(self, other: Self) -> Self
func sub(self, other: Self) -> Self
func mul(self, other: Self) -> Self
func div(self, other: Self) -> Self
}
trait Float: Numeric { // Float implies Numeric
func sqrt(self) -> Self
func sin(self) -> Self
func cos(self) -> Self
}
trait Integer: Numeric { // Integer implies Numeric
// Marker trait — no methods
}
trait SignedInteger: Integer {} // i8, i16, i32, i64
trait UnsignedInteger: Integer {} // u8, u16, u32, u64
trait Enum {} // All enum types, provides E.Underlying

All integer and float primitives satisfy all applicable traits intrinsically. bool satisfies Eq only.

7.4 Binary Relation Traits (SPEC-042, compile-time only)

Section titled “7.4 Binary Relation Traits (SPEC-042, compile-time only)”

These are comptime-only traits — no methods, no runtime cost. The compiler derives them from type metadata.

trait LosslesslyConvertible[S, T] {} // widen[T](x: S) bound
trait LayoutCompatible[S, T] {} // bitcast[T](x: S) bound
trait SameWidth[S, T] {} // signed/unsigned bound
trait Narrowing[S, T] {} // clampTo SIMD bound

Users cannot impl these traits. They are compiler-derived facts about the type system.

Each unique (function, type_args) pair compiles to a separate specialized function:

min[i32](3, 5) // emits: min_i32
min[f64](3.14, 2.7) // emits: min_f64

Trait bounds are verified once at definition, not per instantiation.


Compile-time evaluation. Code runs in the compiler, vanishes before the binary exists.

The §{ } syntax is the canonical form for compile-time expression evaluation:

// Expression form: single comptime value
const SIZE = §{ calculate_optimal_size(PAGE_SIZE) }

The comptime do ... end keyword form is accepted as an alias and desugars to §:

// Module scope: evaluated once during compilation
comptime do
assert(PROTOCOL_VERSION >= 1, "requires protocol v1+")
end
func hex_encode(comptime N: usize, input: [N]u8) -> [N * 2]u8 do
var output: [N * 2]u8 = undefined
inline for 0..N |i| do
output[i * 2] = charset[input[i] >> 4]
output[i * 2 + 1] = charset[input[i] & 0x0F]
end
return output
end
// comptime T: type gives full introspective access
func decode(comptime T: type, bytes: []u8) -> ?T do
const info = §type_info(T) // requires :service
// ...
end

:core+ builtins (available everywhere):

BuiltinInputOutputPurpose
§size_of(T)typeusizeByte size of type
§align_of(T)typeusizeAlignment requirement
§offset_of(T, field)type, stringusizeField byte offset in struct
§is_integral(T)typeboolTrue for integer types
§is_float(T)typeboolTrue for float types
§fmt(args...)variadicstringComptime string construction
§compile_error(msg)stringneverHalt compilation with message

:service+ builtins (type introspection):

BuiltinInputOutputPurpose
§type_info(T)typeTypeInfoFull type metadata
§type_name(T)typestringFully qualified name
§type_id(T)typeu64Stable hash identifier
§fields(T)type[]FieldInfoStruct field list
§has_field(T, name)type, stringboolField existence check
§has_decl(T, name)type, stringboolDeclaration existence
§field(val, name)any, stringfield typeAccess field by name
§field_type(T, name)type, stringtypeType of named field
// inline for: unrolls at compile time
inline for §fields(T) |field| do
const field_value = §field(value, field.name)
process_field(field_value)
end
// inline switch: eliminates dead branches
inline switch §type_info(T) do
.Struct => |s| encode_struct(value, s)
.Int => |i| encode_int(value, i)
else => comptime do §compile_error("unsupported") end
end
// if comptime: conditional compilation
if comptime §is_integral(T) do
optimized_int_path()
else
generic_path()
end
extern struct ResponseFrame do
seq: u64
status: u8
has_tip: u8
_pad: [2]u8
detail_len: u32
final_tip: [32]u8
end
comptime do
assertsize_of(ResponseFrame) == 48, "frame must be 48 bytes")
assertoffset_of(ResponseFrame, "seq") == 0, "seq at offset 0")
assertoffset_of(ResponseFrame, "status") == 8, "status at offset 8")
assertoffset_of(ResponseFrame, "detail_len") == 12, "detail_len at offset 12")
assertoffset_of(ResponseFrame, "final_tip") == 16, "final_tip at offset 16")
end

All conversions are explicit. Janus never silently coerces between types.

Every conversion belongs to exactly ONE of three categories:

CategoryFunctionReturnCost
Totalwiden[T]TFree
Fallibleto[T]!TRange check
Lossytruncate[T]TFree, info loss
func widen[T](value: auto) -> T where LosslesslyConvertible[auto, T]

Source provably fits in target. Zero-cost, compiler-checked.

let n: i32 = 42
let f: f64 = widen[f64](n) // Always safe, no runtime cost
func to[T](value: auto) -> !T

Returns error if value doesn’t fit in T.

let wide: i64 = 42
let narrow = to[u8](wide)? // Ok(42)
let fail = to[u8](256) // Err(Overflow)
let neg = to[u8](-1) // Err(Overflow)
func truncate[T](value: auto) -> T

Discards high bits. Always succeeds.

let byte = truncate[u8](0x1FF) // 0xFF (low 8 bits)
let zero = truncate[u8](256) // 0x00
func round[T: Integer](x: f64, mode: RoundMode) -> !T

Round a float to integer with specified mode. RoundMode variants: .Nearest, .TowardZero, .TowardPosInf, .TowardNegInf.

let n: !i32 = round[i32](3.7, .Nearest) // Ok(4)
let m: !i32 = round[i32](3.7, .TowardZero) // Ok(3)
func bitcast[T](value: auto) -> T where LayoutCompatible[auto, T]

Same bits, different type. Source and target must have identical size and alignment.

let f = bitcast[f32](0x42280000) // 42.0
func signed[T](value: auto) -> T where SameWidth[auto, T], auto: UnsignedInteger
func unsigned[T](value: auto) -> T where SameWidth[auto, T], auto: SignedInteger

Flip sign interpretation without changing bits.

let u: u32 = 0xFFFFFFFF
let i = signed[i32](u) // -1
let j: i32 = -1
let v = unsigned[u32](j) // 0xFFFFFFFF
// Integer to enum (checked)
func toEnum[T](value: auto.Underlying) -> !T
let color = toEnum[Color](1)? // Color.Green
let bad = toEnum[Color](99) // Err(Overflow)
// Enum to integer (zero-cost)
func fromEnum(value: auto) -> auto.Underlying
let n = fromEnum(Color.Green) // 1 (type is u8 if Color: u8)

fromEnum returns the enum’s declared backing type, not i64. No generic parameter needed.

func fromBool(value: bool) -> u8 // 0 or 1
func toBool(value: auto) -> bool // non-zero is true
func parse[T](input: str) -> !T
func parseConsume[T](edit input: str) -> !T

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.

let n: !i32 = parse[i32]("42") // Ok(42)
let bad: !i32 = parse[i32]("42 ") // Err(TrailingInput)

9.11 Value Formatting (Zero-Allocation, Atomic)

Section titled “9.11 Value Formatting (Zero-Allocation, Atomic)”
func format[T](value: T, edit buf: [u8]) -> !usize
func formatLen[T](value: T) -> usize

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.

var buf: [32]u8
let written: !usize = format[i32](42, buf[..]) // Ok(2), buf[0..2] == "42" — edit intent inferred at call
let len = formatLen[i32](42) // 2
error ConvError {
Overflow,
Underflow,
NegativeUnsigned,
InvalidDigit,
InvalidEnum,
EmptyInput,
TrailingInput,
BufferTooSmall { needed: usize },
}

10. Memory Operations (std.core.mem_generic)

Section titled “10. Memory Operations (std.core.mem_generic)”
// Slice copy (non-overlapping, equal length)
func copy[T](dst: []T, src: []T) !i64
// Slice fill
func set[T](dst: []T, value: T)
// Pointer casts (unsafe)
func ptrCast[T](ptr: ptr) -> T
func constCast[T](ptr: ptr) -> T
func alignCast[T](ptr: ptr) !T // checked alignment

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
use identity.types
// Access symbols through the namespace
let did = types.parse_did("did:key:z6Mk...")
let err = types.ResolveError.NotFound
// Import from same directory
use resolver
use envelope
// Import from subdirectory
use method.key
use vc.normalize
// Import from Janus stdlib
use std.encoding.cbor
use std.crypto.mldsa65

Resolution algorithm (pipeline Stage 2.5):

  1. Join path components with /, append .jan → e.g., identity/types.jan
  2. Resolve relative to source file’s directory
  3. Fallback: resolve relative to CWD
  4. Future (SPEC-041): resolve by CID via content-addressed registry

When bridging to Zig modules (stdlib or local), use the use zig graft syntax. This is the ONLY context where file paths appear in import statements.

// Import Zig stdlib module (zero FFI overhead — Janus compiles through Zig)
use zig "std/hash/blake3.zig"
use zig "std/service/ns_msg/transport.zig"
// Import a local Zig bridge file
use zig "bridge/net_bridge.zig"

Think of use zig as a filesystem bridge resolver:

  1. use zig "path" is resolved by Janus using four 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
    • cwd fallback as the final fallback
  2. The resolved absolute file is parsed into the extern registry before codegen.

Concrete example (assuming JANUS_RUNTIME_DIR=/repo/janus/runtime):

// Janus source
use zig "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
  1. 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)
const client = @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.

The Five-Form Taxonomy:

FormSyntaxPurpose
use januse std.crypto.blake3Native Janus modules, content-addressed
use ziguse zig "std/hash/blake3.zig"Zig module bridge, stdlib/bootstrap
graft cgraft ip = c "ip-stack.h" link "ip-stack"C header + linked library
graft rustgraft blake3 = rust "crates.io:[email protected]"Cargo crate through C-ABI wrapper
graft vendorgraft rl = vendor "graphics:[email protected]"Curated Janus-blessed foreign package bundle
graft oqs = c "oqs/oqs.h" link "oqs"
graft argon2 = c "argon2.h" link "argon2"
graft ip = c "ip-stack.h" link "ip-stack"
// Use via alias
let sig = oqs.OQS_SIG_ml_dsa_65_sign(...)
let hash = argon2.argon2id_hash(...)

Rust crates are compiled through C-ABI exports. Cargo builds a static library; Janus generates bindings.

graft blake3 = rust "crates.io:[email protected]"
graft rg = rust "crates.io:[email protected]"
graft ring = rust "crates.io:[email protected]"
let hash = blake3.hash(data)

The Rust graft spec already establishes: C-ABI exports, Cargo static library build, Janus-side binding generation. Unknown functions default to :sovereign profile — foreign code is a loaded gun in a clown mask.

11.3.3 Vendor Graft — Curated Foreign Batteries

Section titled “11.3.3 Vendor Graft — Curated Foreign Batteries”

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"
graft rl = vendor "graphics:[email protected]"
graft sdl = vendor "graphics:[email protected]"
graft lua = vendor "script:[email protected]"
graft curl = vendor "net:curl@8"
graft zlib = vendor "compress:[email protected]"
// Use via alias
rl.init_window(1280, 720, "Janus")
sdl.poll_event(...)
lua.dostring("print('hello')")

Vendor Capsule Manifest (KDL):

Each vendor graft resolves against a curated capsule definition:

vendor "graphics:raylib" {
version "5.5"
source "upstream:raylib"
license "Zlib"
abi "c"
headers ["raylib.h"]
libs ["raylib"]
profile "service"
capabilities ["graft.c", "gpu", "filesystem.read"]
sha256 "b3f8c1d9a2e4..."
wrapper_cid "bafkreia7q..."
}

The manifest provides:

  • version — pinned version, never floating
  • source — upstream origin for rebuild verification
  • abi — C, C++, or raw symbol export
  • headers/libs — compilation units
  • profile — minimum required profile (:script, :service, :sovereign)
  • capabilities — required capability tokens (checked at link time)
  • sha256 — content integrity pin
  • wrapper_cid — content-addressed Janus wrapper module

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.

Resolution chain (in order):

  1. pkg-config lookuppkg-config --libs raylib returns -lraylib -lm
  2. System library paths/usr/lib/libraylib.so, /usr/local/lib/libraylib.a
  3. Platform-specific/opt/homebrew/lib/libraylib.dylib (macOS), /usr/lib/x86_64-linux-gnu/libraylib.so (Debian multiarch)
  4. 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.

$ janus build --vendor-path=/usr/local/lib app.jan
# Resolves graft rl = vendor "graphics:[email protected]"
# → /usr/local/lib/libraylib.so.5.5.0
# → sha256 matches manifest → ACCEPT

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
janus build --vendor-fetch=allow app.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:

graft rl = vendor "graphics:[email protected]"
func main(view gfx: GpuCap) -> i32 !{IO} do
rl.init_window(1280, 720, "Janus")
// ... or equivalently:
// graft.rl.init_window(1280, 720, "Janus")
end

Vendor vs Package — The Trust Boundary:

graft rl = vendor "graphics:[email protected]" // curated, reviewed, stable
graft foo = package "hinge:somebody/[email protected]" // external Hinge package, signed/CID-pinned, less trusted
Propertyvendorpackage
CurationJanus-reviewedCommunity-submitted
WrapperChecked-in, maintainedAuto-generated
StabilityStable, versioned APIBest-effort
CapabilitiesManifest-declared, verifiedSelf-declared
Use caseGraphics, game dev, crypto, compressionEcosystem 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

Section titled “11.3.4 Odin Theft — What We Steal and What We Don’t”

Steal:

  1. Vendor batteries for graphics/game/app development
  2. Explicit overload sets (§4.6.1)
  3. Data-oriented ergonomics (vec types, §3.9)
  4. Zero-drama C interop
  5. Fast “first 30 minutes” experience

Do NOT steal blindly:

  1. using as silent field promotion everywhere
  2. Ambient vendor trust (npm with a nicer coffin)
  3. Public-by-default package culture
  4. “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.

Steal Odin’s dopamine. Keep Janus’s teeth.

Sovereign Index files re-export sub-module symbols for consumers:

// identity.jan — Sovereign Index
pub use identity.types
pub use identity.resolver
pub use identity.registry
// With alias (for name conflicts)
pub use gateway.jsonrpc_handler as jsonrpc
pub use gateway.sbi_handler as sbi
// ❌ WRONG — @import is Zig plumbing, not Janus syntax
const types = @import("../identity/types.jan");
// ✅ CORRECT — use is Janus-native module loading
use identity.types
// ❌ WRONG — const assignment from use
const types = use identity.types;
// ✅ CORRECT — use binds the namespace directly
use identity.types
// Access as: types.DID, types.parse_did(), etc.

Zero-encoding binary serialization. Wire bytes = memory bytes on same architecture.

use std.sbi
// Encode
var buf: [§size_of(Sensor) + 24]u8 = undefined
const frame = try sbi.encode(Sensor, &reading, &buf)
// Decode (zero-copy, same arch)
const decoded = try sbi.decode(Sensor, &buf)
// Decode (copy, any alignment)
const value = try sbi.decodeCopy(Sensor, &buf)
// Decode (cross-architecture, in-place endian fixup)
const fixed = try sbi.decodeMut(Sensor, &mutable_buf)
// Schema fingerprint (comptime)
const fp = §{ sbi.schemaFingerprint(Sensor) }
// Encoded frame size (comptime)
const size = sbi.encodedSize(Sensor) // 24 (preamble) + §size_of(T)
// Layout analysis (comptime)
const layout = §{ sbi.fieldLayout(Sensor) }
const padding = §{ sbi.totalPadding(Sensor) }
[0..4) Magic: "SBI\x00"
[4] Version: 0x01
[5] ArchFlags (endianness, pointer width)
[6..8) CapFlags (pure, deterministic)
[8..24) Schema fingerprint (16-byte truncated BLAKE3)
[24..) Raw extern struct bytes

13. Structured Concurrency (:service Profile)

Section titled “13. Structured Concurrency (:service Profile)”
pub const Scheduler = std.Scheduler;
pub const Nursery = std.Nursery;
pub const Budget = std.Budget;
pub const SpawnOpts = std.SpawnOpts;
// Initialize runtime
var rt = GrafRuntime.init(allocator, worker_count) catch unreachable;
defer rt.deinit();
// Create nursery (structured scope)
var nursery = rt.createNursery(Budget.serviceDefault());
defer nursery.deinit();
// Spawn tasks (all must complete before nursery exits)
discard nursery.spawn(task_fn, task_arg)
discard nursery.spawn(other_fn, other_arg)
ProfileDefault Stack
:core64 KB
:service256 KB
:sovereign512 KB

Override via SpawnOpts.stack_size.

Nurseries support cooperative cancellation via CancelToken. Cancellation propagates transitively through nested nurseries.

13.4 std.sync: Parker and Mutex[T] (SPEC-057-ter)

Section titled “13.4 std.sync: Parker and Mutex[T] (SPEC-057-ter)”

std.sync provides the v0.1 blocking substrate for :service: a permit-model parking primitive and a generic futex-style mutex.

use std.sync.parker as parker
use std.sync.mutex as mtx

Parker has the same lost-wakeup-resistant contract as Java LockSupport, Rust parking_lot, and the Go runtime parking layer:

  • parker_unpark(&p) deposits one permit. Depositing while a permit already exists is a no-op.
  • parker_park(&p) consumes an existing permit and returns immediately, or blocks until one is deposited.
  • parker_park_timeout(&p, ns) consumes a permit before the deadline or returns ParkResult.TimedOut.
pub enum ParkResult { Unparked, TimedOut }
pub struct Parker { ... }
pub func parker_new() -> Parker
pub func parker_park(edit self: *Parker) -> void
pub func parker_park_timeout(edit self: *Parker, ns: u64) -> ParkResult
pub func parker_unpark(edit self: *Parker) -> void

Target status: v0.1 dispatches to std.sync.parker_linux only. -Dsync-target=nexusos is rejected by build.zig until the NexusOS backend lands.

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.

pub enum LockError { WouldBlock, Cancelled }
pub struct Mutex[T] { ... }
pub struct MutexGuard[T] { ... }
pub enum LockResult[T] {
Ok(MutexGuard[T]),
Err(LockError),
}
pub func mutex_new[T](initial: T) -> Mutex[T]
pub func mutex_lock[T](edit self: *Mutex[T]) -> MutexGuard[T]
pub func mutex_try_lock[T](edit self: *Mutex[T]) -> LockResult[T]
pub func mutex_release[T](edit guard: *MutexGuard[T]) -> void
pub func guard_value_of[T](edit guard: *MutexGuard[T]) -> *T
use std.sync.mutex as mtx
func bump() -> u32 do
var mu = mtx.mutex_new[u32](41)
var guard = mtx.mutex_lock(&mu)
let value = mtx.guard_value_of(&guard)
value.* = value.* + 1
mtx.mutex_release(&guard)
return 42
end

Caller contracts for v0.1:

  • 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.


Nearly every allocation-using function takes an explicit allocator:

func encode(allocator: std.mem.Allocator, value: anytype) ![]u8 do
var out: std.ArrayListUnmanaged(u8) = .empty;
errdefer out.deinit(allocator);
try encodeValue(allocator, &out, value);
return out.toOwnedSlice(allocator);
end

For small collections, ArrayList with linear scan replaces HashMap:

var list = std.ArrayListUnmanaged(Entry).empty;
defer list.deinit(allocator);
try list.append(allocator, entry);
// Search
for list.items do |item|
if std.mem.eql(u8, item.name, target) do return item; end
end
test "round-trip" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
// ... use tmp.dir for file operations
}

Per Haiku Block Discipline (§2, Law 2a): Struct literals MUST begin with a type path. There are no anonymous struct literals in Janus.

// Valid — type head required:
let rt = GrafRuntime { scheduler: sched, allocator: allocator }
let frame = RequestFrame {
version: PROTOCOL_VERSION,
msg_type: MsgType.submit,
payload_len: 1024,
seq: 42,
}
// Dotted type paths are valid:
let hash = std.crypto.Hash { algorithm: .blake3 }
// Explicit leniency — accept defaults for unlisted fields
let cfg = Config { host: "prod.example.com", ..defaults }
// ILLEGAL — no anonymous struct literals:
// let x = { field: value }
// 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.

struct Server { host: str = "localhost", port: u16 = 8080, tls: bool = false }
let s1 = Server { host: "prod", port: 443, tls: true } // all fields — OK
let s2 = Server { host: "prod", ..defaults } // explicit leniency — OK
let s3 = Server { host: "prod", port: 443 } // ERROR: missing 'tls'
// Unwrap with RFC-019 pattern-binding `with`
with .Some(val) <- optional_value do
use(val);
end
// Null coalescing
const result = maybe_value ?? default_value;
// Optional chaining
const name = user?.profile?.name;
// catch null (error to optional)
const val = fallible() catch null;

+, -, *, /, %

==, !=, <, <=, >, >=

and, or, not (NOT &&, ||, !)

& (AND), | (OR), ^ (XOR), ~ (NOT), << (left shift), >> (right shift)

OperatorPurpose
?Error propagation (suffix)
??Null coalescing
?.Optional chaining
|>Pipeline
..Inclusive range
..<Exclusive range

ContextConventionExample
Modules, Structs, EnumsPascalCaseTreeEntry, ObjectKind
Variables, Functionssnake_caseread_ref, payload_len
ConstantsUPPER_SNAKE_CASECID_LEN, MAX_OBJECTS
Local immutable valuessnake_caseuser_count, entry_count
Type parametersSingle uppercaseT, N
Comptime builtins§snake_case§size_of, §type_info
Private fields/padding_prefix_pad, _pad2
Capability tokens (SPEC-080 §9.6.4)<promise>Cap (PascalCase type) / <promise>_cap (snake_case binding)WpathCap / wpath_cap, InetCap / inet_cap
Sovereign facade variants (SPEC-080 §9.5)<base_fn>_sovereign accepting matching cap as first argwrite_text / write_text_sovereign(WpathCap, ...), exec_sovereign(ExecCap, ...)

Functions, variables, control flow, structs, enums, error handling, pattern matching, generics with trait bounds, comptime blocks/params, §size_of/§align_of/§offset_of/§is_integral/§is_float/§fmt/§compile_error, use zig, extern functions, SBI (extern struct encode/decode), tests.

All of :core plus: §type_info, §type_name, §type_id, §fields, §has_field, §has_decl, §field, §field_type, nurseries, fibers, channels, spawn, M:N scheduler, structured concurrency, cancellation tokens, budgets, and std.sync Parker / Mutex[T].

All of :service plus:

Actors and Grains:

  • actor Name(msg: T) do ... end — node-pinned actor with typed mailbox
  • grain Name(id: Id, msg: T) do ... end — virtual identity with owned state; activation is actor-shaped
  • message Name { ... } — sealed algebraic message protocol
  • spawn Name(args) with { ... } — spawn with options
  • spawn Name(args) on { ... } — cluster-aware placement
  • send ref, msg — send message to actor/grain
  • receive do ... end — blocking message receive with pattern matching
  • receive ... after N => ... — receive with timeout

Supervision:

  • supervisor Name, strategy: .one_for_one do ... end — restart strategy
  • child Type, args: [...], restart: .permanent — supervised child

Memory Sovereignty:

  • alloc[Local.Exclusive](value) — owned memory, serialized on migrate
  • alloc[Session.Replicated](value) — async-replicated to cluster peers
  • alloc[Session.Consistent](value) — sync-replicated via consensus
  • alloc[Volatile.Ephemeral](value) — dropped on migrate, reconstructed

Capability Gating:

  • requires CapX, CapY — function requires capabilities from caller (between return type and do)
  • @requires(cap: [...]) — grain declares required hardware/software capabilities
  • @mailbox(capacity: N, overflow: .drop_oldest) — mailbox policy
  • @replicate(scope: .wing) — replication scope annotation
  • @persist(via: GrainStoreBytes) — bind grain-owned state to the v1 local persistence substrate
  • @lifecycle(activation: .lazy, deactivation: .idle_timeout(ms)) — declare activation/passivation policy

Syscall-Surface Pledging (see §19 / SPEC-080):

  • pledge { stdio, rpath, inet } — block-form pledge declares OS syscall surface
  • pledge { ... } on a grain — committed pledge (OS-enforced at spawn, placement-constraining)
  • pledge { ... } on a transition — effective pledge (narrower; per-transition compile-time proof)
  • unveil { path : { .read } } — filesystem whitelist; migratable grains use cap::... handles
  • Sovereign stdlib facades: func_name_sovereign(cap, ...) — typed-capability-gated variants

Cluster (runtime – see NexusOS SPEC-030–034 for implementation):

  • Cluster.join(config) — join cluster mesh (NexusOS runtime)
  • cluster.advertise(manifest) — broadcast node capabilities (NexusOS runtime)
  • cluster.migrate(ref, target, strategy) — migrate grain (NexusOS runtime)
  • cluster.locate[T](name) — location-transparent grain lookup (NexusOS runtime)

:compute (GPU/NPU, tensors), :sovereign (raw pointers, unsafe, comptime allocation), :script (REPL, JIT).


18. :cluster Profile — Actors, Grains, and Distribution (Draft)

Section titled “18. :cluster Profile — Actors, Grains, and Distribution (Draft)”

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.

Every actor and grain declares its messages as a sealed algebraic type:

message StorageMsg {
Read { sector: u64, len: usize, reply: Reply[ReadResult] },
Write { sector: u64, data: []const u8, reply: Reply[WriteResult] },
Flush { reply: Reply[FlushResult] },
Eject,
}

Desugars to enum + compile-time Serialize check.

Synchronous request-response without blocking the actor:

let reply = Reply[ReadResult].new()
storage_ref.send(StorageMsg.Read { sector: 0, len: 512, reply: reply })
let result = reply.await(timeout: 5000)

Desugars to oneshot channel + send + recv.

Node-pinned, never migrates. Use for boot-critical singletons or non-serializable state.

message BootMsg {
MountRoot { device: []const u8 },
StartInit { runlevel: u8 },
}
actor BootLoader(msg: BootMsg) do
var state: BootState = BootState.pre_init
receive do
BootMsg.MountRoot { device } => do
state = try mount_rootfs(device)
end
BootMsg.StartInit { runlevel } => do
state = try start_init_sequence(runlevel)
end
end
end

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.

@persist(via: GrainStoreBytes)
@lifecycle(activation: .lazy, deactivation: .idle_timeout(300_000))
@requires(cap: [.network])
grain StorageService(id: StorageId, msg: StorageMsg) do
var state: StorageState = StorageState.empty()
var index = alloc[Session.Replicated](StorageIndex.new())
var write_cache = alloc[Volatile.Ephemeral](WriteCache.new(capacity: 4096))
on_activate(stored: StorageState) -> StorageState do
return stored
end
on_deactivate(state: StorageState) -> StorageState do
return state
end
receive do
StorageMsg.Read { sector, len, reply } => do
let data = try read_sectors(index, sector, len)
reply.send(ReadResult.Ok { data })
end
StorageMsg.Flush { reply } => do
let result = try write_cache.commit(index)
reply.send(result)
end
end
reconstruct() do
write_cache = WriteCache.new(capacity: 4096)
end
end

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.

18.6 Capability-Gated Functions (requires)

Section titled “18.6 Capability-Gated Functions (requires)”

Functions that demand capabilities use requires clause — same position as where:

func read_config(ctx: *Context) !Config
requires CapFsRead
do
return ctx.fs.read("config.toml")
end
func sync_backup(ctx: *Context) !void
requires CapFsRead, CapFsWrite, CapNetHttp
do
// ...
end

Conjunction semantics: requires CapFsRead, CapFsWrite means caller must grant both.

supervisor DeviceTree, strategy: .one_for_one,
max_restarts: 5, max_seconds: 10 do
child NetworkDriver, args: [net_config], restart: .permanent
child StorageService, args: [disk_config], restart: .permanent
child DisplayDriver, args: [gpu_config], restart: .transient
child TempLogger, args: [], restart: .temporary
end

Restart strategies: .one_for_one, .one_for_all, .rest_for_one Restart policies: .permanent (always), .transient (abnormal only), .temporary (never)

Lightweight interned strings for tags and status codes:

:ok, :error, :timeout, :normal, :shutdown

Desugars to Symbol.intern("string") — GC-managed, safe unlike BEAM atoms.

TagMigrationReplication
Local.ExclusiveSerialized + movedNever
Session.ReplicatedAsync to peersContinuous
Session.ConsistentSync via consensusRaft/PBFT
Volatile.EphemeralDroppedNever
:cluster SurfaceDesugars to
message Foo { ... }enum Foo { ... } + Serialize check
actor Name(msg: T) do ... endMessage loop + typed mailbox
grain Name(id: Id, msg: T) do ... endVirtual identity + actor activation + owned-state + Serialize
@persist(via: Store)Grain-owned state binding to runtime persistence substrate
@lifecycle(...)Activation/passivation policy metadata plus hook validation
func f() !T requires CapX do ... endCapability gate on call graph
supervisor Name ... endsupervisors.start_link(ctx, Spec)
receive do ... endactors.receive().match(...)
Reply[T].new()channel.oneshot[T]()
alloc[Tag](v)actors.alloc(v, ReplicationPolicy { tag: ... })
:symbolSymbol.intern("symbol")
pledge { P } on grainCommitted-pledge metadata + placement constraint + virtual-pledge gate (§19.4)
pledge { P } on transitionEffective-pledge verification; effect set ⊆ P proven at compile time
unveil { path : { perms } }Backend-dispatched Landlock / OpenBSD unveil / NexusOS VFS narrowing
send ref, msg under pledgeTyped-mailbox admission check: message handler pledge ⊆ target committed pledge

19. Pledge & Unveil — OS-Surface Promises (SPEC-080)

Section titled “19. Pledge & Unveil — OS-Surface Promises (SPEC-080)”

“A pledge is not a wish. It is a contract the compiler enforces before the binary exists, and the kernel enforces after it runs.”

Normative source: SPEC-080 v0.1.2. This section is a quick reference; when in doubt, read the spec.

Janus overloads the pledge keyword along two orthogonal axes, disambiguated by the token immediately after pledge:

FormGrammar triggerEnforcementPurpose
pledge <ident>[(args)]identifierCompile-time onlyInternal behaviour: pledge no_alloc, pledge stack(4096), pledge wcet(10_000 cycles) (SPEC-P-SOVEREIGN §4)
pledge { promise, ... }{ braceCompile-time proof + runtime kernel/supervisor enforcementOS syscall surface: pledge { stdio, rpath, inet } (SPEC-080)

Both forms MAY co-exist on the same function. They compose independently.

pub func serve_http(listener: TcpListener) do
pledge { stdio, inet }
// compiler proves: body performs no syscalls outside { stdio, inet }
// runtime: pledge(2) on OpenBSD, seccomp-bpf on Linux, capability narrow on NexusOS
end
  • Accepted lists: comma-separated ({ stdio, rpath, inet }) or whitespace-separated ({ stdio rpath inet }, OpenBSD-style).
  • Empty block (pledge { }) – maximally restrictive; function may only compute, return, or _exit.
  • Narrowing-only rule – a function may narrow its module’s pledge but never widen (PU-E003).
  • Transitive verification – every transitively-called function is checked against the caller’s pledge set.
  • Core promises: stdio, rpath, wpath, cpath, tmppath, inet, unix, dns, getpw, proc, exec, fattr, flock, tty, chown, id, unveil.
  • 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).
pub func serve_static(req: Request) do
pledge { stdio, rpath, inet }
unveil {
"/var/www/public" : { .read },
"/etc/ssl/certs" : { .read },
"/tmp/upload" : { .write, .create },
}
// ... body ...
end
  • 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).

19.4 Pledge in :cluster (Grain Integration)

Section titled “19.4 Pledge in :cluster (Grain Integration)”

A grain declares a committed pledge at definition scope; each transition MAY declare a narrower effective pledge:

grain ChatSession do
pledge { stdio, inet } -- committed: what the OS enforces at spawn
in ReceiveMsg(m: Message)
in SendMsg(m: Message)
transition handle_receive(m) do
pledge { stdio } -- effective: stricter; effect set ⊆ { stdio }
persist(m)
end
transition handle_send(m) do
pledge { stdio, inet }
connect(server) |> send(m)
end
end

Grain-level pledge influences six layers (SPEC-080 §6.5):

  1. Placement – hosts without the required promise support are rejected as placement targets.
  2. Migration – target-host pre-handshake verifies pledge support; failure is a placement error, not a runtime panic.
  3. Message admission – the typed mailbox refuses messages whose handlers require promises outside committed (PU-E009).
  4. Transition reachability – transitions exceeding committed pledge are unreachable by construction.
  5. Supervision lattice – child pledge SHALL ⊆ parent pledge; privilege narrows monotonically (PU-E010).
  6. Virtual-pledge runtime gate – when many grains share a process, the compiler synthesizes SPEC-030 user-defined-effect handler gates enforcing per-grain pledge in-language.

Stdlib facades that wrap syscall-producing operations ship two variants at the same call-target:

VariantSignatureUse
Portablewrite_text(path, data)Ambient authority; no capability ceremony
Sovereignwrite_text_sovereign(wpath_cap, path, data)Typed-capability-gated; the wpath_cap is a compile-time proof object, zero runtime cost
  • Both variants compile to identical machine code on the hot path.
  • The sys/* layer stays token-free; capabilities are a facade-level concept.
  • Reference implementation: std.net.http.http_get_sovereign (predates SPEC-080).

Capability tokens (WpathCap, RpathCap, InetCap, …) are obtainable through exactly two mechanisms:

Canonical: a pledge { ... } block auto-materializes the matching token into lexical scope. No user ceremony.

pub func write_config(path: []const u8, data: []const u8) !void do
pledge { wpath }
-- `wpath_cap: WpathCap` is in scope; no explicit construction needed
return std.os.fs.write_text_sovereign(wpath_cap, path, data)
end

Hazard-flagged: std.os.caps.unsafe_forge_<name>_cap() fabricates a token outside a pledge block. Requires ⚠ sigil + one-line justification. Emits PU-W006 at every call site, unconditionally. Intended for migration code only.

There is no third mechanism. No trait impls, no ambient inference, no derive macros.

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.


20. Anti-Patterns — Decomposition Doctrine

Section titled “20. Anti-Patterns — Decomposition Doctrine”

These are the mistakes Janus is designed to prevent. Each is named here so the linter can catch it and the developer can name it.

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.

// ANTI-PATTERN — domain-model trap
trait Vehicle { fn drive(self) }
trait LandVehicle { fn drive(self) } // Already covered by Vehicle
struct Truck { ... }
// BETTER — operation-oriented
struct Position { x: f64, y: f64 }
struct Velocity { dx: f64, dy: f64 }
struct Mass { kg: f64 }
system physics_step(entities: Query<(Position, Velocity, Mass)>, dt: f64) do
// canonical ECS iteration — no trait hierarchy needed
end

Heuristic: If your trait hierarchy has more than two levels, you are probably mirroring a human ontology instead of a code structure.

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.

// ANTI-PATTERN — encapsulated entity in hot loop
struct Entity {
pos_x: f64, // private with accessor
pos_y: f64,
}
for entity in entities do
entity.set_pos(entity.get_x() + dx, entity.get_y() + dy) // indirect calls
end
// CANONICAL — component arrays (SPEC-082 §5)
for i in 0..count do
pos_x[i] += vel_x[i] * dt
pos_y[i] += vel_y[i] * dt
end

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.

  • SPEC-082 — Decomposition Doctrine (full treatment of §20.1 through §20.5)
  • SPEC-021 — :cluster profile (actor decomposition)
  • SPEC-050 — :compute profile (ECS decomposition)
  • std.ecs — Reference ECS architecture

Anchor: SPEC-090 (Algebraic Effects & Profile-Ambient Handlers). Phase C LANDED 2026-05-10 — effect graph + visitor API + SPEC-080 syscall_class consumer + effect_set_of(f) query. Doctrine: doctrines/three-leg-tripod.md. Effects are the third leg of the static-analysis tripod: what does this function do to the world?

A function that performs side effects declares them as a brace-delimited row immediately after the return type, optionally after an error union:

// Pure function — no row.
func add(a: i64, b: i64) -> i64 do
return a + b
end
// Single effect.
func read_config(view path: [u8]) -> Config !{IO} do
let bytes = IO.stdout_write("loading config")
return parse(bytes)
end
// Error union + effect row. Order is fixed: -> T !ErrorType !{Effects}
func fetch_blob(view cid: [32]u8, allocator: std.mem.Allocator)
-> [u8] !FetchError !{IO, Net, Alloc} do
// ...
end
// Multi-effect — order within the braces is not significant.
func game_turn(view player: [u8]) -> i64 !{IO, Random} do
let roll = Random.next_int(6) + 1
IO.stdout_write(player ++ " rolled: " ++ str(roll))
return roll
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.
func process(view bytes: [u8]) -> Result do ... end
// Explicit purity assertion — adding any effect later produces E2509.
pub func canonical_hash(view bytes: [u8]) -> [32]u8 !{} do
return blake3(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.

21.3 handle ... with E do ... end Handlers

Section titled “21.3 handle ... with E do ... end Handlers”

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):

handle do
handled_expression
end with EffectName do
func operation_name(params) -> ReturnType do
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.

Every profile carries a profile-ambient handler table — the set of effects whose handlers the runtime installs by default at program entry:

ProfileAmbient effects
:core / :s0(empty — pure-by-default)
:serviceIO, Alloc, Net, Time
:cluster(empty — Send/Recv/Spawn pending SPEC-021 ratification)
:compute(empty — GPU/NPU pending SPEC-017-P device dispatch)
:sovereign(empty — Boot/MMU/Hardware pending SPEC-080 §4.4)

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.

Higher-order parameters propagate their callee’s effects through the wrapper:

func map[T, U](view xs: [T], view f: func(T) -> U !{?E}) -> [U] !{?E} do
var result: [U] = []
for x in xs do result.append(f(x)) end
return result
end

When called with f typed func(T) -> U !{IO}, the call site’s ?E resolves to IO. When f is pure, ?E resolves to the empty row.

?E is not unrestricted row polymorphism. The compiler rejects:

  • ?E mixed with concrete effects in the same row → E2512
  • Row arithmetic on ?E (?E - X, ?E & X) → E2514
  • Multiple ?E sharing a name → E2515 (use distinct names — ?Ef, ?Eg)
  • ?E declared in a row without a higher-order parameter to source it → E2513
  • Bounded row variable / row-polymorphism-with-constraints → E2517

?E is restricted to call-site monomorphization through higher-order parameters. Every ?E MUST be sourced by a function-typed parameter’s effect row.

effect IO {
func stdin_read(edit buf: [u8]) -> usize
func stdout_write(view bytes: [u8]) -> void
}
effect Alloc {
func alloc(size: usize, align: usize) -> *u8
func free(take ptr: *u8) -> void
}
  • Effect names MUST be PascalCase; operation names MUST be snake_case.
  • Default implementations are FORBIDDEN — effects are pure contracts.
  • Effect operation signatures use SPEC-085 intent qualifiers natively.
  • Effect names and module names share a namespace; collision produces E2508.

21.7 Affine-Ledger Semantics Across the Effect-Call Boundary

Section titled “21.7 Affine-Ledger Semantics Across the Effect-Call Boundary”

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:

effect Alloc {
func free(take ptr: *u8) -> void
}
func release(take ptr: *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)
end
  • Effects answer: what can this computation do?
  • 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.Xv2026.7.X), the parser accepts the legacy form and rewrites to !{E} with W2509:

// Old (SPEC-030, deprecated, surfaces W2509):
func legacy() -> i32 with IO do ... end
// New (SPEC-090, canonical):
func canonical() -> i32 !{IO} do ... end

After v2026.7.X the with clause produces a parse error.

CodeMeaning
E2502Incomplete handler — missing operation (substrate-blocked v1.0)
E2509Function declares !{} (purity assertion) but body introduces effects
E2510Multi-effect single with clause (with E1, E2 do is forbidden)
E2511Effect reaches main but is not in the active profile’s ambient handler table
E2512?E mixed with concrete effect in the same row
E2513?E polymorphism variable has no source
E2514Row arithmetic on polymorphism variable
E2515Multiple ?E polymorphism variables sharing a name
E2516Handler body declares its own row using row arithmetic
E2517Bounded row variable / row-polymorphism-with-constraints
W2509SPEC-030 with clause — deprecated; rewrite as !{...} row
W2516Duplicate effect in row — likely a copy-paste 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)
pub func write(fd: Fd, buf: []const u8) -> Result[usize, Errno] do ... end
// Multi-class disjunction (§8.1.2) — readlink is rpath + stdio-adjacent.
@syscall_class(rpath, stdio)
pub func readlink(path: []const u8, 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.”