SFEP-0049
Effect-transparency for concurrency-primitive leaves (spawn / parallel / channel send·receive)
- Status
- Implemented
- Type
- language
- Created
- Updated
- Author
- agent:compiler-architect; human review
- Tracking
- SFN-86, #1702
SFEP-0049 — Effect-transparency for concurrency-primitive leaves (spawn / parallel / channel send·receive)
1. Summary
The three in-process concurrency-primitive leaves — spawn, parallel, and
channel send/receive — currently attribute a hardcoded ![io] effect to
their caller regardless of what the spawned/sent body actually does. That io
is a deliberate conservative placeholder (runtime_helpers.sfn lines
~1118–1161, #1655), not a real capability the primitives exercise: the v0
scheduler primitives are pure pthread mutex/condvar/MPMC-queue code that touch
no fs, net, console, or clock. This SFEP makes those three leaves
effect-transparent: the primitive itself contributes no effect, and the
caller inherits exactly the effects of the body it spawns/sends. A spawn /
parallel / channel op over a pure body requires no effect; over an
![io] body, io propagates. This closes a spot where the flagship effect
checker lies about concurrency — a pure fan-out is pure, as it is in Go — while
preserving the #1655 “registry rows and checker arms agree by construction”
invariant. This is the leaf half of SFN-86 (#1702); the join half
(await and nursery/routine {} exit effect propagation) is explicitly
deferred to the concurrency-cancellation SFEP (SFN-124).
This proposal is SFEP-0049 (registry maximum was 0048; SFEP-0001 §2 assigns
max + 1 at merge) and Accepted at the design gate (owner sign-off, 2026-07-17).
2. Motivation
Sailfin markets effects as its headline pillar — compile-time capability
enforcement is the whole differentiator. That claim is only as strong as the
effect checker’s honesty. Today the checker attaches io to any spawn,
parallel, or channel send/receive, so a function whose only “impure” act
is fanning a pure computation across threads is forced to declare ![io] and,
transitively, so is every one of its callers. Concretely:
// Pure fan-out of a pure computation. There is no I/O anywhere here.fn sum_in_parallel(a: int, b: int) -> int ![io] { // ← the ![io] is a lie let joined = parallel [ fn () -> int { return a * a; }, fn () -> int { return b * b; }, ]; return joined[0] + joined[1];}Nothing in sum_in_parallel performs I/O — parallel here is a scheduling
mechanism over two pure closures. Go treats goroutine spawn as effect-free (a
pure fan-out is pure); the same should hold in Sailfin. The phantom io makes
the checker over-attribute a capability, which (a) teaches users that “effects
are approximate” — corrosive to the pillar — and (b) forces io to bubble up
call chains that never touch a capability, defeating the fine-grained enforcement
the effect system exists to provide.
Why now. #1655 deliberately parked this as a placeholder pending #829
(frontend concurrency typing) — the design was “ahead of the foundation.” That
soft gate has effectively closed: typed Channel<T> (SFN-111) and live
await-result typing (SFN-113) both landed in 2026-07, so the concurrency
frontend is now typed enough to reason about spawned/sent bodies. This SFEP is
the principled end state the #1655/#1702 comment block names outright:
“effect-transparency (the spawned body’s own effects propagate, the primitive
adds nothing).” It is a self-contained, mostly-single-file correctness fix that
directly strengthens the language’s flagship guarantee.
3. Design
3.1 Scope boundary — leaf only
This SFEP covers the effect attribution of the in-process primitive leaves:
- IN:
spawn <expr>/spawn(...),parallel [...], channel.send(v), channel.receive(). - OUT (deferred to the concurrency-cancellation SFEP / SFN-124 “join half”):
the join-side counterpart —
await <future>and nursery-exit (routine {}) effect propagation.awaittoday only recurses into its operand and contributes no effect of its own (effect_checker.sfnline ~1535), androutine {}exit has no dedicated effect arm; giving those join points their own effect semantics (e.g. propagating a joined task’s effects at the await site, or a nursery aggregating its children’s effects at exit) is a distinct design entangled with cancel-on-fault and future-result typing. That work is the natural home for the join-side rules and is called out as such in the cancellation SFEP’s cross-reference. Drafting the leaf half now delivers the honest-attribution win without waiting on the join-side redesign.
3.2 Core change
The three placeholder registry rows drop their io to effects: [], and the
checker arms — which already recurse into the body — propagate the body’s
own collected effects unchanged.
Registry (compiler/src/llvm/runtime_helpers.sfn, _rh_descriptors_08):
| target | before | after |
|---|---|---|
spawn_task |
effects: ["io"] |
effects: [] |
channel_send |
effects: ["io"] |
effects: [] |
channel_receive |
effects: ["io"] |
effects: [] |
parallel_join is already effects: [] and stays so. No parallel
descriptor row is added — the Parallel arm continues to route its effect
through the spawn_task row (which now resolves to []), preserving the
fail-closed rejection of the unimplemented sfn/sync paren-call parallel(...)
that a dedicated row would make resolvable (sync_rejects_unimplemented_ concurrency_test.sfn). channel_create/channel_close remain effects: []
(unchanged — no checker arm consults them).
Checker (compiler/src/effect_checker.sfn): no logic edits are required.
The four arms already do the right thing once the rows are empty, because each
arm already walks the body and then appends the (now-empty) builtin effects:
Spawnarm (line ~1527):collect_effects_from_expression(expression.operand, imports)collects the spawned body’s effects, then_append_builtin_effects(required, "spawn_task", …)appends[]. Net: the operand’s effects, and nothing else.Parallelarm (line ~1542): merges everyexpression.tasks[i]‘s effects, then appends thespawn_task[]. Net: the union of the task bodies’ effects.- Channel
send/receiveMember arm (line ~1343): appends thechannel_send/channel_receive[]; the argument walk in the enclosingCallbranch (line ~1368) already propagates any effects in the argument expression (e.g.ch.send(fs.read(p)?)still requiresiofromfs.read). spawnCall-form / member-call arm (line ~1289): unchanged; the callee-effect recursion and_propagate_imported_callee_effectsalready carry the body’s effects.
This is the whole point of the #1655 refactor: because the arms derive their
requirement from the registry via _append_builtin_effects, flipping the rows
to [] is sufficient and the “rows and arms agree by construction” invariant is
preserved with zero arm edits.
Decision — keep the _append_builtin_effects(…, "spawn_task"/"channel_*", …)
calls in place (appending []), do not delete them; only update the
comments citing #1655/#1702 to describe the effect-transparent end state. Deleting
them would re-hardcode into the arm what #1655 deliberately made registry-driven:
the linkage is the mechanism, so that if a real scheduler-level effect ever lands
on the spawn_task row, the arm picks it up mechanically with no arm edit. The
no-op append is the correct architectural state, not dead weight to prune.
3.3 Worked behavior
// Pure body → no effect required.fn fan_pure() -> int { // no ![…] needed after this SFEP let r = parallel [ fn () -> int { return 1; }, fn () -> int { return 2; } ]; return r[0] + r[1];}
// io body → io propagates from the body, not from the primitive.fn fan_io() ![io] { // ![io] comes from print, correctly spawn fn () ![io] { print.info("hi"); };}
// channel over pure element → no effect; effect comes only from the argument.fn send_pure(ch: Channel<int>) { ch.send(42); } // purefn send_io(ch: Channel<int>, p: string) ![io] { // io from fs.read ch.send(read_len(fs.read(p)?));}3.4 Callee-resolution subtlety (must be stated precisely)
“Pure body ⇒ no effect” is exact for the cases the checker can see the body,
and conservative — falling back to the import table, never a phantom io — for
the cases it cannot:
- Inline lambda body (
spawn fn () ![io] { … },parallel [ fn()->T {…} ]): fully handled.collect_effects_from_expressionon aLambdarecurses intocollect_effects_from_block(body, …), so the body’s effects propagate exactly. - Intra-module named target (
spawn worker()whereworkeris a top-levelfnin the same module): handled. The per-program localized table (_merge_local_functions/localize_import_symbols_for_program) carries the module’s own top-level functions asorigin == "local", and_propagate_imported_callee_effectspropagates their declared effects (as the in-moduleE0400family). - Imported named target (
spawn some_mod.fn()/spawn imported_fn()): handled to the extent the import table carries the callee’s declared effects — the same resolutionserve-of-an-imported-function already uses (SFN-29 / #1329), renderingE0402cross-module. - Unresolved / genuinely cross-module bare target the localized table does
not contain: the checker sees no effect for it and therefore attributes
nothing — it falls back to whatever the import table carries, not a
phantom
io. General type-directed cross-module callee resolution is #1184 and is scoped OUT of this SFEP. The consequence is precisely stated: aspawnof an unresolved callee under-attributes (contributes nothing) rather than the old behavior of over-attributing a fixedio. This is the correct conservative direction for an additive, honesty-improving change — it never invents a capability the code does not visibly use — and it is exactly the behavior #1184 will tighten when it lands full callee resolution. The existing_make_spawn_routinetest fixture (aspawn worker()over an unresolvedworkerwith an empty import table) is the canonical witness of this case and is retargeted accordingly (see §8).
This trade — exact for visible bodies, “inherit the import table, never phantom
io” for invisible ones — is the load-bearing decision of the SFEP.
Soundness note. This aligns spawn/parallel/channel-op attribution with the
checker’s existing cross-module resolution limit (#1184): a direct call
worker() to an unresolved cross-module effectful worker already under-attributes
today, and after this SFEP spawn worker() behaves identically. The change
therefore neither opens nor closes a soundness gap beyond the one direct calls
already have — it removes a coincidental over-approximation on one construct and
makes the two paths consistent; #1184 tightens both together. The blanket io was
never a principled fail-closed guard (it covered only the spawn path, not direct
calls), so keeping it would cement an inconsistent, false requirement rather than
buy real safety.
4. Effect & capability impact
This SFEP is an effect-system change; that is its entire subject.
- No new effect. The canonical six
[clock, gpu, io, model, net, rand]are locked at 1.0 (effect_taxonomy.sfnlines 29–31, SFEP-0008). A dedicatedconc/ concurrency effect is rejected — see §6. - Direction of the change is strictly narrowing attribution. After this
SFEP,
spawn/parallel/channel-op over a pure body requires fewer effects (none) than before (io). It never adds a requirement. Over-declaration is not an error in Sailfin — the checker enforces that used effects are declared (under-declaration is the error), not that declared effects are used — so functions that keep an independentiosource (e.g.print) and still declare![io]remain valid. The only user-visible change is that some functions that previously had to declare![io]no longer do. excess_effects(effect_checker.sfnline 130) tracks effects declared outside the capsule capability surface, not effects declared-but-unused, so this change does not newly trip it.- Capability enforcement is unchanged in kind. A body that genuinely does
I/O inside a spawned closure still surfaces
io(via the body walk); the capsule manifest gate is untouched. The primitive stops fabricating a capability requirement; it does not stop enforcing real ones.
5. Self-hosting impact
- Passes touched: effect checker (
effect_checker.sfn) and the runtime descriptor registry (llvm/runtime_helpers.sfn) only. No lexer, parser, AST, native-emitter, or LLVM-lowering change — the primitives already parse, type-check, emit, and lower; only the effect attribution of the registry rows changes.spawn/parallel/channel ops are ordinary constructs the current seed already compiles. - Additive / self-host-safe. The change relaxes an effect requirement. The
compiler tree self-hosts today under the stricter placeholder (
ioeverywhere), so it self-hosts a fortiori under the relaxed rule — no compiler-source function loses a declaration it still needs (anyiothe compiler’s own concurrency use requires comes from real I/O in the body, which still surfaces).make compilefrom the pinned seed builds the new checker, and that checker re-checks the whole tree in the same pass. - Seed-cut discipline (
.claude/rules/seed-dependency.md). This is a compiler-source capability (an effect-checker/registry behavior change). Its only consumers are the effect-checker regression tests and any user/example code — all worked in the same session. Bundle the registry+checker change with its retargeted tests in one PR.make compilebuilds the new compiler from the old seed; that compiler compiles the retargeted tests in the same self-host pass, so no seed cut and no/pin-seedis required. Nothing downstream needs this behavior present in the pinned seed before it can build. - Invariant preserved. The #1655 “rows and arms agree by construction” contract is the mechanism that makes this a one-line-per-row change instead of four scattered edits — it is strengthened, not bypassed.
6. Alternatives considered
- Do nothing (keep the
ioplaceholder). Rejected: it keeps the flagship checker lying about a pure fan-out. #1655 always framed theioas a temporary placeholder pending the frontend-typing gate that has now closed; leaving it in permanently cements a documented falsehood in the headline pillar. - A dedicated
conc(concurrency) effect. Rejected on two independent grounds. (1) The canonical six effects are locked at 1.0 (effect_taxonomy. sfn29–31, SFEP-0008); a seventh effect breaks the lock and needs its own RFC. (2) It is semantically wrong:spawnis a scheduling mechanism, not a capability. A pure computation fanned across threads accesses no resource the single-threaded version does not; there is no capability to gate. Effects gate access to the outside world (io/net/clock/…), and concurrency touches none of it. Go agrees — goroutine spawn is effect-free. - Keep
ioon channel ops only (on the theory that a channel is “I/O-ish”). Rejected: a v0 channel is a pure in-process pthread MPMC queue (runtime/sfn/concurrency/channel.sfn) — mutex/condvar over a heap ring buffer. It touches no fd, no fs, no socket. Its effect is exactly the effect of the value flowing through it, which the argument walk already captures. - Resolve all callees first (fold #1184 into this SFEP). Rejected as
scope-inflating: full type-directed cross-module callee resolution is a large,
separate lift (#1184). This SFEP is correct and shippable with the conservative
“inherit the import table, never phantom
io” fallback for unresolved callees, and it composes cleanly with #1184 when that lands (the fallback simply narrows further). Bundling would gate a small honesty fix behind a large resolution project. - Fail-closed on unresolved callees (keep
ioonly when the spawn target cannot be resolved). Rejected: it reintroduces the phantomiofor an arbitrary subset and makesspawn worker()requireiowhile the direct callworker()requires nothing — an inconsistency that itself teaches “spawn is I/O-ish.” It buys no real soundness, because the identical under-resolution already exists on the direct-call path (§3.4 soundness note); #1184 is the principled closure for both. The transparent fallback keeps the two paths consistent. - Ship the join half (
await/routine) in the same SFEP. Rejected: join- side effect semantics are entangled with cancel-on-fault and future-result typing (the concurrency-cancellation SFEP / SFN-124). Splitting the leaf half lets the honest-attribution win land now without waiting on that redesign.
7. Stage1 readiness mapping
- Parses — no new syntax;
spawn/parallel/channel/.send/.receivealready parse into their AST nodes/Member calls. - Type-checks / effect-checks — the substance of this SFEP: the four arms
attribute the body’s effects and no phantom
io. Verified by the retargetedeffect_detection_test.sfn/effect_checker_test.sfn/concurrency_effect_test.sfnassertions (§8). - Emits valid
.sfn-asm— unchanged; the emitter/lowering for the primitives is untouched, only their effect metadata changes. - Lowers to LLVM IR — unchanged;
sfn_spawn_task/sfn_channel_send/sfn_channel_recvsymbols and their call lowering are unaffected. - Regression coverage — three effect test files retargeted, each with a
positive (io-body/io-arg propagates →
E0400) and negative (pure op requires nothing) case per primitive (§8). - Self-hosts —
make compilefrom the pinned seed passes; the change is a strict relaxation, so the tree self-hosts a fortiori. -
sfn fmt --checkclean — every touched.sfn(runtime_helpers.sfn,effect_checker.sfn, the three effect tests) formatted. - Documented in
docs/status.md+ spec — the concurrency rows/effect notes indocs/status.mdand the previewconcurrency.mdchapter flipped from the![io]-placeholder wording to the effect-transparent description.
8. Test plan
Gates (specified, not run here): make compile (self-host invariant for the
compiler/src change) then make check (full triple-pass + suite). Inner loop:
sfn check compiler/src/effect_checker.sfn compiler/src/llvm/runtime_helpers.sfn
and build/bin/sfn test compiler/tests/unit/effect_detection_test.sfn /
effect_checker_test.sfn.
Retarget compiler/tests/unit/effect_detection_test.sfn — the current
assertions bake in the phantom io:
"detect: spawn(...) -> io (Spawn node, real source)"(line ~140): change from_assert_detects("spawn(task)", "io")to assert the Spawn variant still routes (_probe_expr(...).variant == "Spawn") but the pure-body/bare-identifier spawn attributes no effect (add an_assert_no_detecthelper, or assert the requirement set is empty). Add a paired positive: a spawn of an inline io lambda (spawn fn () ![io] { print("x"); }) does attributeio."detect: spawn(...) -> io (defensive Call+Identifier arm)"(line ~149): retarget so the barespawnCall-arm over no effectful argument attributes nothing."detect: parallel [...] -> io (Parallel node, registry-driven, #1655)"(line ~157): keep thevariant == "Parallel"routing assertion; change the effect assertion soparallel [task]over a pure task attributes nothing, and add a positive over an io task body that attributesio."detect: channel .send(...) -> io"/"detect: channel .receive() -> io"(lines ~168/174): change to assert a purech.send(v)/ch.receive()attributes nothing, and add a positivech.send(fs.read(p)?)(or an io-typed argument) that attributesiovia the argument walk.
Retarget compiler/tests/unit/effect_checker_test.sfn:
"effect violation: spawn prefix requires io"(line ~237) — flips: with_make_spawn_routine("launcher", [], …)spawning an unresolvedworker()under an empty import table, the enclosing fn now type-checks clean (no effect required). Rewrite as"spawn of an unresolved/pure body requires no effect"asserting no diagnostic. This is the canonical witness of the §3.4 “never phantomio” fallback."effect violation: spawn declaring io type-checks clean"(line ~247) — stays clean (over-declaration remains legal); keep as a non-regression guard.- Add a positive counterpart: a
_make_spawn_routine-style helper whose spawned body is an inline lambda callingprint(or a localfndeclaring![io]), declared with[]→ must raise the missing-iodiagnostic (E0400), proving io-body propagation still enforces. "effect violation: bare spawn() Call carets at the callee identifier"(line ~607) and the span/caret tests (line ~836): audit — any assertion that depends onspawnitself producing the requirement must move the requirement onto the body (the caret/trigger tests can retarget to an io-body spawn so a diagnostic still exists to caret).
Blast-radius audit — examples/concurrency/*.sfn (verified by reading each):
all six retain an independent io source, so none breaks:
| file | primitive over | independent io source | breaks? |
|---|---|---|---|
channels.sfn |
messages.send(42) / .receive() (pure int) |
print("Received…") |
no |
dynamic-task-scheduling.sfn |
tasks.send(fn(){print…}), await …receive() |
send-body print and later print |
no |
parallel.sfn |
parallel [pure closures] |
print.info("Total…") |
no |
producer-consumer.sfn |
buffer.send(i) / await …receive() |
print("Producing/Consumed…") |
no |
routine-spawn-await.sfn |
spawn fn()->int { 40+2 } (pure) |
print("Result…") |
no |
routines.sfn |
routine { print(…) } (join-side, OUT) |
print |
no |
parallel.sfn (pure task bodies) and routine-spawn-await.sfn (pure spawn body)
are the two witnesses where the primitive stops contributing io yet the file
still needs ![io] from its print — exactly the intended narrowing, and proof
the change is safe against the shipped examples. (No file requires io solely
from a pure spawn/parallel/channel op, which is what would have forced an edit.)
9. References
- SFN-86 / #1702 — the tracking issue; this SFEP is its leaf half.
- #1655 — the registry-as-single-source-of-truth unification that installed
the
ioplaceholder rows and the “rows and arms agree by construction” invariant this SFEP flips and preserves. - #1184 — cross-module type-directed callee resolution; the predecessor that will tighten the §3.4 unresolved-callee fallback (scoped OUT here).
- #829 — frontend concurrency typing soft gate (now effectively closed by
typed
Channel<T>/ SFN-111 and live await-result typing / SFN-113), which is what unblocks doing this now. - SFN-124 /
docs/proposals/draft-concurrency-cancellation.md— the concurrency-maturity SFEP; the home for the join-side effect semantics (await/ nursery-exit) deferred by this SFEP. - SFEP-0008 (
0008-effect-validation.md) — effect validation as a build gate; the taxonomy lock that rejects a dedicatedconceffect. - SFEP-0017 (
0017-hierarchical-effects.md) — the subsumption model this attribution feeds into. compiler/src/llvm/runtime_helpers.sfn(~1118–1161, 1685) — the descriptor rows andeffects_for_callee_target.compiler/src/effect_checker.sfn(~1289, 1334–1360, 1527–1558, 1261) — the spawn/parallel/channel arms and_append_builtin_effects.compiler/src/effect_taxonomy.sfn(29–31) — the canonical-six lock.runtime/sfn/concurrency/{channel,nursery,parallel,scheduler}.sfn— the pure pthread primitives that are the factual basis for “the primitive contributes no effect.”compiler/tests/unit/{effect_detection_test,effect_checker_test}.sfn— the retarget surface.