Status: Superseded by ADR-283 — its D1-D5 are D14-D18 there
Date: September 26, 2026
Target Version: Nikaia 0.0.202
Answers: open-decisions.md’s a container of
structs holding views that drops entries in a loop — option 3, the owner’s
decision, with the follow-up questions answered from the project’s own rule:
the complexity goes into the compiler, kind to resources and to the
programmer, and only the special case pays
Refines: ADR-209 D4 (a container that drops entries holds
each view with its own handle — now a struct of views as well) and §4 (the
refusal this replaces); ADR-218 D3 (tether::Holding, now over
keeps rather than one Arc<str>)
Related: ADR-037 D7 (a decision the compiler makes per value
and shows rather than asks for), ADR-094 D4 (a read through the
brackets is a view), #84 (the emitted code writes no
unsafe)
NK2304 refused a program that keeps structs holding views of text read inside
a loop, in a container that also drops entries:
struct Record { name: ref String, size: i64 }
fn main() throws {
let mut kept: Vec[Record] = Vec()
for path in paths() {
let text = fs::read_to_string(path, fs::Root::Anywhere)
kept.push(Record { name: text.trim(), size: text.len() as i64 })
if kept.len() > 100 { kept.remove(0) }
}
}
ADR-209 D4 held text this way — a Held per view — and refused a struct,
because every read of its fields would have to go through a handle. Three
options were on the page: keep the refusal (a .clone() per view field),
lower each such struct a second time with Held fields, or hold each element
beside its buffer (tether::Holding). The owner took the third, and asked
three questions of it before it was built:
let old = kept.remove(0);Each is answered below by the same rule rather than by a new one. Checking the answer against the compiler also found a defect in D4 itself (D5).
Where ADR-209 D4 applies — a local declared outside a loop keeps views of
buffers read inside it and drops entries while the loop goes on — an element
that is a struct of views becomes a tether::Holding: the struct, and a handle
on the keep of each buffer it points into. The struct’s own declaration, its
methods and every other use of it are untouched; only the container’s element
type changes, and only in that container.
For each struct of views in a unit that holds anything this way, the compiler
writes three short items — Views (the struct over any lifetime; its body
compiles only where the struct is covariant), Rebase (one call per field that
holds a view) and Hold (put the value into a Holding). None of them is
unsafe. Text is held the same way: tether::hold, an unsafe fn whose
caller promised where the view points, is gone, and a held view is
Held::new, which asks the keep instead. The emitted code for D4 writes no
unsafe at all.
An element carries a handle on each buffer its views point into. Which
buffers those are is the plan’s origin analysis (ADR-209 D6); every element of
one container carries as many handles as its widest value needs, because the
elements are one type, and a value with fewer repeats one. --tethers says how
many.
The plan’s claim is checked, not trusted. A view is carried into the
element by Keep::find: the keep looks for the view’s bytes among its own
buffers by address and hands back the same bytes, borrowed from itself. A view
that points into none of them — a literal in a view field, or anything the
analysis did not follow — is copied into the element’s first keep. So the
program stays sound where the plan is wrong, and the one case that allocates is
the case that points somewhere the element does not hold. The common record,
all of whose views are cut from the one file it was read from, copies nothing.
let old = kept.remove(0) hands back the Holding, which carries its handles
with it, so old needs no copy and no new keep. It is read through its handle
exactly as an element in the container is (D4). This is ADR-209 D3’s rule
applied again: a handle travels with the value where no frame outlives it.
A removal is a removal wherever it stands. The analysis saw kept.remove(0)
only as a statement of its own; bound by a let, it was missed, and the
container got one keep for the whole loop — no unsoundness, but every buffer
the loop ever read kept alive to the end. Both positions now count.
for, and iter, first, last and get on
such a container hand out the struct through Holding::get, over no longer
than the read — the same &Record a list of plain structs lends (ADR-094
D4). A method of the struct is then called on that as on any other.kept[i].field = value, and the compound forms, are written
through Holding::with_mut: a closure handed the struct and its keeps over a
lifetime it cannot name, so that it can store only what lives as long as the
keeps — a number as it is, and a view found again in the keeps first (D2).
rustc refuses a view of anything else inside that closure, which is the
whole argument; the crate carries it as a compile_fail example.D4’s condition is that entries are dropped while the loop goes on reading. The analysis asked only whether the container drops entries anywhere, so
for path in paths() { … kept.push(Record { name: text.trim(), … }) }
kept.clear()
was treated as the pruning case, and for a struct refused. A handle per view frees nothing earlier there: every entry lives until after the loop either way. The removal now counts only if it stands inside a loop that reads the buffer and that the container is declared outside of. Otherwise the buffer lives in the function’s keep (ADR-209 D2) — no count, no handle, no refusal.
A value can be held where every view in it is text (ref String), a nullable
of one, a Vec or List of those, or a struct without type parameters made
only of such fields — recursively. That is what Rebase is written for, and
every such struct is covariant, which is the one further thing Holding needs.
Anything else is refused by name (§5).
The records program, in Rust: tether against the copy option 1 would have
written (.to_owned() per view field). Every line of a file is a record, the
last 100 are kept, the file’s own read is in both columns. Nanoseconds per
record, best of five, on a 4-vCPU shared VM:
| records per file read | one view per record: held | copied | three views per record: held | copied |
|---|---|---|---|---|
| 1 | 122 | 49 | 131 | 84 |
| 10 | 42 | 36 | 51 | 68 |
| 64 | 35 | 37 | 45 | 69 |
| 1000 | 32 | 35 | 46 | 72 |
The cost of holding is per buffer: an Arc<Keep> and the buffer boxed in
it, which is ADR-209 D4’s own cost and unchanged. Per record it is one handle,
an atomic count, and one lookup by address per view, with no allocation. A copy
costs an allocation per view. So holding wins where a parser is: many
records per file, several views per record (about 35 % at three). It loses
where each file yields one record, because the copy never pays for a keep at
all. That case is the rare one and pays about 70 ns, and the common one is the
one that got cheaper. The first buffer of a keep takes neither a lock nor a
list, which is the shape of every keep D4 makes; before that change the first
row was 170 ns.
What holding costs in memory is the other side, and ADR-209 D5 names it
already: an element keeps its whole buffer alive, so one small record can keep
a large file. --tethers says which container does it.
Refused with NK2304, each naming what it met:
Not built, and not refused by this compiler (the backend answers):
ref mut self method on an element reached through the brackets. Not
built for a list of plain structs either — kept[1].grow(3) is rustc’s
cannot borrow data in a & reference as mutable today, held or not.
with_mut is the shape it would take here.Holding, and the other side expects the struct.open-decisions.md is empty again. The container-of-structs refusal is gone
from Part I 6.6 and from Part III’s NK23xx row. NK2304 keeps its two other
cases. The emitted code for a held view or a held struct contains no unsafe;
what is left of #84 is a task’s packed values
(tether::forever, ADR-209 D3).
All of it, in 0.0.202:
crates/unsafe/tether: Keep::put_viewed, Keep::find, Keep::view and
the Viewed trait; Held::new (and hold removed); Keeps, Rebase,
Hold; Holding<V, N> over N keeps, with with_mut. Tests and Miri under
both aliasing models, as ADR-218 requires; nikaia-std implements Viewed
for Mapped and Bytes.contracts::keep: removals inside the reading loop only (D5), removals in any
position (D3), several buffers per value and the width per container (D2),
which containers hold structs and which locals were taken out of them, and
the refusals of §5.put_viewed for a keep of its own, .hold([…]) for what goes
in, the Holding element type, the three items per struct, reads through
.get(), writes through with_mut; --tethers names a held struct and its
handles.crates/nikaia/tests/tether_keep.rs: the records program (brackets, for,
a method, last), two buffers, a literal copied in, an element taken out and
fields written, entries cleared after the loop, and the refusal of a map — each
compiled and run at both settings of user_parallelism.