Skip to content

v2026.5.10 – SPEC-090 Phase C: Algebraic Effects Land

v2026.5.10 – SPEC-090 Phase C: Algebraic Effects Land

Section titled “v2026.5.10 – SPEC-090 Phase C: Algebraic Effects Land”

Release date: 2026-05-10 Profiles affected: every profile (uniform language surface), with the active behaviour on :sovereign Status: Feature release (effect graph + visitor API + SPEC-080 syscall_class consumer)

This release lands SPEC-090 Phase C end-to-end — the effect graph, the SPEC-030 §3.4.5 visitor API, the profile-ambient handler tables, the effect_set_of(f) query layer, the canonical first consumer (SPEC-080’s @syscall_class aggregator), and the Janus-side std.compiler.effects contract surface.

After this commit, Janus can answer the third leg of the static-analysis tripod: what side effects does this function actually perform? Combined with SPEC-085 (parameter intents — what does it touch?) and SPEC-029 (reference capabilities — what authority does it carry?), every Janus function is now compile-time honest about memory, authority, and side effects.


Functions that perform side effects declare them as a brace-delimited row immediately after the return type, optionally after an error union:

// Pure function — no effects, no errors
func add(a: i64, b: i64) -> i64 do
return a + b
end
// Single effect — reads as "returns Config, but can do IO"
func read_config(view path: [u8]) -> Config !{IO} do
let bytes = IO.stdout_write("loading config")
return parse(bytes)
end
// Error union + effect row
func fetch_blob(view cid: [32]u8, allocator: std.mem.Allocator)
-> [u8] !FetchError !{IO, Net, Alloc} do
// ...
end

The !{...} syntax is parallel to !Error: both are return-type modifiers. The braces follow Law 2 ({ } for data structures) — the effect row is a set of effect tags, not control flow. The leading ! mirrors error-union syntax for visual symmetry.

A function with !{} and a function omitting the row are not the same:

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

handle ... with E do ... end — effect handlers

Section titled “handle ... with E do ... end — effect handlers”

Effect handlers use do...end block form (Law 1, control flow), not the brace-delimited handler list of SPEC-030. The handle expression has the shape:

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 function definitions, not a data table. Each handler is a piece of imperative code. SPEC-030’s brace-delimited form treated handlers as data; SPEC-090 treats them as code, consistent with Law 1.

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 ambient table must be wrapped in an explicit handle...with block; otherwise E2511 fires:

// Under :core (empty ambient), this fires E2511.
// Under :service (IO is ambient), this is fine.
func main() -> i32 do
return writes_io() // writes_io declared !{IO}
end

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
// ... f's effect row threads through map's row
end

This is not unrestricted row polymorphism — ?E is restricted to call-site monomorphization through higher-order parameters. Every ?E in a function’s row must be sourced by a function-typed parameter (E2513 if not).


After this release, three queries answer the tripod:

What does it touch? — SPEC-085 parameter intents (view/edit/take/make)
What authority does it carry? — SPEC-029 reference capabilities (iso/trn/val/ref/box/tag)
What side effects does it perform? — SPEC-090 effect rows (!{IO, Net, Alloc, ...})

Every Janus function signature is honest along all three axes. The compiler proves the claim via:

  • Parameter-passing analysis (SPEC-085)
  • Cross-actor send-safety (SPEC-029)
  • Call-graph effect propagation (this release)

The compiler exposes two query entry points over the per-function effect data:

  • declaredEffectRowOf(f) — the contract surface. The !{...} row the user wrote. What callers see; what auditors read.
  • inferredEffectSetOf(f) — the deep view. The post-handle-elimination callee-propagated set unioned with profile-gate-satisfied visitor contributions. What tooling consults when peering past the contract.

Per [EFF:7.1.4], these can’t collapse into one query — 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 first concrete consumer of the SPEC-030 §3.4.5 visitor API ships in this release. Stdlib syscall wrappers carry @syscall_class(<class>) annotations:

@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 (:core, :service, :cluster, :compute), the visitor is silently inactive — no diagnostic, no overhead.

This is the substrate SPEC-080’s pledge verifier consumes to compute required_promises for compile-time pledge proof.


A new stdlib module declares the SPEC-030 §3.4.5 visitor API contract types in Janus:

use std.compiler.effects
// Future: register a stdlib-extension visitor in Janus code.
// (Phase D wires execution; the surface is in place today.)
pub func register_effect_visitor(
schema_name: []u8,
profile_gate: Profile,
visitor: func(EffectGraphNode, AttributeBag, EffectSet) -> void,
) -> void do
// Phase D — comptime evaluator routes Janus visitor bodies into
// the Zig-side EffectVisitorRegistry.
end

