ADR-221: A Container of Structs of Views That Drops Entries Is Held, One Handle per Buffer

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)

1. Context

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:

  1. a struct whose views point into two buffers;
  2. an element that leaves the container — let old = kept.remove(0);
  3. methods on the struct, and writing its fields.

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).

2. Decisions

D1 — An element of such a container is held beside its buffers, and the struct is lowered once.

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.

D2 — One handle per buffer, found by address; what points into none is copied, and only that pays.

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.

D3 — An element taken out of the container stays held.

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.

D4 — A held element is read and written through its handle.

D5 — Entries dropped only after the loop, or before it, are not dropped while it goes on.

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.

3. What a held element may contain

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).

4. Measured

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.

5. What is still refused, and what is not built

Refused with NK2304, each naming what it met:

Not built, and not refused by this compiler (the backend answers):

6. Consequences

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).

7. What is built

All of it, in 0.0.202:

This site is open source. Improve this page.