Lowent Manual←↑→

15 Effects — the marks an op leaves on the world

What to know first

chapter 5, Ops · a fn is pure and a proc’s effects is a narrowing clause
chapter 14, Contracts · an unenforced promise is not used as a fact

Looking back

In chapter 5, the pure helper did not print by itself and only called say, yet it was rejected. Why?

A. Because effects spread to the caller. say performs io, so helper, which calls it, performs io too, and a pure fn cannot perform effects. This chapter covers what effects are made of, and what happens when the declaration and reality diverge in either direction.

The need for this chapter, and its context

If a contract is a promise about values (chapter 14), an effect is a promise about behaviour. Does it read files, obtain memory, possibly stop, wait for another flow? Without these answers in the head, a caller must follow the body to the end. Lowent has effects written as a set of closed words and the compiler checks both directions. Capabilities (chapter 16) are the partner that writes who allowed the effect, so effects come first.

By the end of this chapter

You will learn that effects are a closed set of atoms and what each atom means. You will pick up that effects spread along calls, that doing more than declared is rejected and declaring what is never done is warned about. You will see the rules that follow from the effects line being a set, the closure by which concurrent brings wait with it, and via, which lets a type argument decide the effects. Finally you will understand what purity allows the compiler to do.

The questions this chapter answers

  1. To leave one log line, must a whole pure op become a proc?

15.1 Effects are closed words#

An effect is an influence an op has on the outside. Authors cannot invent new effects; the list is fixed by the language.

AtomMeaning
noneNo effect at all. The bottom
ioExchanges data with the outside (files, connections, standard I/O)
allocObtains or returns memory from a fixed window
heapObtains memory from a growing root. Exists only on machines with an operating system
stateChanges state held by a module or actor, or the caller’s storage
panicMay stop the program
atomicPerforms indivisible reads and writes
concurrentCompletion depends on the progress of another flow
waitWaits; wakes up eventually without anyone’s help
lockTakes a lock
deviceTouches a device directly
unsafeDoes work the language cannot check
page_fault · blocking · cancel · detacha flow stopping or being cut off — no primitive produces them yet

Table 15.1 — Effect atoms

The nine io, alloc, heap, state, panic, atomic, concurrent, wait and unsafe have primitives that actually produce them, and the compiler enforces them — a body that does it must say so, and saying it without doing it is reported. The other six (lock, device, page_fault, blocking, cancel, detach) have no primitive yet. They are accepted as declarations and spread to callers, but whether the body really does it cannot be asked. A word not on the list is rejected.

examples/ch15/undef.low

module undef .
rem expect: E-EFFECT-UNDEF

proc checked input x u64 . output u64 . effects crash .
do
  return x .
end

Output

$ lowentc --check undef.low
undef.low:4:0 E-EFFECT-UNDEF: unknown effect (the vocabulary is closed: none/alloc/heap/io/wait/concurrent/lock/atomic/unsafe/device/page_fault/blocking/cancel/detach/panic/state) — a typo here silently declares the op PURE

Quietly accepting a typo would make that op read as having no effect — the case the diagnostic describes as “a typo here silently declares the op PURE”.

15.2 Effects spread to the caller#

Calling an op adds its effects to the caller’s. Performing more effects than declared is rejected.

examples/ch15/spread.low

module spread .
rem expect: E-EFFECT

proc log_line input out cap io . input msg slice u8 . output u64 . effects io .
do
  return write_out out 1 msg .
end

proc compute input out cap io . input x u64 . output u64 . effects panic .
  requires le x 1000 .
do
  let n u64 be log_line out "computing\n" .
  if eq x 0 . do
    panic "zero" .
  end
  return mul x 2 .
end

Output

$ lowentc --check spread.low
spread.low:11:1 E-EFFECT: this op performs `io`, which its `effects` clause does not declare — add it to `effects …`, or stop calling what needs it

compute declares only effects panic ., but calls log_line and so also performs io. The diagnostic says “add it to effects, or stop calling what needs it”. Conversely, the layers effects do not spread into are clear too.

examples/ch15/layered.low

module layered .
rem run: main

fn celsius_to_f input c i64 . output i64 .
  requires ge c -1000 .
  requires le c 1000 .
do
  return add (div (mul c 9) 5) 32 .
end

proc print_digit input out cap io . input d u8 . output u64 . effects io .
  requires le d 9 .
do
  let buf slice u8 be "0123456789" .
  return write_out out 1 (subslice buf (widen u64 d) (add (widen u64 d) 1)) .
end

