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.
What’s new at the surface
Section titled “What’s new at the surface”!{Effects} return-type modifier
Section titled “!{Effects} return-type modifier”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 errorsfunc add(a: i64, b: i64) -> i64 do return a + bend
// 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 rowfunc fetch_blob(view cid: [32]u8, allocator: std.mem.Allocator) -> [u8] !FetchError !{IO, Net, Alloc} do // ...endThe !{...} 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.
!{} — explicit purity assertion
Section titled “!{} — explicit purity assertion”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 rowfunc process(view bytes: [u8]) -> Result do ... end
// Explicit purity assertion — adding any effect later produces E2509pub 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_expressionend with EffectName do func operation_name(params) -> ReturnType do handler_body endendThe 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.
Profile-ambient handler tables
Section titled “Profile-ambient handler tables”Every profile carries a profile-ambient handler table — the set of effects whose handlers the runtime installs by default at program entry:
| Profile | Ambient effects |
|---|---|
:core / :s0 | (empty — pure-by-default) |
:service | IO, 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?E — narrow-form effect polymorphism
Section titled “?E — narrow-form effect polymorphism”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 rowendThis 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).
Static-analysis surface
Section titled “Static-analysis surface”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)
effect_set_of(f) query
Section titled “effect_set_of(f) query”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).
SPEC-080 @syscall_class consumer
Section titled “SPEC-080 @syscall_class consumer”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 // ...endUnder :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.
Janus-side surface: std.compiler.effects
Section titled “Janus-side surface: std.compiler.effects”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.endStatus: 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.
Diagnostics
Section titled “Diagnostics”| Code | Meaning |
|---|---|
| E2502 | Incomplete handler — missing operation (substrate-blocked v1.0; activates when effect EffectName { func op() ... } parser path lands) |
| E2509 | Function declares !{} (purity assertion) but body introduces effects |
| E2510 | Multi-effect single with clause (with E1, E2 do is forbidden) |
| E2511 | Effect 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 |
| E2514 | Row arithmetic (subtraction, intersection) on polymorphism variable |
| E2515 | Multiple ?E polymorphism variables — use distinct names (?Ef, ?Eg) |
| E2516 | Effect handler body declares its own effect row using row arithmetic |
| E2517 | Bounded row variable / row-polymorphism-with-constraints |
| W2509 | SPEC-030 with clause — deprecated; rewrite as !{...} row |
| W2516 | Duplicate effect in row (!{IO, IO}) — likely a copy-paste error |
SPEC-030 migration
Section titled “SPEC-030 migration”The SPEC-030 with E clause syntax is deprecated. During the migration window (v2026.5.X → v2026.7.X), the parser accepts the legacy form and rewrites to !{E} with W2509:
// Old (SPEC-030, deprecated, surfaces W2509):func legacy() -> i32 with IO do return 0end
// New (SPEC-090, canonical):func canonical() -> i32 !{IO} do return 0endAfter v2026.7.X the with clause produces a parse error. The migration tutorial walks through the rewrites — see Effects: From with E to !{E}.
What’s NOT in this release
Section titled “What’s NOT in this release”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_classonextern func. SPEC-080 §8.1’s example usespub funconly; the extern path is a Phase A.4 follow-up.- Stdlib annotation rollout. Not every
std.os.*wrapper carries@syscall_classyet; 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_Handleopcodes. Phase D wires the lowering; today’s effect rows live entirely at the sema layer.
Deferred amendments
Section titled “Deferred amendments”@syscall_class(...)parser-newline doctrine. The C8 fix forced explicit.newline-skip handling between the attribute and thefunckeyword. This is now baked into the parser’s top-level dispatch; future function-attribute kinds should mirror the pattern.processUseJancleanup-defer drift. Five LoweringResult sub-allocated fields had drifted out of sync between the canonicalLoweringResult.deinitand the imported_result cleanup defer inprocessUseJan. The fixfa0568b6adds the missing arms (most prominentlyexported_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.
Test coverage
Section titled “Test coverage”- 33 sema tests (
test_spec_090_diagnostics.zigSEMA-090-T01..T33), with T29-T33 covering the EFF-011 acceptance suite end-to-end (:sovereignpositive contribution,:serviceprofile-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).
What’s next
Section titled “What’s next”- SPEC-090 Phase D — QTJIR opcodes (
Effect_Perform/Effect_Handle),?Emonomorphization at call sites,handle...withlowering 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_classattribute 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.
Commits
Section titled “Commits”Phase C ladder + followups (post-rebase SHAs on unstable):
| SHA | Step |
|---|---|
df5632cc | C1 — effect_graph.zig skeleton |
ac1ecf1d | C2 — propagation pass extracted |
7303947d | C3 — profile-ambient overlay extracted |
7f50d938 | C4 — visitor registration plumbing |
a2ab6868 | C5 — std.compiler.effects Janus surface |
7f209ae7 | C6 — SPEC-080 @syscall_class consumer (parser + AST + sema + visitor) |
03acd205 | C7 — effect_set_of(f) query layer |
83b6d876 | C8 — EFF-011 acceptance suite + parser newline-skip fix |
42d9b32f | followup — lowerer skip-arm for syscall_class_attr / capability_attr |
fa0568b6 | followup — processUseJan imported_result leak quartet fix |