SharedMut[T], Its Shape Is Chosen Per Value, It Opens Through Four Doors, and What Leaves It Is StampedStatus: Accepted
Date: October 3, 2026
History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached
Answers: whether Locked[T] keeps two implementations or gets one Mutex
floor, and what the shared mutable type is called; what a lock is in the
machine; how a block that changes locked data says so; how a hull is made; what
a door over several locks is called and whether it hands a value back per lock
(#245); the two
refusals that come with the doors
(#175); what access
may do and how update changes a large value; the lost update written over two
lines; the re-entrancy switch; I/O under a lock that neither pauses nor takes a
lock, which ADR-288 D21 leaves to this record; and update’s
compare-and-swap row
(#184)
Displaces: Part II 12.2’s per-setting
split of the lock (D5-D7); Part II 12.3’s “often a compile-time error”, Part
II 12.2’s “safety net” sentence and ADR-005 D6’s last sentence
(D28); Part I 6.2’s definition of Locked[T] by the nesting (D3); Part II
12.2’s sync-lambda derivation and ADR-297 D3’s “for free”
(D10); ADR-312’s annotation as the constructor (D2); Part I 6.3’s
sentence about appending through access (D11); ADR-021 D5’s
cache-key enumeration, which gains a dimension (D37); and
ADR-037 D7, which gains a third answer, word (D8)
Related:
ADR-037 D2, D6, D7 (one user thread at no; the atomic count as
the floor; the per-value inference D7 here rides on),
ADR-312 D6-D8 (a lock may go into a task of our own and not into
code nothing describes — the crossing verdict, which this record does not
restate; the count, plain at one user thread; a handle is duplicated, never
moved),
ADR-288 D21 (a door’s block is sync and touches no lock;
NK2202 against NK2203) and its sync fixpoint, which D29 mirrors,
ADR-094 D3 (mut on a parameter is in-place change),
ADR-292 D3 and D7 (touches; a default that cannot be declined
is imposed),
ADR-288 D15 (sync = "from(f)"),
ADR-303 D7 (the foreign-thread rule D32 extends),
ADR-010 D1 (the polarity D31 and D34 each depart from, for their
stated reason),
ADR-025 D6 (iterates, the precedent for a column on a type),
ADR-313 D5 (why the doors and the
shared types need no use),
ADR-258 (the four weights). Evidence:
mutex-floor.md, lock-free.md.
How does a program share a value that changes — what is the type called, what is it in the machine, how is it read and changed, and what may run while it is open?
The answer decides whether Part II 12.2’s counter — the program
user_parallelism = yes exists to serve — can be written.
| Language / tool | Its answer | What it costs there |
|---|---|---|
| Rust | two wrappers, Arc<Mutex<T>>; RefCell panics on a re-entrant borrow, a Mutex hangs on one, and a panic under a Mutex poisons it |
the composition everybody complains about; two behaviours for what a reader thinks is one thing (D4) |
Clojure atom, Haskell MVar / TVar, Scala Ref |
one name for the capability, no composition; the cell is made by a call, and a change takes a pure function | none of them can spell it as a count around a lock; the pure change is what allows compare-and-swap with retry |
C++ std::lock |
several locks at once, by try-and-back-off | a different mechanism from sorting two by address |
| Perl taint mode | data from outside is marked; one word removes the mark | somebody removes it early to make the types fit, and three functions later nobody remembers |
What does not transfer: Rust’s split into two wrappers, because the combination is this language’s common case; a mark that can be removed. What does: one name made by a call, and a change written as a block the implementation may run more than once.
The options rejected, one row each; the number that decided, where there was one, is in §5.
| Question | Option rejected | Why it lost | See |
|---|---|---|---|
| what is under the lock | one Mutex floor at both settings |
pays 11.3 ns an acquisition at no, where it unlocks nothing; a conflict-refusing flag needs the same atomic and its refusing branch is dead under D28 |
D4 |
| one shape per build setting | decides a property of a value somewhere other than where the value is; a library cannot know its own type across its boundary | — | |
| two shapes that behave differently | a per-value choice the reader cannot see must not change what a program does | D9 | |
| the name | Shared[Locked[T]] as the spelling, or as a second one |
Rust’s wart; the same bytes below, two types above | D3 |
| a name that is the mechanism | closes the door to a lock-free implementation that D8 walked through | D1 | |
| the first hull | the annotation as the constructor | constructs only where a position says so, and the list of positions had four holes — a literal, a return, an argument, a field |
D2 |
| the doors | one door that carries every rule | three of four shapes of change need none of them | D10 |
access changing through its parameter, with mutability arriving from the callee unspelled |
the first place a change would not be written on the line | — | |
| a second lambda form for a mutable parameter | mut on a parameter already exists (ADR-094 D3), and one lambda form is what ADR-277 protects |
D12 | |
update as a pure function, value in and value out |
a large value is moved out and back, and a panic leaves an empty slot | — | |
| several locks | a ledger entry | a signature binds from a receiver, and there is none | D17 |
| a keyword, or a method on one lock | a reserved word buys nothing the call form lacks; a method implies an order, which is the bug | D17 | |
| the lost update | a dataflow pass over one body, or across calls | stops at the function boundary, or at time — a value kept in a struct between requests | — |
| a version carried and checked at run time | reports the collision when it happens; a counter per lock and sixteen bytes per value | — | |
| a stamp with a word that removes it | Perl’s taint again | — | |
| what may run inside | a precise which lock analysis | whether a program compiles would depend on aliasing an unrelated line can change | D30 |
| a copy of the crossing walk for foreign calls | re-derives four subtleties, and gets the empty field list of fs::Mapped wrong |
D32 | |
a type’s touches read fail-closed |
what is built on it is a refusal, and refusing on doubt refuses correct programs | D34 | |
| the runtime check | always on; on the command line; named debug | a default that cannot be declined is imposed; a command-line switch makes a shipped build irreproducible; it is a guarantee, not a development aid | D36 |
Where two weights pulled apart, the reader’s (D2, D4 of ADR-258) is chosen only where the program’s (D1, D3) cost nothing: the mutex shape pays its owner check (D9) so that one written type has one behaviour, and that check falls only on values that actually cross.
SharedMut[T]: one name for the capability, and it survives to the emitter.SharedMut[T] means a value several parts own at once and any of them may
change. It does not mean a count around a lock; what is under it is the
compiler’s, chosen per value (D5-D8). The short name is for the common case and
is a deliberate departure from spelling words out.
It is one name up to the emitter and becomes two hulls only there, so no internal spelling appears in a diagnostic, a printed type or a ledger entry. Naming the capability rather than the mechanism keeps the lock-free implementation possible — which D8 uses.
Shared(x), SharedMut(x) and Locked(x) are calls.A hull you cannot observe, the compiler writes. A hull you can observe, you write.
A T?’s Some(…) stays the compiler’s (ADR-278); these three
change when a value is cleaned up, which a program can see. A call is an
expression, so a hull is made wherever an expression stands — a literal, a
return, an argument, a field — and SharedMut(0) gives the number its type as
any argument does. The call is typed by the compiler, not the ledger: a ledger
signature binds from a receiver (ADR-288) and a constructor has
none.
Shared[Locked[T]] is refused, and Locked[T] is the lock on its own, for individually locked fields.NK1123 refuses the long spelling, and a hull of a hull, with the replacement
in the message. Locked[T] keeps its own use — one lock per field inside a
shared structure rather than one around the whole — and is opened by the same
doors. Part I 6.2 defines it by that use, not by the nesting.
A cheap shape (a borrow flag) and a robust one (an operating-system mutex) both
stay. What a program could see between them — re-entering one lock panics under
one and hangs under the other — is made impossible at compile time (D28), not
paid for with a floor on every acquisition in every program. Poisoning is not
unified: at no a panic is a process abort (Part III A.2), so nobody survives
for whom poisoning would be done.
Where it is not proved that nothing crosses a thread with a value, its lock is the mutex. ADR-037 D6’s polarity, for its reason: wrong towards the mutex costs nanoseconds, wrong towards the flag costs correctness.
user_parallelism = no every lock is the cheap shape.Nothing a user writes can cross a thread there: one user thread (ADR-037 D2), and the runtime’s I/O thread carries no closure and cannot reach a user lock. The mutex would buy nothing and cost what the switch exists to save — and the cheap shape says so when a lock is re-entered, where the mutex hangs.
user_parallelism = yes the shape is decided per value, by the analysis that decides the count.A lock is reachable from two places only through a shared handle, so the answer
contracts::sharing gives the handle is the answer for the lock inside it —
one analysis, two decisions, and every one of its reasons to decline carries
over unchanged.
update is a compare-and-swap.A third answer of the same analysis, beside plain (the cheap lock) and
atomic (the mutex): word, an atomic count around an atomic 64-bit word
whose update is D13’s retry loop and whose set(…; after:) is one
compare-and-swap. A class of handles gets it where all of these hold:
atomic because of a proven crossing — a spawn body, a
parallel walk — and not because the floor answered for something unseen;SharedMut of a number, a bool or a char;access_all or update_all (D18).It never replaces the cheap shape, and at no nothing gets it.
Rc and Arc differ in cost and nothing else, which is what makes a per-value
choice invisible. A borrow flag and a mutex do not, so the mutex shape carries
an owner check and re-entering a lock says the same thing whichever shape a
value got. One behaviour, two prices. Whether that check is emitted at all is
D36’s switch.
get or set no user code runs.| door | for | the block may wait |
|---|---|---|
kasse.get() |
taking a copy out | — |
kasse.set(value) |
replacing; the value is computed outside | yes, outside |
kasse.update fn(mut v) { v += 100 } |
changing it, under the lock | no |
kasse.access fn(state) { … } |
reading it in place | no |
set needs no block: its argument is evaluated before the call, so the lock is
open for one store, and get is one load. While the lock is open in get
and set, no user code runs at all — no derivation through a sync lambda is
needed, since set has no lambda.
get and access are reads; set and update are writes. Under
ADR-292 D3 two reads of one resource are unordered, so two gets
or two accesses on one lock may run in either order, and the writing forms
keep theirs.
access reads in place, and may not change what it is handed.It hands the block the value where it lies and returns what the block computed.
It stays because nothing else reads part of a large value without copying it:
get copies, and asking a ten-thousand-entry list for its length should not.
update has one form: the block takes mut v, changes it, and returns nothing.kasse.update fn(mut v) { v += 100 }
protokoll.update fn(mut log) { log.push(entry) }
mut is ADR-094 D3’s word, unchanged: the value is changed in
place, and the caller’s value — here the lock’s — is what changes. A block that
hands a value back is NK1141; one that changes v without the word is
NK1138, asked at a door. So exactly one lambda in this language is handed
something it may change, and it carries the word every changed parameter
carries; Part I 5.3’s one lambda form is untouched.
v is, is the compiler’s, by type: a copy where the value is a word, the address otherwise.| the value | v is |
the door does |
|---|---|---|
in D8’s word class |
a copy on the stack | load the word, run the block on the copy, compare-and-swap it in; on a collision load again and run again |
| anything else | the address in the lock | take the lock, run the block once, release |
Nothing is moved out of the lock in either row and no slot is ever empty. The
program writes one form and learns nothing about word sizes or
user_parallelism from it.
update block may run more than once, and a program cannot tell.The three things that would let a program notice are already refused at every
door: the block is sync, so it does not wait; it touches no lock, so it does
no I/O and takes nothing else (ADR-288 D21); and it captures its
surroundings as views (Part I 5.3), so it changes nothing outside v. The retry
is used only where the copy is free; a block on an address runs once, because a
change to the address cannot be taken back.
On a copy nothing was swapped in. On the address the value stays as the block
left it, and D4 applies: an abort at no, poisoning at yes, where the next
door on that lock refuses.
get is the one door that copies, and --sharing says where it copies something large and which row each value got.--sharing names a get on a type that does not copy cheaply — big.get()
copies the whole String out of the lock each time; access reads it in
place — reports a word as word (its update is a compare-and-swap and
there is no lock), and says why a word-sized value that crosses kept its lock:
a door over several locks, or the floor.
Not a ledger entry: a signature binds from a receiver and the locks are
arguments of different types. Not a construct: the grammar already takes
access_all(a, b) fn(x, y) { … } as a call with a trailing lambda, and a
keyword would cost a reserved word for nothing. Not a method on one lock:
a.update_with(b) reads as first A, then B, which is the bug. The checker
types the locks and hands the block one parameter per lock; the ledger carries
the sync column, sync = "from(f)", from an entry with no signature.
access_all reads; update_all takes one mut per lock, returns nothing, and keeps the lock.update_all(konto_a, konto_b) fn(mut von, mut nach) {
von -= 30
nach += 30
}
D12 widened, not a second rule: nothing sees either lock between the two writes.
Two words cannot be swapped by one compare-and-swap on every target, so the pair
takes the locks, in D19’s order — and a value such a door names keeps the
mutex rather than becoming a word, because a retry cannot be held. The word
shape is not a door over several locks, so a program that reached one with it
would not compile rather than race. Two locks at a time; NK1124 refuses a
door handed something that is not a lock, one lock where two are wanted, or a
block that does not name one value per lock.
std sorts the locks by address, so access_all(a, b) in one task and
access_all(b, a) in another take them alike, and no cycle can form. Two locks
need not hold the same type or have got the same shape. The names stand beside
println, without a use — ADR-313 D5 gives the reason.
SharedMut directly is NK2204, and the message names the door with the value in it.kasse = 42 is refused with write kasse.set(42): the repair is mechanical,
and “use a door” would leave the reader to pick one of four. It fires where
the target is a name.
An expression has no span (ADR-081 D2), so a help line rebuilds a literal, a name and a negation and writes an ellipsis for anything else. A help line that quoted the wrong thing would be worse than one that quotes nothing.
Seen[T], and the emitter erases it.kasse.get() is a Seen[i64]; so is what access and access_all compute.
Seen[T] is a type in the checker and in the ledger’s type language (Part III
13.5), and not in the language below: no counter, no marker, no check at run
time, no bytes.
stand + 100, stand > 100, stand.abs() are stamped. A call whose argument
is stamped is allowed where the callee’s touches names no lock — it cannot put
the value into one — and its result is stamped: f"…", println, fs::write,
a response body. A callee whose touches names a lock, or is absent (touches
everything, ADR-292), takes a Seen only where its signature
says Seen.
Seen is written at two places and inferred everywhere else.A struct field that keeps a value read from a lock, because that is where the value outlives the moment it was read; and a parameter of a function that touches a lock, where a stamped value is passed in. Locals, expressions, arguments to lock-free functions and results are inferred.
NK2205, first shape: a set given a stamped value —
kasse.set(stand + 100), whether stand was read on the line above, in
another function or in another request. kasse.set(kasse.get() + 100) is
its smallest case.NK2205, second shape: a set under a stamped condition —
if stand > 100 { kasse.set(0) }. Conditions accumulate: a plain if
inside a stamped one is still under it.NK2207: an update or update_all block that assigns to its mut v
without reading it — a set through the back door. v = v + 1 reads it.There is no .value, no overwrite, and no word that turns a Seen[T] into a
T. b.set(a.get()) is refused too; the form is
update_all(a, b) fn(mut x, mut y) { y = x }, which also reads a consistent
pair.
set(neu; after: seen) is the one door for a stamped value, and it is a definition.kasse.set(neu; after: stand)
// is, by definition,
kasse.update fn(mut v) { if v == stand { v = neu } else { throw Overtaken } }
The witness covers the stored value and the condition alike, so neither
NK2205 shape applies. T must compare; the witness is a view; Overtaken is
an error like any other and carries nothing, because reading the current value
would be a second acquisition. It takes the lock once; on a word it is one
compare-and-swap, and on a large value it compares the whole value, which the
message says. The lowering’s own name, set_after, written by hand is
NK2208: there is one door to the comparison, and so none around the stamp.
yes and at no.A two-line update at no with no pause between the lines would be safe, and is
refused all the same: the refusal is about the shape, not the clock
(ADR-005 Group B).
Part II 12.3’s “often a compile-time error” becomes always (NK2203): one
door opened while another is open, directly, through a chain of calls, or by a
scope opened inside the block. With that no program exists in which the two
shapes of D4 behave differently.
sync’s call graph with the opposite lattice.One traversal, two lattices: sync is a greatest fixpoint (everyone starts
sync), this is a least one (nobody starts touching a lock). An unresolvable
call takes the sync claim away and sets this property, so fail-closed lands
on the same side for both. A trailing lambda’s body and a scope’s tasks
belong to the calling function, because they run during the call; a spawn’s
body does not. No from(f) form is needed: the check reads the caller’s own
body, where an immediate lambda is visible. Inside update, access,
access_all and update_all, a call to anything that carries the property is
NK2203. The ledger’s column is locks.
It says a lock, not which. The precise version needs alias analysis, and
then whether a program compiles would depend on whether two handles are
provably distinct. It is a different column from
ADR-292 D3’s touches, which names a lock per container and is
what D10’s ordering rests on.
The one inverted key: an absent sync means not sync, and for locks the
restrictive answer is the other way round. Reading absence as permission is
the polarity ADR-010 D1 forbids. Part III 13.5 states it as a
sentence of its own.
This language has no global mutable data, so foreign code touches only what it
reaches. Where no lock is reachable from the arguments, transitively through
fields, the call is allowed; otherwise it is NK2503, naming the call, the
argument and the field. The walk is contracts::send’s, because it already
answers undecided for an empty field list (fs::Mapped), carries a depth
limit and a cycle rule — judgements a copy would re-derive wrongly. It extends
ADR-303 D7’s first rule to locks.
It closes the callback detour: foreign code, called from inside an open lock,
calling back into Nikaia and taking a lock. A function type answers
undecided — a lambda is its captures — so this rides on D29’s body walk. A
spawned lambda is handed to our own runtime and runs later, so it is outside
this rule. D32 and D33 are the two halves of one boundary.
touches.fs::Mapped says touches = ["file read"]: mapped[i] calls nothing a
program wrote, so the fact lives on the type, as iterates does
(ADR-025 D6). It is written by hand and never inferred. Silence
is not a claim: a type with no touches is not refused on, because what is
built on it is a refusal (Part III C.4).
NK2201.Both shapes of read, a method and an index. The message names the page fault,
says why NK2202 (a pause) and NK2203 (a lock) are silent, and hands over the
way out: read what the block needs before the door. Part II 12.2’s no I/O
while holding locked data has a code for each of its three cases.
reentrancy-check in [build], yes or no, yes by default.Under D28 it cannot fire in a correct compiler; it is self-control of D28,
not error handling, and without it a compiler hole shows as a silent hang.
For every program that obeys D28 both builds behave identically. It is a
guarantee that can be declined (ADR-292 D7), not a debug aid. It
lives in the manifest, with no command-line override, because a shipped
build that could be changed from the command line is not reproducible; and in
[build] rather than the runtime settings, because it changes what the
compiler emits. A third spelling is refused, not guessed at. Part I 1.2 names
the build options rather than counting them.
std’s own tree.A switch is a cache-key dimension (ADR-037 D4), which adds one to
ADR-021 D5’s enumeration. And because the check lives in
nikaia-std, the word decides what that crate contains: without a tree of its
own, a second project would link the first one’s answer
(ADR-021 D7).
The mutex shape carries a mark this compiler wrote, and that is what the switch
declines. The cheap shape is a RefCell, whose borrow flag notices a
re-entrant borrow whatever anyone asks: the language below’s, not this
option’s.
SharedMut made by a call, captured by tasks
(ADR-312 D7), changed through update and update_all.Overtaken exists only where a program wrote
after:.update, with mut. A program
that changed a large value in place says update and the word; push on a
large value is one line, a lock and the push.NK1123 (D3), NK1124 (D18), NK1141 and NK1138 at a
door (D12), NK2201 (D35), NK2203 (D28-D29), NK2204 (D20), NK2205 and
NK2207 (D25), NK2208 (D26), NK2503 (D32).contracts::sharing carries twice the
weight — a missed crossing is now unsoundness for the lock as well as for the
count (D7); a coarse lock property refuses some programs a precise one would
accept (D30); Seen[…] is written at a field that keeps a read value and at
a parameter of a lock-touching function (D24).The numbers that decided:
no, no system
call — what D4 and D6 decline.word row uncontended, several times
less under contention, and ×6.5 worse than the cheap single-thread shape
— why D8 never replaces the cheap shape.Vec into and out of a block, whatever its length
— why access needs no mutation.Locked[T] outside a Shared. It has one owner and takes the
floor because the analysis has no entry for it — safe and wasteful.Locked[Shared[T]], a lock around a handle: a different type, probably
useless, still permitted.std::lock), and which
pairs of types a target could swap in one step.yes — recover, reset, or end the process — a
runtime setting rather than a door; whoever offers recover offers a copy
taken before the block.; or
doors of their own.a.set(x; after: b_seen)), a version
counted at run time for a large witness, and what Seen means to a
foreign crate, whose reviewed touches line (ADR-290)
answers for it.catch that retries Overtaken and lets another failure past.a.b = 42 with a hull behind b:
NK2204 asks of a name only.locks column cannot answer for without
refusing correct programs, and widens the day the type grammar’s function
types are kept by a callee.touches (D30).fs::Mapped outside one (D35).