proc main input out cap io . output u8 . effects io .
do
  let f i64 be celsius_to_f 100 .
  let n u64 be print_digit out (narrow u8 (mod f 10)) .
  let m u64 be write_out out 1 "\n" .
  return 0 .
end

Output

$ lowentc --run main layered.low
2
main() = 0

celsius_to_f is a pure fn and can be called anywhere. print_digit performs io, so it can be called only from main, which declares io. The computing layer and the layer that touches the outside separate in the head. Good design keeps the pure layer wide and the effectful layer thin.

Q. To leave one log line, must a whole pure op become a proc?

A. Yes. An op that logs leaves a trace outside, so it is not pure, and the head must show it. There is a common way out, though. Keep the computation pure and return the result, and log from the outer layer that receives it. This shape, pushing effects outward, is also easy to test — the pure layer can be checked right away with --run and test without capabilities.

15.3 Declared but not done is reported#

Declaring an effect that is never performed is also a problem, because a declared effect is a cost the caller must bear.

examples/ch15/over.low

module over .
rem expect: W-EFFECT-OVER

proc double input x u64 . output u64 . effects panic .
  requires le x 1000 .
do
  return mul x 2 .
end

Output

$ lowentc --check over.low
over.low:6:1 W-EFFECT-OVER: this op DECLARES `panic` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)

double says it may stop, but it never stops. Then every op calling it must declare panic, and pure fns cannot call it — it exaggerated what it does. The diagnostic says to remove it, or to say so if a future version will actually perform it.

A common misconception. Declaring effects generously saves work later

Generous effects spread to every caller. Declaring one panic in advance makes every calling op declare panic, and computations that should be pure lose their purity. An op that loses purity also loses optimisations such as reordering and memoisation. Declare effects only as far as they are actually performed.

15.4 The effects line is a set#

The effects line is a set, not a list. Writing the same word twice is rejected.

examples/ch15/dup.low

module dup .
rem expect: E-EFFECT-DUP

proc checked input x u64 . output u64 . effects panic panic .
do
  if eq x 0 . do panic "zero" . end
  return x .
end

Output

$ lowentc --check dup.low
dup.low:4:0 E-EFFECT-DUP: an effect atom appears more than once in the `effects` clause — the row is a SET, not a list. A repeat says nothing the first one didn't, and the reader has to decide it's noise (RFC-0057 E3)

The second panic says nothing the first did not. none cannot appear with any other atom.

examples/ch15/nonemix.low

module nonemix .
rem expect: E-EFFECT-NONE-MIX

proc checked input x u64 . output u64 . effects none panic .
do
  if eq x 0 . do panic "zero" . end
  return x .
end

Output

$ lowentc --check nonemix.low
nonemix.low:4:0 E-EFFECT-NONE-MIX: `none` sits in the `effects` clause ALONGSIDE a real effect — `none` means this op performs no effects, so it cannot co-occur with one. Say the effects, or say none; not both (RFC-0057 E2)

An op cannot both have effects and have none; write the effects or write that there are none. And a fn does not even carry effects none (chapter 5).

Some effects bring others with them. Today there is one such rule — writing concurrent counts as writing wait too, since waiting for another flow’s progress may mean being suspended. Two sets are therefore compared after applying this closure. Writing concurrent means wait need not be written separately, but writing only wait and doing something concurrent is rejected.

15.5 via — a type argument decides the effects#

Generic containers are in an awkward spot, because their effects depend on which allocator they receive. Opened on a bump allocator over borrowed bytes, the effect is just state; opened on an allocator standing on the growing heap, heap appears. Always declaring the largest effect any allocator could produce is a lie, and declaring only the smallest is a hidden allocation.

export proc append
  input comptime t type .
  input comptime a type .
  input g mut vec t a .
  input x t .
  output bool .
  effects state via a .
  requires allocs.byte_allocator a .

This is the head of append in the standard library’s vecgen. effects state via a . means “the allocation-family effects (alloc, heap, atomic) declared by the ops of type a are also this op’s declaration”. Each monomorphised instance has its effects decided by that type. Generic containers return in chapter 34.

15.6 What purity allows#

A fn — an op whose effects are none — satisfies three properties.

These three allow optimisations. Two pure calls with the same arguments can be reduced to one (common-subexpression elimination), results can be remembered (memoisation), unused calls can be removed, and calls that do not depend on each other can be reordered or split up. That these optimisations do not change a program’s meaning is proven in Coq (chapter 44). Call boundaries and the timing of traps are outside that proof.

In practice. Where effect declarations become premises of a proof

