SFEP-0065
sfn/sync — public API surface and semantics
- Status
- Draft
- Type
- language
- Created
- Updated
- Author
- agent:compiler-architect; human review
SFEP-0065 — sfn/sync: public API surface and semantics
Correction (2026-08-02). A structural review found four gaps this draft had understated or left as documentation rather than enforcement: §3.8’s lifetime contract has no mechanism stopping a handle from escaping its owning scope (C2); §3.6’s error-checking-mutex
SelfDeadlockguarantee has no Windows implementation and Windows has no primitive that can provide one (C3);mutex_create()called from aspawn-nested worker sees no current nursery because the pointer is thread-local and not inherited, which §3.8 does not reconcile with this section’s own open question (C4); and §3.9’swith_cell<T>claim that unsynchronized access is “unrepresentable” does not hold for aTthat is itself a pointer, handle, or aliasing-capable aggregate (C5). Each is recorded as a prerequisite below, with explicit acceptance criteria, rather than folded silently into the affected section. The consequence for phasing: Phase 1 (Once) is close to ready; Phase 2 (Mutex) is not ready, and §3.10’s phase table is revised accordingly.
1. Summary
SFEP-0063 established that synchronization objects are already buildable in
Sailfin — an ordinary struct-typed handle is not owned/affine, so the
ownership checker’s spawn-capture move rule never fires and two spawn
closures can share one today — and that the single remaining blocker is
reclamation: nothing invokes a stored destructor when a shared object dies.
This proposal designs the other half: what sfn/sync exports, and with what
semantics, given a language with no defer, no RAII, and no user-facing drop
hook.
The core decision is that sfn/sync exports no unlock operation at all.
Mutual exclusion is offered exclusively through scoped closure combinators —
with_lock(m, body), with_read_lock, with_permit — which acquire, run the
body inside a try/catch, release on both the normal and the throwing path,
and re-throw. This is not a stylistic preference: with no defer, a public
unlock() is unguarded against early return and throw, and a leaked lock
is a permanent deadlock — the worst failure mode a synchronization library can
ship. The combinator form makes the leak structurally unrepresentable, and
Sailfin already has the exact precedent in production
(capsules/sfn/test/src/fixtures.sfn:55, with_tmp_dir).
Two further decisions follow from what the language actually provides today.
The capsule exposes no atomics wrapper — atomic_load/atomic_store/
atomic_add/atomic_sub/atomic_cas/atomic_fence are already
user-reachable language builtins lowering to real seq_cst LLVM atomics
(compiler/src/llvm/atomics.sfn:1-30), and a capsule export bearing one of
those names would be silently unreachable because is_atomic_builtin
short-circuits ahead of call resolution. And Once ships first, ahead of
everything else, because it holds no OS resource and therefore needs no
teardown.
Gating. Everything from Mutex onward is gated on the reclamation seam —
the generic (handle, destructor) nursery registry and rc.sfn’s drop_fn
invocation — designed in the sibling proposal now in flight (SFEP-0064). This
document deliberately does not design that seam, design around it, or
duplicate its decisions. It depends only on the seam’s existence, not its
shape. The one place where the shape leaks into the API surface (whether a
routine { } scope can be probed for absence at creation time) is recorded in
§3.8 as an open question rather than guessed.
This gate is now purely a landing-order dependency, not a seed-cut
one: SFEP-0064’s rewrite establishes that the indirect-call capability the
seam needs already ships in the pinned seed, so Mutex and everything after
it need only wait for the seam’s runtime-source PR to merge, not for a
cadence seed bump to be pinned. See §5.
2. Motivation
SFEP-0063 Phase 0 made stdlib/sync/ honest — an empty reserved shell
with [capabilities] required = [] and a comment pointing at the reason. That
correction removed a false advertisement but left a real question unanswered:
when the reclamation gap closes, what should this capsule actually be?
Answering that now, in parallel with the seam, is worth doing for three reasons.
The API surface is independent of the seam’s shape. The seam decides how a
destructor travels with a handle inside the runtime. Nothing about that choice
changes whether the user writes with_lock(m, body) or m.lock(). Settling
the surface in parallel converts a serial two-proposal chain into one, and
means the session that lands the seam can be followed immediately by a session
that lands Mutex against an already-reviewed design.
The hard constraint is a language property, not a runtime one, and it will
not be relieved by the seam. Sailfin has no defer, no RAII, and no
user-facing Drop hook. A design that assumes scope-exit cleanup — the shape
every mainstream systems language uses for locks — is not merely inconvenient
here, it is unimplementable, and will stay unimplementable after the seam
lands. Discovering that during implementation would produce exactly the
API-shaped-like-Rust-but-unenforceable outcome that SFEP-0063 §6 already
rejected once.
Part of the surface is not gated at all and can ship immediately. §3.4
shows Once is buildable today against shipped builtins, and §3.5 shows the
atomics question resolves to “export nothing.” That is a full session’s worth
of shippable, ungated work that the current framing (“the whole capsule waits
on reclamation”) would have left on the floor.
There is also a live correctness hazard the design must dodge. SFEP-0063 §3.5
records that import { Channel, channel } from "sync" appears in two examples
and in the spec’s canonical import example, resolving only because
Channel/channel are compiler-special-cased builtins — sfn check performs
no import validation. Any name this capsule exports that collides with a
compiler-special-cased or builtin name inherits that ambiguity. §3.5 turns that
hazard into a hard naming rule.
3. Design
3.1 Shape of a handle, and why sharing needs no new machinery
Every object in this capsule is an opaque handle struct wrapping a heap
address, following runtime/sfn/concurrency/channel.sfn’s pointer-as-address
convention:
// Opaque handle. The address points at a heap block holding the// PthreadMutex storage from runtime/sfn/platform/pthread_layout.sfn.// Copying the handle copies the address, not the lock.struct Mutex { addr: i64;}Three properties fall out of this shape, and all three are load-bearing.
Sharing already works, with zero diagnostics. Mutex is an ordinary
struct, so is_owned_type (compiler/capsules/analyzer/src/typecheck_types/expr_type_rules.sfn:232-239)
is false for it — it matches only OwnedBuf, OwnedBuf<, Affine<, and
Linear<. _consume_identifier therefore returns immediately at its
if !binding.is_owned gate (compiler/capsules/analyzer/src/ownership_checker.sfn:373), and two
spawn closures capturing the same Mutex compile with no move recorded. This
proposal introduces no carrier type and requests no ownership-checker
exemption; SFEP-0063 §3.1 settled that there is no sharing problem to solve.
Copying the handle is the sharing mechanism, and it is copy-cheap. A
capture copies eight bytes; the pointee is shared. This is exactly how
sfn_channel_* already operates across worker threads.
The struct is opaque by convention, not by enforcement. Sailfin has no
private-field mechanism, so a caller can read m.addr. The capsule documents
the field as internal and does not promise its stability. This is the same
posture the runtime already takes with its handle structs, and it is not worth
a language feature.
3.2 The lock API: scoped combinators, and no public unlock
Decision: the only way to hold a lock is to run a closure under it. There is
no exported lock, unlock, acquire, or release, in any phase.
// Run `body` with `m` held. The lock is released before this function// returns, on both the normal and the throwing path.fn with_lock(m: Mutex, body: fn () -> int) -> int { // acquire (module-private) try { let result = body(); // release return result; } catch (e) { // release, then propagate the caller's error unchanged throw e; }}The precedent is direct and already in production:
capsules/sfn/test/src/fixtures.sfn:55’s with_tmp_dir(body: fn (string) -> int) -> int ![io]
uses precisely this acquire / try / cleanup-on-both-paths / re-throw shape to
guarantee a temp directory is removed even when the body throws. The mechanism
is proven; this proposal applies it to a lock.
Why this closes every leak vector. Three control-flow escapes can strand a lock, and the closure boundary handles each by a different mechanism:
| Escape | Why it cannot leak the lock |
|---|---|
Early return in the body |
Returns from the closure, not the enclosing function. Control lands on with_lock’s release path. |
break / continue targeting an enclosing loop |
The body is lifted into its own function at the closure boundary, so a break/continue inside it cannot transfer control past with_lock’s frame into a loop the closure does not lexically enclose — the release path still runs. No diagnostic rejecting the syntax was found during this review; whether the compiler diagnoses it or simply cannot reach the target loop (making the question moot) is unverified and worth a separate probe. |
throw from the body |
Caught by the combinator’s catch, released, then re-thrown unchanged. |
The first is a structural property of the closure boundary and costs nothing to guarantee. The second’s safety conclusion holds for the same structural reason — lambda lifting, not a compile-time rejection — even though the exact diagnosed-vs-silently-inapplicable behavior at the language boundary is unverified. Only the third needs code, and it is four lines.
What the body cannot do. Because effects and control flow are contained,
the body also cannot return a value out of the enclosing function — it must
thread it back through with_lock’s return value. That is a real ergonomic
cost, paid deliberately. It is the same cost with_tmp_dir already imposes and
that sfn/test accepted.
Rejected: explicit lock() / unlock(). Every leak vector above becomes
live. A user who writes lock(m); if bad { return -1; } unlock(m); has written
a deadlock that no pass in the compiler will diagnose — no borrow checker, no
linear typing on the handle (§3.1: the handle is deliberately not affine), no
defer. The failure is not a crash but a hang, the least debuggable outcome
available, and it will most often manifest under contention in CI rather than
locally. Shipping the raw pair and documenting the discipline is exactly the
“parsed but not enforced” posture CLAUDE.md forbids marketing, transposed to a
library. Raw acquire/release remain as module-private helpers implementing the
combinators; they are not exported.
Rejected: a routine-integrated form (e.g. routine holding m { ... } or
registering a lock with the nursery for scope-exit release). This loses on
three counts. It is new syntax, so it violates “libraries over keywords” and
requires a compiler change plus a seed cut for what a library function does
today. Nursery scope is the wrong granularity — a nursery exists to bound the
lifetime of spawned children, and a lock held for the whole nursery scope
serializes precisely the workers it was introduced to coordinate. And it
conflates two distinct roles the nursery would then play for the same object:
reclamation owner (correct, per SFEP-0063 §3.6) and mutual-exclusion scope
(wrong). Keeping the nursery purely as the reclamation owner keeps the seam
general.
Non-blocking probe. Contention is an expected outcome, not a failure, so it gets its own combinator rather than an error variant on the primary one:
// Attempt to acquire without blocking. On success, runs `body` under the// lock and returns Ok(<body result>). On contention, does not run `body`// and returns Err(SyncError.Busy).fn with_try_lock(m: Mutex, body: fn () -> int) -> Result<int, SyncError>3.3 Mutex<T> or bare Mutex
Decision: bare Mutex — a standalone lock the programmer pairs with the data
it guards by convention. Mutex<T> is deferred, and §3.9 records the
value-cell form as its intended successor.
The data-owning Mutex<T> is the modern default and is genuinely safer, so
this deserves an honest accounting rather than a shrug at generics.
Generics are not the blocker. Generic structs and generic functions parse,
typecheck, and monomorphize — compiler/tests/e2e/fixtures/mono_box_int.sfn,
mono_pair_struct.sfn, and compiler/tests/unit/monomorphize_generic_fn_test.sfn
all exercise struct Box<T> / .fn id<T> end to end, and Result<T, E> is a
generic prelude enum (runtime/prelude.sfn:162) already consumed by shipped
capsule code (stdlib/time/src/mod.sfn:34). struct Mutex<T> would
compile.
The blocker is that the safety property is unenforceable. The entire value
of Mutex<T> is that unsynchronized access is unrepresentable — the only
route to the T is a guard, and the guard cannot outlive the critical section.
Sailfin has no borrow checker (SFEP-0018 is unshipped), no reference type with
a lifetime, and no escape analysis. A with_lock(m, fn (guard: T) -> int { ... })
that hands the payload to the body cannot prevent the body from storing it in a
module global and reading it after release. Mutex<T> would therefore deliver
the shape of the guarantee with none of the enforcement — a stronger-looking
API that is exactly as safe as the bare lock, which is worse than the bare lock
because it invites reliance. That is the “parsed but not enforced” failure
transposed to a type.
A secondary, weaker consideration worth recording: no production .sfn
under compiler/src/, runtime/, or capsules/ currently declares a generic
struct or generic function — the coverage is entirely in test fixtures, and
capsules/sfn/test/src/expect.sfn:17-19 records a shipped capsule backing away
from generics because “generic monomorphization + where clauses” did not meet
its needs. That is a maturity signal, not a proof of impossibility, and it
argues for proving generics in this capsule’s simplest surface first (§3.9)
rather than in its foundational type.
3.4 Once — the ungated phase
Decision: Once ships first, before the seam, because it holds no OS
resource.
The distinction that matters is not “does it allocate” but “does freeing it
require running a destructor.” A Mutex owns a pthread_mutex_t: free-ing
it without pthread_mutex_destroy is unsound, and destroying it while another
thread holds it is unsound, so it genuinely needs the seam. A Once owns
two i64 slots — sixteen bytes, not one word — because once_do (below)
stores the state word at addr and the winner’s result at addr + 8; a single
i64 allocation would make that second store an out-of-bounds write. Leaking
the sixteen bytes costs the process lifetime and loses nothing.
// Opaque handle to a process-lifetime once-cell. Deliberately never freed:// it owns no OS resource, so the reclamation seam is not required.// `once_create` allocates 16 bytes: state word at `addr`, result word at// `addr + 8`.struct Once { addr: i64;}
fn once_create() -> Result<Once, SyncError>
// Run `init` exactly once across all callers sharing `o`. Concurrent// callers that lose the race block until the winner's `init` returns,// then return the winner's value. Returns `init`'s result.fn once_do(o: Once, init: fn () -> int) -> intIt is buildable today. atomic_cas(ptr, expected, new) -> boolean is a
shipped builtin lowering to cmpxchg with seq_cst ordering
(compiler/src/llvm/atomics.sfn:20,26-30). The winner is the caller whose
atomic_cas(state, 0, 1) succeeds; it runs init, stores the result, then
atomic_store(state, 2). Losers spin on atomic_load(state) until it reads 2.
No mutex, no condvar, no destructor.
A design bug in that protocol, and its fix. If init throws, the winner’s
throw unwinds via sfn_throw_frame → longjmp
(compiler/src/llvm/lowering/instructions_try.sfn:199-213) straight out of
once_do, and state is stranded at 1 — the winner’s transition to 2
never runs. Every other caller, including any future retry by the same caller,
then spins on atomic_load(state) forever, at full CPU, with no diagnostic.
That is exactly the undebuggable hang §3.2’s combinator design exists to
prevent, and it would ship in the one phase billed as gate-free. The fix:
// States: 0 = untried, 1 = running, 2 = done, 3 = failed (poisoned).fn once_do(o: Once, init: fn () -> int) -> int { if atomic_cas(o.addr as *i64, 0, 1) { try { let result = init(); atomic_store((o.addr + 8) as *i64, result); atomic_store(o.addr as *i64, 2); return result; } catch (e) { atomic_store(o.addr as *i64, 3); throw e; } } // Backoff wait. No iteration cap: see the note below on why a finite // one is unsound here. let mut backoff_ns: int = _once_backoff_floor_ns(); loop { let state = atomic_load(o.addr as *i64); if state == 2 { return atomic_load((o.addr + 8) as *i64); } if state == 3 { throw SyncError.InitFailed(0); } _once_backoff_sleep(backoff_ns); backoff_ns = _once_backoff_next(backoff_ns); }}_once_backoff_sleep is a short nanosleep — runtime/sfn/platform/posix.sfn
already declares that extern and runtime/sfn/clock.sfn already consumes it,
so no new primitive is needed. The backoff climbs from a sub-microsecond floor
to a bounded ceiling, so a fast initializer is not penalised and a slow one
does not burn a core. (POSIX sched_yield would be a marginally better first
step than a minimum-duration sleep, but it is not currently declared anywhere
in runtime/sfn/platform/, and adding it is not worth making this phase
depend on.)
A losing caller that observes state 3 throws rather than spinning forever —
the same init failure the winner saw, not a fresh attempt (this is a
single-shot Once, not a retryable one; retry-on-failure is a different,
unproposed primitive).
Why the wait carries no iteration cap, deliberately. An earlier draft of
this section put a fixed ONCE_SPIN_GUARD_LIMIT on the loop, citing the
code-style rule “Guard counters on input-driven loops”
(.claude/rules/code-style.md). That was a misapplication of the rule and
the cap is removed. The rule targets loops whose trip count is driven by
untrusted input, where an unbounded loop is a denial-of-service vector. This
loop is a blocking wait on another thread’s progress, and its trip count is
governed by scheduling, not by input. A finite cap there is not a safety net —
it is a correctness bug: the cap can expire while the winning initializer is
merely preempted or legitimately slow, so losers throw InitFailed for an
initialization that subsequently succeeds. No “generous” constant fixes this,
because there is no finite number of iterations that a preempted thread cannot
exceed; choosing one only trades a certain bug for a rare, load-dependent, and
almost undebuggable one. Blocking semantics and a finite spin cap are mutually
exclusive, so the cap goes and the wait blocks as advertised. Anyone tempted to
re-add it on the strength of the code-style rule should read this paragraph
first.
If a bounded wait is ever wanted, it belongs in a separate, explicitly timed
entry point (once_do_timeout(o, init, deadline)) that returns a distinct
timeout outcome — not as a hidden cap on the untimed one.
Three further consequences to record honestly:
- Losers busy-wait. For one-time initialization — the only thing
Onceis for — the wait is bounded by the initializer and typically microseconds. A futex/condvar-backed park is a later refinement and would moveOncebehind the seam, so it is explicitly not part of the first phase. - The cell is heap-allocated because a module-level
let mutcannot yield a*int. Saying Sailfin “has no address-of operator” would overstate it:&T/&mut Tparse and lower (compiler/src/llvm/expression_lowering/native/core_borrow_lowering.sfn), thoughdocs/status.md:687records them as parsed-only with exclusivity unchecked. The narrower, correct claim is the one this design actually needs: there is no way to take the address of a module-level binding and get a*intout of it, so the runtime idiom is uniformlymalloc+ cast (runtime/sfn/memory/rc.sfn:119,133).once_createallocates for that reason. This is a decided point, not an open question, and it does not change the phase ordering. - The intentional leak must be declared to leak-checking test legs. An
ASAN/LSAN run will see the eight bytes. The
Oncephase must either suppress it explicitly or route the allocation through an allocator the leg already excludes; whichever it does, the reason belongs in a comment naming this section, perdocs/conventions/sanitizer-tests.md.
This is an explicit override of SFEP-0063’s verdict, not a fresh finding.
SFEP-0063 §3.2’s verdict table (docs/proposals/0063-sync-capsule.md:154)
lists Once as Buildable, unsafe to reclaim, gated on reclamation like every
other row in that table. The merits favor shipping it ahead of the seam
anyway — two i64 words and no OS resource, so the destructor question
SFEP-0063 gated on is vacuous for this type — but that is a deliberate override of an
Accepted proposal’s row, and it is recorded here as exactly that rather than
presented as though SFEP-0063 already said so.
3.5 Atomics — export nothing
Decision: the capsule exposes no atomics wrapper. It documents the builtins and stops.
Six atomic builtins already ship and are reachable from any Sailfin source:
atomic_load, atomic_store, atomic_add, atomic_sub, atomic_cas, and
atomic_fence. They lower directly to LLVM atomic instructions with seq_cst
ordering rather than to runtime helper calls, and their type contract
(*int/int/boolean) is already enforced with diagnostic E0806
(compiler/src/llvm/atomics.sfn:15-23). atomic_add/atomic_sub return the
old value; atomic_cas returns the success bit.
A capsule wrapper over these would be redundant at best, and at worst introduces two concrete hazards:
- A same-named export would be silently unreachable.
is_atomic_builtinshort-circuits before the runtime-helper registry is consulted (compiler/src/llvm/atomics.sfn:49-62, dispatched fromcore_call_lowering.sfn), sofn atomic_add(...)exported fromsfn/syncwould never be called even by a module that imported it explicitly. That is the SFEP-0063 §3.5Channelshadowing hazard exactly, reproduced deliberately. - A differently-named wrapper is a real function call where the builtin is a single inlined instruction, and it would fork the vocabulary: two spellings for the same operation, one of which is slower.
The naming rule this generalizes to, binding on every phase: sfn/sync
must never export a name that is a compiler builtin or a compiler-special-cased
identifier. Concretely this forbids the six atomic names above and the
Channel/channel pair named in SFEP-0063 §3.5. Because this proposal exports
no Channel, the three decorative import { Channel, channel } from "sync"
sites that SFEP-0063 §3.5 flags do not change meaning under any phase here —
that hazard is dodged rather than managed, and no phase needs to sequence
around them.
What the capsule may add later, without collision, is a composed helper
that is not one-to-one with a builtin — a saturating counter, or a bounded
spin_until(cell, value, max_iterations) with the guard counter the code-style
rule requires on input-driven loops. None of that is in the shipped scope here.
This, too, is an explicit override of SFEP-0063’s verdict. SFEP-0063
§3.2’s verdict table (docs/proposals/0063-sync-capsule.md:155) lists Atomics
as Buildable, unsafe to reclaim, the same reclamation gate as every other
row. This section effectively overrides that row by exporting nothing at all
— there is no reclaimable object here for the gate to apply to, since the
builtins operate on caller-owned memory rather than a capsule-owned handle —
and that override is recorded here rather than left implicit.
3.6 Error and failure semantics
Decision: three tiers, uniform across every type in the capsule.
Construction is fallible and returns Result. malloc can return null;
pthread_mutex_init and pthread_cond_init can fail. This is where the real,
recoverable failure lives, and it gets the Result<T, E> + ? treatment the
style rule mandates for new fallible code.
enum SyncError { OutOfMemory, InitFailed(int), // the errno-style code returned by the pthread call Busy, // try-acquire found the lock held; not a failure NoScope, // created outside any routine { } scope — see §3.8 SelfDeadlock // recursive acquire detected; see "Reentrancy" below}
fn mutex_create() -> Result<Mutex, SyncError>Operations on a validly-constructed object do not return Result.
with_lock(m, body) -> int, not Result<int, SyncError>. The argument is that
these operations are not fallible in the sense the style rule addresses: a
nonzero return from pthread_mutex_lock on a mutex this capsule itself
initialized, with attributes it chose, indicates memory corruption or
use-after-destroy — not a condition a caller can meaningfully handle. Wrapping
it in Result would be actively harmful, because the ergonomic pressure of a
Result on every critical section produces let _ = with_lock(m, body); at
call sites, and a silently skipped critical section is a data race that
looks like working code. Response instead: fail closed — print a diagnostic
naming the mutex address and the pthread return code to stderr, and abort. A
crash with a message beats a race without one. This is the same reasoning that
makes lock().unwrap() idiomatic in Rust, reached independently.
Expected non-success outcomes are discriminated, not raised.
with_try_lock returns Err(SyncError.Busy) for contention. Busy is
enumerated alongside genuine errors for a single error type across the capsule,
but the doc comment must state plainly that it is an outcome, not a fault.
No poisoning. When a body throws, with_lock releases and re-throws; it
does not mark the mutex as poisoned, and the next acquirer sees an ordinary
lock. Three reasons:
- The fault already propagates. The re-throw reaches the enclosing scope,
and under a
routine { }nursery with cancel-on-fault (the concurrency-cancellation work in draft) the fault tears the scope down. A poison flag would be a second, weaker signal about an event the first mechanism already handles. - It costs the ergonomics we just bought. Poisoning is only meaningful if
observed, which forces
Resultonto everylock— the exact tax rejected above. - The prior art has soured on it. Rust’s poisoning is widely treated as a
design mistake and routinely bypassed;
parking_lotomits it.
What the capsule owes users instead is a plain statement of the contract:
a throw across a critical section releases the lock but does not roll back
partial mutations. If a later cancel-on-fault design demands poisoning, it is
strictly additive — a with_lock_checked returning Result alongside the
existing combinator, no breaking change.
Reentrancy. The default pthread mutex is non-recursive, so a nested
with_lock on the same handle self-deadlocks — a hang with no diagnostic,
which is the outcome this whole design exists to prevent. The recommendation is
therefore to initialize with the error-checking mutex type, so the nested
acquire returns EDEADLK and the capsule converts it into a thrown
SyncError.SelfDeadlock naming both acquisition sites. Two implementation
notes: this needs pthread_mutexattr_settype, where channel.sfn currently
passes a null attr; and the type constant is platform-dependent, so it belongs
next to the existing per-target layout constants in
runtime/sfn/platform/pthread_layout.sfn rather than inline. If measurement
shows the error-checking mutex is materially slower on the hot path, the
fallback is default attributes plus a documented non-reentrancy contract — but
the burden of proof sits with the faster-and-hangs option, not the
slower-and-diagnoses one.
Prerequisite (blocking):
SelfDeadlockhas no Windows implementation, and Windows has no primitive that provides one. Verified againstruntime/sfn/platform/pthread_windows.sfn:34,38-51:pthread_mutex_locklowers toAcquireSRWLockExclusive, and the file has nopthread_mutexattr_settypeor any mutex-attribute function at all — the header (line 53-56) records thatSRWLOCKhas no destroy call either, an even more basic gap than attributes.AcquireSRWLockExclusiveis non-recursive and, per the Win32 SRWLOCK contract, a same-thread recursive acquire is undefined behavior that hangs rather than returning an error code — there is no Windows primitive analogous to POSIX’sPTHREAD_MUTEX_ERRORCHECKto detect it. So §3.6 as written either fails to link on Windows (if it calls apthread_mutexattr_settypethat does not exist there) or silently violates theSelfDeadlockguarantee it promises (if it falls back to plainAcquireSRWLockExclusive, where a nestedwith_lockhangs instead of throwing). Phase 2 must pick one of two paths, not leave this implicit:
- A genuine Windows-specific implementation — e.g. tracking the owning thread ID alongside the
SRWLOCKstorage and checking it beforeAcquireSRWLockExclusiveto raiseSyncError.SelfDeadlockin Sailfin code rather than relying on an OS error code — plus a Windows-run test proving a nestedwith_lockthrows rather than hangs on that platform.- An explicit, documented platform limitation:
SelfDeadlockdetection is POSIX-only, andself_deadlock_test.sfn(§8) is marked platform-conditional with the reason recorded next to the test and in this section, rather than silently skipped or silently hanging in CI.This proposal does not pick between them — that is a design decision for whoever implements Phase 2 — but Phase 2 cannot ship claiming
SelfDeadlockas a cross-platform guarantee without choosing one.
3.7 The rest of the surface
RwLock and Semaphore follow the same combinator discipline, with no
exported acquire/release:
fn rwlock_create() -> Result<RwLock, SyncError>fn with_read_lock(l: RwLock, body: fn () -> int) -> intfn with_write_lock(l: RwLock, body: fn () -> int) -> intfn with_try_write_lock(l: RwLock, body: fn () -> int) -> Result<int, SyncError>
fn semaphore_create(permits: int) -> Result<Semaphore, SyncError>fn with_permit(s: Semaphore, body: fn () -> int) -> intfn with_permits(s: Semaphore, n: int, body: fn () -> int) -> intA semaphore’s asymmetric use is deliberately not served. The classic
counting-semaphore pattern — one worker signals, another waits, with no lexical
pairing — cannot be expressed by a scoped combinator. That pattern is
Channel<T>, which ships, and routing users there keeps the capsule
consistent with the CSP grain SFEP-0063 §3.3 asks it to respect. with_permits
covers the legitimate scoped use: bounding concurrency to N inside a nursery.
WaitGroup is not in this surface, per SFEP-0063 §3.2 — routine { }
already joins every child at scope exit. select over channels is out of
scope, per the same section, and blocked on separate fd-bridge plumbing.
Timed variants are a later phase and carry an effect (§4):
fn with_lock_timeout(m: Mutex, timeout_ms: int, body: fn () -> int) -> Result<int, SyncError> ![clock]3.8 Lifetime, and the one open question
Under the reclamation seam, creation registers the handle with the current
routine { } scope — exactly as sfn_channel_create hands each new handle to
sfn_nursery_current() as its final step, so a handle whose init failed is
never registered (SFEP-0063 §3.6). Two API-visible consequences follow.
A sync object created in a routine { } scope dies at that scope’s exit, and
must not be stored anywhere that outlives it. With no borrow checker this is
a documented contract, not an enforced one. It is the strongest candidate for
the capsule’s eventual first diagnostic (§4).
Prerequisite (blocking, verified against source): equivalent escape enforcement, not documentation, is required before Phase 2 reclamation ships. Channels avoid exactly the hang-turned-use-after-free this paragraph describes via
E0838(compiler/capsules/analyzer/src/ownership_checker.sfn:515-538), which raises “channel handle<name>escapes itsroutinenursery” when a channel binding (or an alias of it) is assigned to a destination outside the creating nursery. But_make_nursery_finding’sE0838path is keyed on a compiler-knownhandle_kindstring ("channel","task") — it is wired to specific compiler-special-cased types, not to an arbitrary user/capsule struct. §3.1 is explicit thatMutexis deliberately an ordinary struct precisely so the ownership checker’s affine move rule never fires on it (is_owned_typeis false for it); that same ordinariness meansE0838’s nursery-escape check does not fire on it either. Freeing eachMutexat nursery exit (Phase 2) while an outer or global binding still aliases the handle leaves that binding holding a danglingpthread_mutex_t*with no diagnostic anywhere. This is not solvable by “document it harder” — it needs one of: (a) teaching the ownership checker to recognize capsule-level handle types (a new registration surfaceownership_checker.sfndoes not have today), or (b) a different enforcement point entirely (e.g. a runtime generation check on the handle, catching use-after-reclaim at the pthread call site rather than at compile time). Both are open design questions, not designed here — the acceptance criterion for Phase 2 is that one of them (or an equivalent) exists and is tested, not that the existing documentation paragraph above is deemed sufficient. Negative test required: aMutexcreated inside aroutine { }and stored into a module-global or an outer binding must be caught (statically) or safely detected (dynamically) rather than silently producing a dangling handle.
Creating one outside any routine { } scope should fail closed, returning
Err(SyncError.NoScope) rather than silently allocating an object nothing will
ever reclaim. The cost of this strictness is near zero in practice: a lock is
only useful alongside concurrency, and concurrency is nursery-scoped, so the
scope the check demands is one the program already has.
Open question — depends on the seam’s shape, not decided here. Whether “there is no current scope” is observable at creation time is a property of the generalized registry the sibling proposal defines.
sfn_channel_createregisters unconditionally against whateversfn_nursery_current()returns; it is not established here that a top-level call yields a distinguishable sentinel, nor that the generalized seam intends to expose such a probe. If the seam exposes a scope-presence probe,mutex_createreturnsErr(SyncError.NoScope)at top level. If it does not, the fallback is process-lifetime registration with a documented leak — which is sound (a top-level mutex lives as long as the process anyway) but weaker, and would makeSyncError.NoScopedead. This is the only place in this document where the API cannot be finalized without the seam; it must be resolved when SFEP-0064 is accepted, and it is a one-variant change either way.
Prerequisite (blocking), and a second half of the same open question this section already flags:
mutex_create()called from a spawned worker also sees no current nursery, for a different reason than top-level. Verified against source:_sfn_g_current_nursery(runtime/sfn/concurrency/nursery.sfn:132,137) is athread_localglobal, set onsfn_nursery_enter(line 177) and restored on exit (line 325). A worker spawned bysfn_scheduler_spawnruns on a different OS thread, and nothing in the scheduler copies the parent thread’s_sfn_g_current_nurseryvalue into the worker’s own thread-local slot — this is deliberate: a nursery pointer is not safely shareable across threads without its own synchronization. The consequence is thatmutex_create()called from aspawn-nested closure inside aroutine { }seessfn_nursery_current() == 0on its own thread, identically to the true top-level case this section already discusses — except this call site is not top-level, so treating it asNoScopeis misleading (a nursery genuinely exists, just not visible from this thread) and treating it as process-lifetime-leaked is wrong for the same reason (the object’s natural lifetime is the parent’s nursery, which will eventually reclaim things the worker cannot register into). This is not a second, separate gap — it is the same open question this section already raises, and must be resolved together with it, not separately: whichever answer the seam gives to “is scope absence observable at creation time” also has to say what a spawned worker sees, because a worker’ssfn_nursery_current()is indistinguishable from genuine top-level absence today. Acceptance criteria: either (a) worker-sidemutex_create()is documented as unsupported and returnsErr(SyncError.NoScope)— accepting the same false-positive-shaped result as top-level, with the restriction stated plainly — or (b) the seam design adds a mechanism for a spawned worker to register against its parent’s nursery (an ownership-registration path distinct from the thread-local pointer), in which case that mechanism is designed in SFEP-0064, not guessed here. A test proving whichever answer is chosen (worker-sidemutex_createreturns the documented result, or registers against the correct parent nursery and is reclaimed at the parent’s exit) is required before Phase 2 ships.
3.9 Evolution: the value-cell form
The intended successor to the bare Mutex is not the guard-passing Mutex<T>
rejected in §3.3, but a by-value cell with functional update:
// Future phase. The lock owns the value; the body receives a copy and// returns the replacement, so no reference to the payload can escape.fn with_cell<T>(c: Cell<T>, body: fn (T) -> T) -> voidIt costs a copy per critical section, which is right for small guarded state
and wrong for large aggregates. It is listed here so the bare-Mutex decision
reads as a first step rather than a dead end, and because it is the natural
place to prove generics in production capsule code, being the smallest generic
surface in the capsule.
The “unsynchronized access is unrepresentable” claim above is overclaimed
and must be weakened before with_cell<T> can ship carrying it. Passing T
by value stops the cell’s storage slot from being aliased — the body cannot
get a reference into the cell itself — but it does nothing about a T that is
itself, or contains, an alias to something else. Sailfin has no trait bound
restricting T, so nothing stops Cell<*u8>, Cell<SomeHandle> (an ordinary
struct wrapping a heap address, exactly Mutex’s own shape per §3.1), or a
struct/array T containing such a field. A body of type
fn (T) -> T that receives such a value can copy the pointer/handle out to a
module global and dereference or mutate the pointee after with_cell
releases the lock — the by-value parameter prevented nothing, because the
alias was never inside the copied bytes to begin with, only reachable through
them. Prerequisite before with_cell<T> may claim the unrepresentable-access
property: either (a) restrict T to a recursively value-only type set — no
raw pointer, no handle-shaped struct, no reference, checked transitively
through struct fields and array elements, which needs a trait-bound or
compiler-recognized marker mechanism that does not exist in Sailfin today and
is out of scope to design here — or (b) narrow the claim to a small, explicit
concrete type list (e.g. the scalar/numeric types) rather than an unconstrained
generic T, dropping the word “unrepresentable” for the general case. Until
one of these lands, §3.9’s claim must read as “unsynchronized access to the
cell’s own storage is unrepresentable; unsynchronized access to values
reachable through T is not addressed” — record that weaker, accurate
statement in place of the current claim, and treat the value-only constraint
(or the narrow type list) as required future work, not a detail to fill in at
implementation time.
3.10 Phasing
Each phase is one session’s work, Ready to merged PR, ordered so the least
gated ships first. Only Phase 1 can start before the reclamation seam lands.
Per §5, the seam gate is a landing-order dependency now, not a seed-cut
one — Phase 2 can start the session after the seam’s runtime-source PR merges,
with no cadence-bump wait in between.
Revised per the 2026-08-02 structural review (top-of-document correction): Phase 1 is close to ready; Phase 2 is explicitly NOT ready, and must not start until C1/C2/C3 below are resolved — these are prerequisites, not polish, and none of them is a documentation fix.
| Phase | Contents | Gate |
|---|---|---|
| 1 — Once + the atomics decision | Once, once_create, once_do, SyncError. Capsule docs recording §3.5’s “no atomics wrapper” rule and the builtin names it forbids exporting. Manifest stays required = []. |
Close to ready. Uses only shipped builtins (§5), ungated, no open review finding against it. Workable in parallel with the seam PR. |
2 — Mutex |
Mutex, mutex_create, with_lock, with_try_lock, error-checking mutex type + SelfDeadlock (§3.6), nursery registration + the §3.8 resolution, ASAN reclamation leg. |
NOT ready. Blocked on three unresolved prerequisites, none satisfied by documentation alone: C1 — SFEP-0064’s effect-safe callback materialization (docs/proposals/0064-reclamation-seam.md §4) must land before the seam itself is safe to register a reclaimer through; C2 — equivalent escape enforcement for sync handles, since E0838 does not cover Mutex (§3.8); C3 — a Windows SelfDeadlock implementation or an explicit documented platform limitation (§3.6), since Windows has no error-checking mutex primitive today. Also carries C4’s worker-side-creation resolution, folded into §3.8 above. Waiting on the seam’s runtime-source PR merging is necessary but no longer sufficient. |
3 — RwLock + Semaphore |
RwLock and Semaphore with their combinators (§3.7). |
Seam merged, and Phase 2’s C1/C2/C3 resolved — RwLock/Semaphore share Mutex’s handle shape and reclamation path, so they inherit its open prerequisites rather than only its shipped code. |
| 4 — Timed variants | with_lock_timeout and siblings, ![clock], manifest becomes required = ["clock"] (§4). |
Seam merged, Phase 2 prerequisites resolved. Independent of Phase 3; can precede or follow it. |
| 5 — Value cell | Cell<T> / with_cell<T> (§3.9). |
Seam merged, Phase 2 prerequisites resolved, plus a generics feasibility probe in production capsule source (§3.3), plus C5 — with_cell<T>’s “unrepresentable” claim must be narrowed to a value-only T constraint or an explicit concrete type list (§3.9) before this phase may ship the claim as written. |
Two notes on the ordering. Phases 2 and 3 should not be split further —
RwLock and Semaphore are the same handle shape, the same combinator
discipline, and the same test skeleton as Mutex, so splitting each primitive
into its own issue would manufacture review cycles without isolating any real
risk; Phase 2 is separate from Phase 3 only because Phase 2 additionally
carries the reentrancy, registration, and sanitizer work that the later
primitives then inherit for free. And Phase 4 is deliberately last among the
gated phases, because it is the only one that changes the capsule manifest,
and that change should land with the feature that justifies it rather than
ahead of it.
4. Effect & capability impact
The core surface is effect-free, and blocking implies nothing. with_lock,
with_read_lock, with_permit, once_do, and their try-variants declare no
effects. This follows SFEP-0049’s transparency model rather than extending it:
Channel’s send/receive already block and carry no effect, and consistency
demands the same of a mutex. The substantive argument is that ![clock] marks
reading the clock — an information channel and a nondeterminism source — not
the passage of time. A thread parked on a mutex learns nothing and observes
nothing; it is not exercising a capability.
Body effects need no polymorphism. A fn () -> int parameter carries no
effect row, and the effect checker attributes a closure’s effects to the scope
that defines it — recorded at stdlib/bench/src/mod.sfn:18-23 and
capsules/sfn/test/src/fixtures.sfn. So a caller passing an ![io] body to
with_lock already accounts for that io in its own signature, and with_lock
declares only what it uses directly, which is nothing. This is why the
combinator design imposes no effect-system work at all.
Manifest. stdlib/sync/capsule.toml keeps [capabilities] required = []
— the value SFEP-0063 Phase 0 just set — through every ungated and
Mutex/RwLock/Semaphore phase. pthread mutual exclusion is none of clock,
gpu, io, model, net, or rand, and re-broadening the manifest would
undo the Reach-pillar correction Phase 0 just made.
The one justified future change is the timed-variant phase (§3.7).
pthread_mutex_timedlock takes an absolute deadline, which the capsule must
build by reading CLOCK_REALTIME — a genuine clock read. So with_lock_timeout
and its siblings carry ![clock], and only when that phase lands does the
manifest become required = ["clock"]. Recording the trigger here means the
change arrives as a designed consequence rather than an unexplained
re-broadening.
Diagnostics: this proposal allocates none. A library capsule surfaces
failures as Result and throw, not as compiler diagnostics, and no phase here
introduces a static rule for a compiler pass to enforce. The relevant existing
code is E0806, which already governs the atomic builtins’ type contract and is
not extended.
A scan of allocated codes (rg -o --no-filename '[EW][0-9]{4}' compiler runtime docs site | sort -u) originally reported E08xx occupied through E0839, but
that scan was self-contaminated: E0839 appeared only because sibling
SFEP-0064, drafted the same day under docs/proposals/, reserved it in a
draft — a repo-wide rg over docs/ cannot distinguish an allocated code from
a proposed one in a sibling document. SFEP-0064’s rewrite (its own §8) has
since dropped that reservation, and the corrected count is E08xx occupied
through E0838, the highest code actually allocated in shipped compiler
source. E09xx holds E0901–E0907 and E0910–E0915, E10xx holds
E1001–E1019, and E1100–E1114 belongs to SFEP-0062; these were not
affected by the contamination. E1200–E1299 is entirely unallocated, and
that is the range a future sfn/sync diagnostic should draw from, unaffected
by the correction above. The likely first candidate is the §3.8 lifetime rule — a sync handle
created in a routine { } scope escaping into longer-lived storage — which
needs an escape analysis that does not exist and is therefore not proposed here.
5. Self-hosting impact
No compiler pass changes. Not one. Every phase is capsule source under
stdlib/sync/src/, compiled by the existing frontend against existing
builtins and existing pthread externs. The lexer, parser, AST, typechecker,
effect checker, emit_native.sfn, and the LLVM lowering are all untouched. The
compiler’s own build graph does not include capsules/, so make compile is
unaffected and the self-hosting invariant is preserved trivially.
Seed dependency, per .claude/rules/seed-dependency.md. The two situations
that rule governs resolve as follows.
The ungated phase carries no seed dependency. Once uses atomic_cas,
atomic_load, atomic_store, malloc, and function-typed parameters — all
long-shipped and present in any current seed. It bundles its capsule tests in
the same PR and needs no cut.
The gated phases wait on a landing-order dependency, not a seed gate. The
reclamation seam is runtime source — the nursery registry in
runtime/sfn/concurrency/nursery.sfn and drop_fn invocation in
runtime/sfn/memory/rc.sfn — and the .claude/rules/seed-dependency.md
runtime-source carve-out would ordinarily force such a capability to land
alone as seed-blocker. It does not bite here: SFEP-0064’s rewrite
establishes that the capability both consumers need (an env-less indirect
call through a stored *fn (A) -> R) already ships in the pinned seed —
runtime/capsule.toml:70 lists sfn/concurrency/scheduler.sfn in
sfn-sources, and that file already calls through *fn on every build. The
consequence for phasing is concrete: Mutex and everything after it can
merge as soon as the seam’s runtime-source PR merges, an ordinary
landing-order dependency with no cadence seed bump to wait on.
The practical scheduling consequence is the reason §3.4 matters: the ungated
Once/atomics-decision phase can be worked in parallel with the seam’s
runtime-source PR, so the dependency costs no wall-clock time.
Formatting. All capsule source is subject to sfn fmt --check. Note that
fmt renders function-type annotations as fn (int) -> int with a space
(visible throughout runtime/sfn/array.sfn:550,594,640); snippets in this
document follow that spelling, and implementers should not hand-tune it.
6. Alternatives considered
- Explicit
lock()/unlock(). Rejected in §3.2: with nodefer, every earlyreturnand everythrowis a permanent deadlock that no pass diagnoses. Enforcing the pairing would need affine typing on the handle — which would also break the sharing that §3.1 depends on, since affine spellings are exactly what_consume_spawn_capturesmoves. - A
routine-integrated lock scope. Rejected in §3.2: new syntax for what a library function already does, the wrong lifetime granularity (nursery scope serializes the workers the lock coordinates), and it conflates the nursery’s reclamation role with a mutual-exclusion role. Mutex<T>with a guard passed to the body. Rejected in §3.3: generics would compile, but with no borrow checker or escape analysis the guard can be stored past release, so the API would advertise a guarantee it cannot keep. The value-cell form in §3.9 is the honest version of the same idea.- A typed atomics wrapper. Rejected in §3.5: the builtins are already
user-reachable
seq_cstLLVM atomics, a same-named export would be silently unreachable behindis_atomic_builtin’s short-circuit, and a differently-named one forks the vocabulary while being slower. - Poisoning after a fault. Rejected in §3.6: the re-throw plus cancel-on-fault
already propagates the fault, observing poison would force
Resultonto every acquisition, and the prior art has moved away from it. Additive later if needed. Resulton every lock operation. Rejected in §3.6: it produceslet _ = with_lock(...)at call sites, converting a crash into a silently skipped critical section. Fail-closed with a diagnostic is strictly more debuggable.- Waiting for the seam before shipping anything. Rejected in §3.4:
Onceand the atomics decision hold no OS resource and need no teardown, so gating them on the seam would idle a full session’s work behind the seam’s landing-order dependency for no safety benefit. - Deferring the whole API design until the seam is accepted. Rejected in §2: the surface depends on the seam’s existence, not its shape, and the one place it genuinely does depend on the shape is isolated as an open question in §3.8 rather than guessed.
7. Stage1 readiness mapping
This proposal adds no language feature, so the compiler-pass rows are not
applicable; the capsule source must nonetheless clear the same bar as any
.sfn.
- Parses — capsule source only; no new syntax.
- Type-checks / effect-checks — the core surface declares no effects (§4);
the timed phase declares
![clock]. - [N/A] Emits valid
.sfn-asm— no codegen change; capsule source flows through the existing path. - [N/A] Lowers to LLVM IR — same.
- Regression coverage — §8.
- Self-hosts — no compiler pass changes;
capsules/is outside the compiler’s build graph (§5). -
sfn fmt --checkclean — applies to every capsule source and test file. - Documented in
docs/status.md+ spec — each phase updates thesfn/syncrow andsite/src/content/docs/docs/reference/standard-library.md, replacing the SFEP-0063 Phase 0 “no capsule wrapper planned” wording with the surface that phase actually shipped.
8. Test plan
All tests are Sailfin *_test.sfn files using sfn/test, per
.claude/rules/no-bash-e2e.md — no .sh surface exists.
Capsule tests, stdlib/sync/tests/:
once_test.sfn— single-threadedonce_doruns the initializer once and returns its value; a secondonce_doon the same handle does not re-run it. Contended: N workers spawned in oneroutine { }all callonce_doon a shared handle; assert a counter incremented by the initializer reads exactly 1 and every worker observed the same returned value.mutex_counter_test.sfn— N workers each perform Mwith_lockincrements of a shared heap cell inside oneroutine { }; assert the total is exactly N*M. This is the primary mutual-exclusion proof and must run with enough iterations to fail reliably if the lock is absent.mutex_throw_unlocks_test.sfn— the leak-vector regression, and the most important test in the suite. Awith_lockbody throws; the caller catches; a subsequentwith_try_lockon the same handle returnsOk, proving the release ran on the throwing path. Repeat forwith_read_lock,with_write_lock, andwith_permit.try_lock_busy_test.sfn—with_try_lockfrom a second worker while the lock is held returnsErr(SyncError.Busy)and does not run its body (assert via a side-effect counter).mutex_create_error_test.sfn—mutex_createreturnsOkin a nursery scope; the top-level behaviour asserted here is whichever §3.8’s open question resolves to, and this test is the record of that resolution.rwlock_test.sfn— concurrent readers all make progress underwith_read_lock; a writer excludes them.semaphore_test.sfn—with_permits(s, k, body)never admits more than k concurrent bodies (assert via a peak-concurrency counter).self_deadlock_test.sfn— a nestedwith_lockon the same handle throwsSyncError.SelfDeadlockrather than hanging. Must carry a timeout so a regression fails the suite instead of wedging it.
E2E, compiler/tests/e2e/: one executable test compiling and running a
program that shares a Mutex across spawn closures, confirming end to end that
no ownership diagnostic fires on the shared handle (the §3.1 property) and that
the program’s guarded total is exact.
Reclamation coverage lands with the first gated phase: an ASAN leg
(docs/conventions/sanitizer-tests.md, with SAILFIN_MEM_LIMIT=unlimited and a
skip-not-fail guard) asserting a Mutex created in a routine { } is destroyed
and freed at scope exit, and that Once’s intentional process-lifetime cell is
the only outstanding allocation.
Verification commands.
sfn fmt --check stdlib/sync/src/*.sfn stdlib/sync/tests/*_test.sfnsfn check stdlib/sync/src/mod.sfnbuild/bin/sfn test stdlib/sync/tests/build/bin/sfn test compiler/tests/e2e/ -k mutex_shared_spawnmake compilemake checkmake compile is not strictly required by any phase — no compiler source
changes — but is run once per phase to confirm that claim rather than assume it.
9. References
- SFEP-0063 (
docs/proposals/0063-sync-capsule.md) — scope, per-candidate verdicts (§3.2), the reclamation finding (§3.1), the nursery-seam state at acceptance (§3.6), and the import-shadowing defect (§3.5). This proposal is the API half of the follow-up it defers to. - SFEP-0049 (
docs/proposals/0049-concurrency-effect-transparency.md) — the effect-transparency model §4 applies. - SFEP-0055 (
docs/proposals/0055-typed-task-handles.md) — adjacent concurrency-typing predecessor. - SFEP-0018 (
docs/proposals/0018-borrow-checking-1.0.md) — the unshipped borrow checking whose absence decides §3.3. - The in-flight reclamation-seam proposal (generic
(handle, destructor)nursery registry andrc.sfndrop_fninvocation) — the gate on every phase afterOnce, and the owner of §3.8’s open question. compiler/src/llvm/atomics.sfn:1-30,49-62— the six atomic builtins, theirseq_cstlowering, the old-value return contract,E0806, and theis_atomic_builtinshort-circuit that decides §3.5.compiler/capsules/analyzer/src/ownership_checker.sfn:373andcompiler/capsules/analyzer/src/typecheck_types/expr_type_rules.sfn:232-239— theis_ownedgate and the four owned/affine spellings; the reason §3.1 needs no carrier.runtime/sfn/concurrency/channel.sfn— the in-tree existence proof: a heap-allocatedpthread_mutex_t+ twopthread_cond_tobject shared across worker threads, written in Sailfin. The handle shape in §3.1 follows it.runtime/sfn/platform/pthread_layout.sfn— the single source of truth for pthread storage sizes, and where §3.6’s mutex-type constant belongs.capsules/sfn/test/src/fixtures.sfn:55(with_tmp_dir) — the production precedent for the acquire /try/ cleanup-on-both-paths / re-throw combinator in §3.2.stdlib/bench/src/mod.sfn:18-23— the recorded rule that afn () -> intparameter carries no effect row and closure effects are attributed to the defining scope; the basis for §4.capsules/sfn/test/src/expect.sfn:17-19— a shipped capsule backing away from generics; the maturity signal in §3.3.compiler/tests/e2e/fixtures/mono_box_int.sfn,compiler/tests/e2e/fixtures/mono_pair_struct.sfn,compiler/tests/unit/monomorphize_generic_fn_test.sfn— generic struct and generic function monomorphization coverage.runtime/prelude.sfn:162(Result<T, E>),stdlib/time/src/mod.sfn:34— genericResultin shipped capsule code..claude/rules/seed-dependency.md— the runtime-source carve-out that would ordinarily make the seam aseed-blocker, and that SFEP-0064’s rewrite establishes does not bite here because the capability the seam needs already ships in the pinned seed (§5).docs/conventions/sanitizer-tests.md— the skip-not-fail ASAN procedure §8 and §3.4 depend on.