Issue #973: A value moved into an or_insert_with lambda is refused (NK2105), though the lambda runs at most once

Open and answer this issue on GitHub.

ready · opened 2026-10-29 · updated 2026-10-29 · 2 comments

Found while building #950 step 7 (b950d report). Site: tools/sharing.nika 510.

Probe

let t: Vec[i64] = [1]
m.entry("k").or_insert_with(fn() { H { a: "x", t: t } })

gives NK2105 (a value moved into a lambda). or_insert_with calls its lambda at most once, so the move is sound.

Decided (owner, 2026-10-29): ADR-lambdas-1, spec Part I 5.4 D (and the NK2105 note in 6.5), Part II 12.2 access / 12.7 task::scope, Part III contract key once (commit 288a993a). A lambda handed to a parameter that runs it at most once may give away what it captures. For std, the contract says once. For user code it is inferred, and there is no marker.

To build

Step (a): std (ADR-lambdas-1 D1, D2, D4)

  1. crates/nikaia-std/std.contracts: key once = ["f"] (["body"] for task::scope) on Entry::or_insert_with, Entry::and_modify, task::scope, Locked::access, SharedMut::access, and nowhere else (not update). The contract reader carries the field.
  2. Checker: when a lambda is the argument of a once parameter, the take walk does not push the repeat frame for it (check_state.nika:328, what: "lambda", read in check_takes.nika hands_over_path_of / around_a_repeat). A value handed over inside it counts as gone after the call. A loop around the call stays a repeat.
  3. fold_bound.nika:1153: the hard-coded once name list reads the contract field instead.
  4. D4: NK2105 inside a lambda names the parameter that may run it again (map may run its lambda more than once).
  5. Tests in tests/compiler/ (Nikaia, with .stdout/.stderr): or_insert_with moving a captured value is accepted and a later use is NK2105. The same move under map stays NK2105 with the D4 wording. update stays refused.
  6. Convert tools/sharing.nika 510 (Sharing::note) to one entry(key).or_insert_with(...) that moves ty (commit says Part of #950).

Step (b): user code (D3), after (a)

  1. Infer once-ness of a user code parameter: called at most once on every path, handed on only to a once parameter. A call in a loop, two calls or keeping it makes it many. Fixpoint over calls, as for kept/lent. Written to the ledger as once.
  2. Lower a once parameter as impl FnOnce (emit_core_types.nika:226). Kept stays Kept<dyn Fn>.
  3. D4 for user code: NK2105 names the second call or loop in the callee. A ledger diff names the edit (Part I 6.7).
  4. Tests: a user wrapper of entry accepting the move. A callee that calls f twice gives NK2105 at the caller, naming that call.

The commit that builds step 10 writes Fixes #973.

Comments

keywan-ghadami, 2026-10-29

Decision paper: a lambda that runs at most once may take what it captures (architect, 2026-10-29)

What this is about, from zero

A lambda (fn { … }, Part I 5.3) may use names from around it. Part I 5.4 decides how without anything written: if the function that takes the lambda only runs it before it returns (map), the lambda borrows them. If it keeps it (spawn), the lambda moves them. There is a third case nobody has decided yet. The lambda runs during the call, as an immediate one does, but it wants to give away a captured value: store it in a struct, push it, hand it to a keeper.

let t: Vec[i64] = [1]
m.entry("k").or_insert_with(fn() { H { a: "x", t: t } })

Today (checked with target/fast/nikaia check on develop 50e18063): error[NK2105]: t is stored in H.t inside a lambda, but it was declared outside it. The reason the checker gives is “a lambda may run more than once, and the second run would find it gone”. That is true of map. It is false of or_insert_with, which runs its lambda at most once. The program’s way out is or_insert(H { .. }). That builds the value, with its copies, on every call, including the calls where the key is already there.

Where it comes from: the #950 step 7 builder (b950d report), at tools/sharing.nika 510 (Sharing::note). There, if !self.handles.contains_key(key) { self.handles.insert(key.clone(), Handle { function: function.clone(), value: value.clone(), ty: ty, .. }) } wants to become one entry(key).or_insert_with(...). The lambda moves ty (an owned parameter) into the new Handle. With or_insert instead, the call would copy function and value every time, so the double lookup stays. This is a real site, not bookkeeping. Rust’s own or_insert_with takes an FnOnce for exactly this reason.

What is already decided: Part I 5.4 C: the context of a user code parameter is inferred, not written; there is no @detached to write. ADR-collections-1 D1: the or_insert_with lambda runs during the call. No ADR speaks of a lambda that is called at most once, so this is kind 2. The owner’s lean (2026-10-29): allow it for the std parameters that are called at most once, and maybe go further.

Facts checked in the code

The std lambda parameters, by how often they run (from std.contracts and Part II):

Runs at most once Runs more than once, or may
Entry::or_insert_with f, Entry::and_modify f, task::scope body, Locked::access f, SharedMut::access f every Seq and ListExt entry (map, filter, fold, …), Vec::retain, Vec::sort_by_key, Par::fold (all three), SharedMut::update and Locked::update (the block runs again if another task got there first, Part II 12.2), SharedMut::supervised build (once per restart), net::serve handler

task::Scope::spawn, supervisor::child and net::serve keep their lambda, so they already move what it captures (NK2101’s rule). update shows that the row cannot be guessed from the name. It is a fact about each entry.

Options

(a) std only. The contract of a lambda parameter gets one field, once = true, set on the five entries in the left column. When a lambda is handed to such a parameter, the take walk does not count it as a repeat. A value moved inside it is gone after the call, as an argument would be. The lambda stays a function of its own for return and the rest.

(b) The same for user code, inferred. A code parameter of a user function is once when its body calls it at most once on every path and hands it on only to a once parameter. A call inside a loop, two calls, or keeping it makes it many. This is the take walk the checker already runs, with “calling f” counted as “handing f over”: a second call is exactly NK2105’s “used again”. The answer goes into the ledger beside kept and lent (6.7) and is lowered as impl FnOnce. Nothing is written in the source.

(b’) The same, with a written marker (f: once fn(), or a @once attribute).

(c) Keep refusing it. or_insert with the copy, or the double lookup.

  Simple as a scripting language Rust lowering Checker effort What self-hosting gains If it is wrong
a nothing to learn: the program that reads right is accepted none (counterparts are FnOnce) a contract field, one test in the repeat frame, fold_bound’s name list reads the field sharing.nika 510 and every entry site that builds an owned value, with no copy and no second lookup a wrong once row would let a retried lambda find a value gone: so the column is short and each row cites the spec sentence
b nothing to learn, same as (a) impl FnOnce for once parameters, impl Fn stays for the rest the walk exists; a ledger column and a fixpoint over calls, as for kept and lent user helpers that wrap entry or a lock work like std’s a callee edited to call f twice turns many, and its callers that moved into the lambda get NK2105 at their call. This is the same fragility kept/lent inference already has, and the message names the second call in the callee
b’ a new word that every reader meets and that answers a question the compiler can answer itself the same parser, formatter, LSP, plus a check that the marker is true the same as b contradicts 5.4 C (inferred, not written)
c the program reads wrong (a copy for nothing) - none nothing; the double lookup stays -

Is (a) now and (b) later a clean path? Yes. The field (a) adds to std.contracts is the same fact (b) would write into the ledger for user functions. (b) only adds the part that computes it. No rule of (a) changes when (b) comes. Nothing in (a) closes a door, and (b’) is not needed for (b).

Recommendation

Decide (a) and (b) together, in one ADR. Build (a) first. Reject the written marker. A marker is a word that a scripting-language user should never have to know, for a fact the body already shows. Inference is the rule 5.4 C already chose for immediate and detached.

Draft ADR (stays here until the owner decides)

ADR-lambdas-1: A lambda that runs at most once may give away what it captures D1. A lambda handed to a parameter that runs it at most once may hand a captured value to something that keeps it. After the call the value is gone, as an argument’s is (NK2105 on a later use). A lambda handed to any other parameter keeps today’s rule. D2. In std these parameters are Entry::or_insert_with, Entry::and_modify, task::scope, Locked::access and SharedMut::access, each marked once in its contract. update is not one (it may run again, Part II 12.2). D3. A code parameter of user code is once when the body calls it at most once on every path and hands it on only to a once parameter. This is inferred, written to the ledger beside kept and lent, and has no syntax. It is lowered as impl FnOnce. A function value that is kept (a field, a let, a result) is never once. D4. The refusal names why: map may run its lambda more than once, or, for user code, the second call in the callee. Build order: D1, D2, D4 (with the sharing.nika 510 move); then D3.


Generated by Claude Code

keywan-ghadami, 2026-10-29

Owner’s decision (2026-10-29): the paper’s recommendation is accepted.


Generated by Claude Code

Back to Open Issues