The soundness theorem of the effect system says “the declared effects cover the effects that actually happen”. That theorem is what the theorems on effect-based optimisation stand on. So the compiler rejects code that does more than declared (the theorem’s premise) and warns about code that does less (harmless to the theorem, but pushing false costs onto callers). That is why the two directions carry different weight.

15.7 Common mistakes#

Counter-example. Using panic in a pure fn

examples/ch15/mistake_fnpanic.low

module mistake_fnpanic .
rem expect: E-EFFECT-CALC

rem ✘ a pure `fn` uses `panic` --- stopping the program is an effect too
fn nonzero input x u64 . output u64 .
do
  if eq x 0 . do panic "zero" . end
  return x .
end

Output

$ lowentc --check mistake_fnpanic.low
mistake_fnpanic.low:6:1 E-EFFECT-CALC: this fn is declared pure but performs `panic` — make it a `proc` with `effects …`, or remove the effect

Stopping the program leaves a mark on the outside too. If a caller memoises this op or reorders it, the moment of the stop changes, so a fn cannot use panic and is rejected with E-EFFECT-CALC. Attaching effects panic . to a fn is rejected with the same code — the words fn and proc already say whether an op is pure. There are two fixes: make it a proc that declares stopping as an effect, or move the condition into a contract, which makes it the caller’s responsibility.

examples/ch15/fnpanic_fixed.low

module fnpanic_fixed .
rem run: nonzero_checked 5
rem trap: nonzero_strict 0

rem fix 1 --- a `proc` that declares stopping as an effect
proc nonzero_checked input x u64 . output u64 . effects panic .
do
  if eq x 0 . do panic "zero" . end
  return x .
end

rem fix 2 --- move the condition into a contract and it stays a `fn`
fn nonzero_strict input x u64 . output u64 .
  requires ne x 0 .
do
  return x .
end

Output

$ lowentc --run nonzero_checked fnpanic_fixed.low 5
nonzero_checked(5) = 5
$ lowentc --run nonzero_strict fnpanic_fixed.low 0
== ir diagnostics (1) ==
0:0 E-VM-CONTRACT: `requires` violated at entry — the caller broke the contract

The second fix is usually better. nonzero_strict stays pure, and the diagnostic says the fault of passing 0 lies with the caller.

Counter-example. A fn writing into a slice received as mut

examples/ch15/mistake_fnmutwrite.low

module mistake_fnmutwrite .
rem expect: E-EFFECT-PURITY

rem ✘ a `fn` writes into the caller's slice received as `mut`
fn clear input xs mut slice u8 . output void .
do
  var i u64 be 0 .
  while lt i (len xs) do
    set (index xs i) 0 .
    set i (add i 1) .
  end
end

Output

$ lowentc --check mistake_fnmutwrite.low
mistake_fnmutwrite.low:5:0 E-EFFECT-PURITY: a `fn` WRITES through a `mut` parameter — that write is visible to the CALLER. A fn is an enforced purity contract (SPEC-003 §27): callers may memoise it, reorder it, or elide it. An op that changes caller-owned storage can do none of those. Declare it a `proc`. (Local mutation stays pure: storage confined to the op is not observable — a machine write is not an observable effect. RFC-0057)

The zeros written by clear stay in the caller’s slice. To the caller the outside has changed, and such an op cannot be memoised, removed or reordered. Hence E-EFFECT-PURITY. Change the head to proc clear … effects state .. As the diagnostic adds, changing locals inside an op does not break purity (see the misconception below).

Counter-example. Putting commas between effects

examples/ch15/mistake_effcomma.low

module mistake_effcomma .
rem expect: E-VOCAB-REMOVED

rem ✘ a comma between effects --- just separate the words with spaces
proc greet input out cap io . input x u64 . output u64 . effects io, panic .
do
  if eq x 0 . do panic "zero" . end
  return write_out out 1 "hi\n" .
end

Output

$ lowentc --check mistake_effcomma.low
5:68 E-VOCAB-REMOVED: `,` (R3) was removed — RFC-0103, 2026-08-27. It opened the NEXT operand of the same form, but nothing used it that way: every `,` in the corpus was a LINE CONTINUATION, and newlines no longer close a form, so continuing a line needs nothing at all. A form is `head operand*` and ends at its closer `.` — just write the operands, on as many lines as you like.

The habit of separating a list with commas becomes E-VOCAB-REMOVED here. The comma was once in the grammar but was only ever used to continue a line; newlines no longer close a form, so it was removed. Separate the words with spaces, as in effects io panic .. The form ends at the stop.

A common misconception. Using var and set makes an op impure

examples/ch15/pure_local.low