Status: SURFACE-ONLY. The Janus-side registration entry point parses and type-checks but the visitor body cannot execute under the current compiler — that path lands with Phase D’s comptime evaluator integration. Compiler-internal consumers (notably the SPEC-080 syscall_class aggregator above) register Zig-side via compiler/libjanus/effect_graph.zig::EffectVisitorRegistry.register(). The Zig path remains the supported mechanism for compiler-internal consumers after Phase D — Phase D adds the Janus-side execution path, it does not deprecate the Zig-side one.


CodeMeaning
E2502Incomplete handler — missing operation (substrate-blocked v1.0; activates when effect EffectName { func op() ... } parser path lands)
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 — function has no higher-order parameter to bind it
E2514Row arithmetic (subtraction, intersection) on polymorphism variable
E2515Multiple ?E polymorphism variables — use distinct names (?Ef, ?Eg)
E2516Effect handler body declares its own effect row using row arithmetic
E2517Bounded row variable / row-polymorphism-with-constraints
W2509SPEC-030 with clause — deprecated; rewrite as !{...} row
W2516Duplicate effect in row (!{IO, IO}) — likely a copy-paste error

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
return 0
end
// New (SPEC-090, canonical):
func canonical() -> i32 !{IO} do
return 0
end

After v2026.7.X the with clause produces a parse error. The migration tutorial walks through the rewrites — see Effects: From with E to !{E}.


Phase D items that don’t land here:

  • Janus-bodied visitor execution. Stdlib consumers writing visitor bodies in Janus must wait for Phase D’s comptime evaluator integration. Until then, register Zig-side.
  • @syscall_class on extern func. SPEC-080 §8.1’s example uses pub func only; the extern path is a Phase A.4 follow-up.
  • Stdlib annotation rollout. Not every std.os.* wrapper carries @syscall_class yet; ongoing coordinated work with SPEC-080 Phase A.
  • Class-membership validation. Mis-spelled or non-core @syscall_class(...) values pass through unchecked. SPEC-080’s pledge verifier owns that diagnostic.
  • QTJIR Effect_Perform / Effect_Handle opcodes. Phase D wires the lowering; today’s effect rows live entirely at the sema layer.

  • @syscall_class(...) parser-newline doctrine. The C8 fix forced explicit .newline-skip handling between the attribute and the func keyword. This is now baked into the parser’s top-level dispatch; future function-attribute kinds should mirror the pattern.
  • processUseJan cleanup-defer drift. Five LoweringResult sub-allocated fields had drifted out of sync between the canonical LoweringResult.deinit and the imported_result cleanup defer in processUseJan. The fix fa0568b6 adds the missing arms (most prominently exported_structs.field_layout — 11 leaks per smoke). Pattern to watch: when LoweringResult grows a new sub-allocated field, mirror cleanup in BOTH sites.
  • SPEC-090 Phase G docs sweep (this release note + the reference page + the tutorial) discharges the Janus docs mandate for the Phase C landing.

  • 33 sema tests (test_spec_090_diagnostics.zig SEMA-090-T01..T33), with T29-T33 covering the EFF-011 acceptance suite end-to-end (:sovereign positive contribution, :service profile-gate negative, multi-class disjunction, declared+visitor coexistence per §3.4.5.7, named-row query).
  • 10 effect_graph unit tests covering the registry mechanics, profile-gate filtering, multi-visitor set-union composition.
  • 4 syscall_class_visitor unit tests (single-class, multi-class disjunction, empty bag, profile gating).
  • 6 effect_set_of unit tests covering the declared/inferred query independence per [EFF:7.1.4].
  • 2 AOT smokes (parse_syscall_class_smoke.jan, use_std_compiler_effects_smoke.jan).

  • SPEC-090 Phase D — QTJIR opcodes (Effect_Perform / Effect_Handle), ?E monomorphization at call sites, handle...with lowering as inline call substitution, profile-ambient handler injection at program entry. Target: 2 weeks.
  • SPEC-090 Phase E — stdlib runtime ambient handler tables (std/runtime/effects/{script,core,service,cluster,compute,sovereign}_ambient.jan).
  • SPEC-080 Phase A@syscall_class attribute parser is now in place; the pledge verifier integration that consumes the inferred set is next.

The static-analysis tripod is structurally complete. Phase D makes the rows execute; Phase G (this release note + tutorial + reference page) makes them legible.


Phase C ladder + followups (post-rebase SHAs on unstable):

SHAStep
df5632ccC1 — effect_graph.zig skeleton
ac1ecf1dC2 — propagation pass extracted
7303947dC3 — profile-ambient overlay extracted
7f50d938C4 — visitor registration plumbing
a2ab6868C5 — std.compiler.effects Janus surface
7f209ae7C6 — SPEC-080 @syscall_class consumer (parser + AST + sema + visitor)
03acd205C7 — effect_set_of(f) query layer
83b6d876C8 — EFF-011 acceptance suite + parser newline-skip fix
42d9b32ffollowup — lowerer skip-arm for syscall_class_attr / capability_attr
fa0568b6followup — processUseJan imported_result leak quartet fix