Bounded Owning-Capture Closures v1
September 18, 2026 ยท View on GitHub
Audience: language users, compiler contributors, backend implementers, and reviewers deciding whether to admit this profile beyond source checking.
Status: reviewed bounded design, past its review checkpoint. All three
backends execute this profile end to end and agree on the observable
result. hir::resolve itself still refuses own fn(...) with the same
stable diagnostic as before (see
Review checkpoint) for any direct
caller that does not substitute first -- but every caller that matters now
does: interpreter::interpret (src/interpreter.rs), codegen::emit_c
(src/codegen.rs) and wasm::emit_module (src/wasm.rs) each substitute
the bounded construction-plus-its-one-call with a direct call before
resolving, exactly as before for the interpreter and now identically for
native C11 and Core Wasm. See
Execution: three backends, one substitution.
This is SPX-AI-021's bounded owning-capture closure slice. It is a
separately versioned and checked profile from
Scalar Snapshot Closures v1 and
v2: those profiles capture only Copy scalars and never
admit an owning capture. This profile admits exactly one lexical owned
Bytes capture and nothing else; it does not extend, relax, or reinterpret
the scalar-snapshot profiles, which are unchanged (see
../src/source_verify/closure.rs).
Syntax
module example.owning_closures;
@id("example.checksum") fn checksum(payload: own Bytes) -> i64 {
42
}
@id("example.main") fn main() -> i64 {
let payload = bytes_zeroed(4usize);
let clo = own fn() -> i64 { checksum(payload) };
clo()
}
own fn() -> R { body } admits zero explicit parameters in this bounded
profile. body must be exactly one call transferring exactly one lexical
owned Bytes local to an ordinary, monomorphic, one-parameter function
declared fn target(payload: own Bytes) -> R, where R is the closure's
declared result type. No other body shape is admitted:
SPX-T292 rejects anything else,
including a body that inspects the payload directly rather than transferring
it whole to a declared target. This is deliberately the smallest useful
shape: a real named function that does the work, invoked once through a
value that carries its one owned argument along with it. Combining this
profile's owning capture with the Copy-scalar captures the existing profiles
already admit is not supported in this slice (a stated limitation, not an
oversight): a follow-up would need capture_names_scoped and this module's
admission to cooperate, which this bounded slice does not attempt.
One-shot semantics, entirely through existing ownership machinery
This profile introduces no new runtime carrier, no new Type variant, and no
new ExprKind variant. ExprKind::Closure gained one field, owning: bool
(false for every existing scalar-snapshot closure literal, unchanged); see
../src/ast.rs. Everything else is checked by reusing the
same move/availability lattice (Availability::Moved, diagnostic
SPX-O101) that already governs every other owned value in this language:
- Construction moves the capture exactly once.
own fn() -> R { target(payload) }checks thatpayloadis an available, unborrowed, ownedByteslocal matchingtarget's one declared parameter, then markspayloadmoved -- identical to passing it by value into an ordinary call. Reusingpayloadafterward reports the sameSPX-O101"use of resource after ownership was moved" any other double-moved value reports. - The constructed value's checked type is a reserved, unspellable
sentinel (
\0owning-closure.v1, carrying the target function's name and the declared result type as nested pseudo-arguments; seeowning_closure::sentinel_typein../src/source_verify/owning_closure.rs). No authored identifier can begin withNUL, so no function parameter, record field, or return-type annotation can ever spell this type. That is what keeps a constructed value from escaping through any boundary other than the one call this module recognizes: reading it as a plain value anywhere else -- aliasing it into anotherlet, passing it as an argument, returning it, storing it in a field, comparing it -- reduces to exactly one check (owning_closure::reject_escaping_read, wired into the sameVar-expression handling every other read goes through) because the callee of a call expression is never itself aVarnode. - Calling it consumes it.
clo()looks upclo's checked type; when it is the sentinel, the call is checked directly (zero explicit arguments,SPX-T295otherwise) without falling through to ordinary named-function or bound-value dispatch. A first call on anAvailablebinding succeeds and marks it moved; a second call seesMovedand reports the sameSPX-O101a second use of any other owned value would. This is what makes the second call a compile-time diagnostic rather than a runtime accident: no backend ever decides this, because no backend ever receives a double-invocation program in the first place (see the refusal below). - Dropping it uncalled needs no new mechanism. This language already drops an unused owned local at scope exit without requiring it be consumed; an owning closure that is constructed and never called settles through exactly that existing path. There is no "must be called" check to add, and none is added.
- Sticky failure and left-to-right staging are inherited, not
reimplemented. Because invocation is checked as an ordinary call to
targetwithpayloadas its one owned argument, this profile makes no independent claim about argument staging or failure propagation; it relies entirely on the existing call machinery's guarantees for that call.
Review checkpoint and what remains
This profile is checked and negative-tested at the source level.
hir::resolve itself is unchanged: it still
refuses every program containing own fn(...) with the same stable
diagnostic (SPX-H006, message containing "owning-capture closures" and
"not yet lowered") before any lowering happens. What changed is who calls
hir::resolve directly with the unmodified program: nobody, any more.
interpreter::interpret, codegen::emit_c and wasm::emit_module each now
run hir::closure::desugar_owning_closures
first and resolve its output instead (see
Execution: three backends, one substitution),
so none of them ever reaches hir::resolve with an own fn construct still
in the program. hir::resolve's own refusal remains directly reachable and
tested: any caller that skips the substitution step -- including calling
hir::resolve directly, which is exactly what
hir_resolution_refuses_an_otherwise_source_clean_owning_closure in the test
corpus below does -- is refused exactly as before.
Why the profile stopped at a source-text substitution rather than
lowering further into shared HIR: giving this closure literal a genuine
owning runtime environment inside shared HIR (rather than treating its
checked identity as sentinel bookkeeping local to source_verify) is the
seam this document originally existed to name precisely, and that
conclusion is unchanged by native C11 and Core Wasm adopting the same
substitution the interpreter already used. The closure literal itself is
still never lowered by any backend: ExprKind::Closure { owning: true, .. } never reaches HIR at all, on any of the three backends, in any
program. Two designs were evaluated and rejected for lowering it into HIR
directly:
- A new
ExprKind/Type/ResolvedTypevariant carrying real owning semantics through HIR.ExprKindalone is matched exhaustively in over two dozen files, several of them (src/project/candidate/*,src/assurance_manifest/smt_discharge/*) leased to other concurrent workers and off-limits to this change. AType/ResolvedTypevariant is worse: those enums are matched in well over a hundred sites across native/Wasm codegen, cache/graph codecs, and public ABI surfaces. Either change ripples far outside a "closure module." - A lexically-scoped construction-to-call association threaded through
HIR's iterative statement/expression resolver, so
own fnsugar could desugar directly into an ordinary call at the one call site aletpermits. This requires either widening the shared per-functionBindingtype (constructed at roughly sixty sites across HIR resolution) or special-casing block resolution inside the single large iterative HIR expression resolver every other language feature also depends on. Both carry real risk of destabilizing unrelated resolution paths.
Both designs above touch shared HIR machinery consumed by every backend at
once, which is exactly why they were deferred pending review. Once approved
to proceed, a third, smaller path became available that neither design
needed: since own fn() -> R { target(payload) } is checked (by
source_verify::owning_closure) to carry no state beyond "which target" and
"which captured local," and its only admitted use is one direct
zero-argument call, construction-plus-its-one-call is a pure source-text
substitution -- replace the one name() call site with the target call,
drop the now-dead let name = own fn ... binding -- with no new runtime
carrier, no HIR variant, and no shared-resolver change at all.
hir::closure::desugar_owning_closures
performs exactly that rewrite on an already-verified Program. That
substitution was approved for the interpreter first, and this document
originally scoped native C11 and Core Wasm out because giving them the same
step was, at the time, unreviewed. It has since been reviewed and adopted
identically by both: codegen::emit_c and wasm::emit_module each call
desugar_owning_closures themselves, immediately before their own
hir::resolve, and resolve its output instead of the original program --
the exact same substitution the interpreter runs, applied independently at
each of the three call sites rather than shared through one plumbing seam
(each backend already resolves from a different entry point, so there is no
single choke point to share it through without the two rejected designs
above). hir::resolve itself, and any caller that reaches it directly with
an unsubstituted program, still refuses own fn(...) exactly as before.
One native-specific detail worth naming: codegen::emit_c must desugar
once and hand the same rewritten program to both hir::resolve and
emit_resolved_c_with_source (which pairs source declarations with
resolved ones via contract_labels). Passing the original, unsubstituted
program to contract_labels alongside a resolved program built from the
desugared one would pair mismatched bodies -- a source declaration missing
the own fn construct entirely -- so emit_c binds desugared.as_ref().unwrap_or(program)
to one local and threads that single value through both calls.
This document, the sentinel encoding, the negative-test corpus in
../tests/language/function_values/owning_closures.rs
(that file's backend_execution module proves the interpreter executes the
profile end to end and that native C11 and Core Wasm both lower it -- emit
successfully -- rather than refuse it), and the cross-backend execution
corpus in
../tests/cleanup_backends/executable_owning_closure.rs
(which compiles and runs the native output and instantiates the Wasm module,
and compares the observable result and the capture's settlement count
against the interpreter and against each other) are the record of that
reviewed step.
Execution: three backends, one substitution
interpreter::interpret, codegen::emit_c and wasm::emit_module each
substitute before resolving, so HIR never lowers an owning closure at all on
any of them -- there is nothing to lower, because the construction and its
one call have already become an ordinary direct call by the time
hir::resolve runs. Concretely, for
let clo = own fn() -> R { target(payload) }; clo():
- Called. The rewrite produces
target(payload)whereclo()stood.payloadis transferred into the call's one argument slot exactly once and committed by exactly oneCallCommit; no exit ever also finalizes (drops) it, so a captured owner is never doubly settled. - Dropped uncalled. The rewrite removes the dead
let clo = ...binding entirely and leavespayloadan ordinary, never-moved owned local. It settles through the language's existing scope-exit drop -- exactly oneFinalizeAction, freeing it once, with no closure-specific mechanism involved.
Both shapes are proven directly against the built cleanup_plan::CleanupPlan
in hir::closure::owning_desugar's tests,
so "cleaned up exactly once" is checked against the same canonical,
deterministic cleanup-plan structure every other owned value in this
language is checked against -- not a parallel, closure-specific claim. That
proof is backend-independent (it reads the resolved cleanup plan directly,
before any backend lowers it further), and
tests/cleanup_backends/executable_owning_closure.rs closes the remaining
gap: it compiles codegen::emit_c's output with clang at -O0 and -O2,
instantiates wasm::emit_module's output with Node, and runs
interpreter::interpret, for both shapes, asserting all four runs return
the same value and -- via a malloc/calloc/free-intercepting native probe and
a token-counting Wasm host-import shim -- that the captured Bytes
allocation is settled exactly once on every backend with a real allocator
to observe (not zero, a leak, and not two, a double free).
What is proven today
- Construction moves its capture exactly once; reuse is
SPX-O101. - A first call succeeds cleanly; a second reports
SPX-O101at the second call specifically (isolated by a clean one-call baseline in the same test). - An uncalled closure reports no diagnostics (settles via ordinary scope-exit drop).
- Aliasing (
let other = clo;), passing as an argument, and any other escaping read are rejected (SPX-T296). - A malformed body, an undeclared or mis-signatured target, and a non-
Bytesor already-borrowed capture are rejected (SPX-T292,SPX-T293,SPX-T294) before any of the above matters. - Owning closures are refused inside generic functions (
SPX-T291) and in contract expressions (SPX-O119). - Canonical formatting round-trips the authored
own fnsyntax exactly. - All three backends -- the interpreter, native C11, and Core Wasm -- execute both the called and the uncalled shape end to end and agree on the returned value, proven by direct compile-and-run/instantiate-and-run execution, not merely by each backend's emission succeeding (see Execution: three backends, one substitution).
- The captured owner settles exactly once in both shapes on every backend with a real allocator to observe -- the interpreter's own cleanup-plan proof plus the native allocation-counting and Wasm token-counting execution evidence above -- so neither shape leaks nor double-frees its capture on any backend.
hir::resolve, called directly with a program that still containsown fn(...)(i.e. bypassing the substitution every real caller now performs), refuses it with the stable message above -- proven byhir_resolution_refuses_an_otherwise_source_clean_owning_closurein the test corpus above.
What is not proven, and is not claimed
- The closure literal itself is never lowered by any backend.
ExprKind::Closure { owning: true, .. }never reaches HIR, on any of the three backends, in any program: every execution path works becausedesugar_owning_closureshas already rewritten construction-plus-its- one-call into an ordinary call (or removed the dead binding entirely, for the uncalled shape) beforehir::resolveever runs. There is still no owning-closure runtime carrier, noExprKind/Type/ResolvedTypevariant for it, and no shared-HIR-resolver change of any kind -- see Review checkpoint for why that remains a deliberate boundary, not a gap left to close later. hir::resolvestill refusesown fn(...)for any caller that does not substitute first. This is not a residual gap either: it is the seam that keeps a partially-lowered owning capture from ever reaching a backend that does not know how to execute it. A hypothetical fourth caller ofhir::resolvethat forgot to desugar would be refused, not silently miscompiled.- The execution evidence covers exactly the bounded shape this document
describes (zero explicit parameters, one lexical owned
Bytescapture, a body that is exactly one transferring call); it makes no claim about any broader owning-capture shape. - No combination with the existing Copy-scalar capture profiles.
- No explicit closure parameters (the profile is fixed at zero).
- No nested owning captures, no capture of a borrowed view, and no public callable ABI -- all excluded by construction (the sentinel type cannot be spelled in a public signature).