module pure_local .
rem run: sum_to 10

rem still pure with `var` and `set` --- the changes are visible only inside this op
fn sum_to input n u64 . output u64 .
  requires le n 1000 .
do
  var s u64 be 0 .
  var i u64 be 0 .
  while le i n do
    set s (add s i) .
    set i (add i 1) .
  end
  return s .
end

Output

$ lowentc --run sum_to pure_local.low 10
sum_to(10) = 55

sum_to keeps changing two locals, yet it is a fn. The storage that changes lives only inside this op; the caller sees only the result 55. It always gives the same answer for the same input, and not calling it leaves nothing behind. Purity does not mean “changes nothing inside”; it means “leaves no mark visible from outside”.

A common misconception. A fn never stops

examples/ch15/fn_can_stop.low

module fn_can_stop .
rem run: first [7,8]
rem trap: first []

rem a pure `fn`, yet it stops on an empty slice --- a bounds-check stop is not an effect the op performs
fn first input xs slice u8 . output u8 .
do
  return index xs 0 .
end

Output

$ lowentc --run first fn_can_stop.low [7,8]
first([7,8]) = 7
  arg0 (written) = [7,8]
$ lowentc --run first fn_can_stop.low []
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: slice index out of bounds (panic)

What a fn cannot use is the panic effect. A stop raised by the processor because a contract or a bound broke — such as reading the first slot of an empty slice — is treated as the result of a wrong call, not as something the op did (canon 6.5.9). So the pure first also stops when given []. To avoid the stop, expose the responsibility to the caller with requires ge (len xs) 1 ., or return an option.

Counter-example. Calling an op with a rare effect from a pure fn

examples/ch15/mistake_blocking.low

module mistake_blocking .
rem expect: E-EFFECT-CALC

rem blocking: the head says it may hold the flow of execution
proc wait_for_device output u64 . effects blocking .
do
  return 0 .
end

rem ✘ a pure fn calls an op that may hold the flow; in this edition it slips through
fn ready output u64 . do
  return wait_for_device .
end

Output

$ lowentc --check mistake_blocking.low
mistake_blocking.low:11:23 E-EFFECT-CALC: this fn is declared pure but performs (이름 없는 효과 비트) — make it a `proc` with `effects …`, or remove the effect

blocking means “may hold the flow of execution”, so a fn calling such an op is not pure, and it is refused with E-EFFECT-CALC. Until 2026-09-16 six atoms — lock, device, page_fault, blocking, cancel and detach — did not spread to the caller, so this passed; device, which touches hardware directly, could hide behind a pure function. The same shape with wait was refused even then.

examples/ch15/blocking_wait.low

module blocking_wait .
rem expect: E-EFFECT-CALC

rem wait does spread; the same shape is refused with E-EFFECT-CALC
proc wait_for_device output u64 . effects wait .
do
  return 0 .
end

fn ready output u64 . do
  return wait_for_device .
end

Output

$ lowentc --check blocking_wait.low
blocking_wait.low:6:1 W-EFFECT-OVER: this op DECLARES `wait` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)
blocking_wait.low:10:23 E-EFFECT-CALC: this fn is declared pure but performs `wait` — make it a `proc` with `effects …`, or remove the effect

These six have no primitive that performs them yet, so they are not asked about by “declared but never performed” (W-EFFECT-OVER) — there is no body to ask. They only propagate.

15.8 This chapter’s syntax at a glance#

ShapeMeaningWhy
fn …no effects — write no effects clausepurity is visible in one word
proc … effects io panic .the set of effects this op may performreading the head tells you what it does
proc … (no clause)not narrowed — may perform anythingnarrowing is the author’s choice
atoms io·alloc·heap·state·panic·…a closed list fixed by the languageso a typo never silently means “pure”
effects panic panic . · none panicrejected (E-EFFECT-DUP · E-EFFECT-NONE-MIX)a set has no repeats or contradictions
doing more than declared · declaring and not doingerror (E-EFFECT) · warning (W-EFFECT-OVER)premise of the proofs · a false cost for callers
concurrentbrings wait alongwaiting on another flow can block
effects state via a .inherit the allocation effects of type argument aeach allocator gets exact effects

Table 15.2 — Effect syntax — shape · meaning · why it looks this way

Recap

Effects are a closed set of atoms, and words outside the list are rejected. Effects spread along calls; doing more than declared is rejected and declaring what is not done is warned about. The effects line is a set, so duplicates and mixing with none are rejected, and concurrent brings wait. via lets a type argument decide allocation-family effects. Purity allows optimisations such as reordering, memoisation and removal.