ADR-209: The Tether Is Built — a Buffer Lives in the Keep of Whatever Keeps Its Views, and the Compiler Decides Which

Status: Superseded by ADR-283 — its D1-D4 are D9-D12 there, D5 is D4, D6 is D13 Date: September 24, 2026 Target Version: Nikaia 0.0.185 Answers: the owner’s instruction to take the tether — the first of the README’s design risks — in one step: invent the programs that need it, check the design against them critically, and build all of it, with the complexity in the compiler and the best representation per case, because the compiler knows the facts that decide it Refines: ADR-008 D2–D4 (the representation); withdraws ADR-201 D2 and D3 (@tethers) — see D5 Related: ADR-156 D4 (the refusal this replaces), ADR-094 (keeps, which D6 reads and ADR-207 D4 repaired), ADR-037 D7 (a decision the compiler makes per value and shows rather than asks for — the model D5 follows)

1. Context

A body reads a file, cuts it into views, and something keeps the views longer than the text would live: the result, a mut parameter, a list outside the loop, a task. ADR-008 called that value tethered — the buffer has to live as long as the views — and decided the analysis, which was built. The representation was not: every such program was refused (NK2303, ADR-156 D4) or, where the old walk could not follow the views, handed to rustc, which answered about a file nobody wrote.

Eight programs were written before anything was built, one per shape a program meets (crates/nikaia/tests/tether_keep.rs holds them):

  shape before
loader a function hands back the settings it read refused, and the refusal blamed path for a view of the file (§6)
chain a view travels two frames up; the reader pauses after reading refused
many a loop over files keeps every list refused
merge recursive includes into one map through a mut parameter refused
task the settings go into a task refused
cache a loop keeps a map that drops entries rustc: text does not live long enough
no_word the loader with nothing written above it rustc: cannot return value referencing local variable
mapped one line of a mapped file handed back rustc, the same

2. What the programs said about ADR-008’s representation

ADR-008 D3/D4 sketched one representation for every case: a handle plus (offset, len) per view, three layouts per struct, and a buffer table. Checked against the eight programs, that is the wrong shape of answer — not wrong in any one case, but one answer where the facts differ:

3. Decisions

D1 — A buffer lives in the keep of whatever keeps its views.

When views of a buffer outlive the scope that read it, the buffer does not stay in the local that read it: it moves into a keep (nikaia_std::tether::Keep), an append-only store in which nothing ever moves. The views stay plain &str, checked by rustc against the keep’s lifetime.

D2 — The keep belongs to a frame wherever a frame outlives the views.

This is region inference without a region name — ADR-008 D9’s promise that the guarantee explicit regions offer is delivered by inference.

D3 — A handle travels with the value only where no frame outlives it.

A task (spawn) outlives every frame the compiler can name. There, the function declares one shared keep behind an Arc, the buffers go into it, and each binding the task takes is packed beside a clone of the handle in a type the compiler writes: its views are 'static only while packed, and its get hands them out for as long as it is borrowed — a coercion rustc accepts only where the type is covariant, so the shortening is checked, not trusted. One count per task, never per view.

D4 — A container that drops entries holds each view with its own handle.

Where a local declared outside a loop keeps views of buffers read inside it, and something removes entries from it (clear, remove, drain, pop, …), each buffer gets a keep of its own and each view kept is a Held: the text and a handle on its buffer, compared and hashed by content (ADR-008 D4). A buffer is freed when the last view of it leaves the container. Without this, one keep for the loop would hold every buffer the loop ever read.

D5 — No word is written for any of this. @tethers is withdrawn.

ADR-201 D2 made a tether an error unless the struct said @tethers, so that keeping a whole buffer alive would be visible where it is declared. Built, that meant a refusal that knows exactly what to do, a word on functions that return a bare view, and a word on every struct a parser yields — which is the bookkeeping the README’s first section says this language exists to take away: you want to keep a name that points into a buffer you already have; instead you write lifetime annotations.

So the decision is the compiler’s, like which count a Shared gets (ADR-037 D7), and it is shown rather than asked for: nikaia --tethers prints where each buffer lives and why, and the ledger’s views column makes a change a diff in review. The cost ADR-201 worried about — a small view keeping a large buffer — is real and is named in that output; it is not a reason to make every program say something the compiler already knows.

D6 — The views are followed, not guessed at.

The analysis (contracts::keep) follows origins: every local carries the set of buffers its views may point into, through methods, fields, indexing, for, literals, push/insert, calls to functions that themselves take a keep, and the holes of an f"…". It stops at anything that hands back a value of its own — to_owned, len, a call whose declared result holds no view — and a buffer handed on whole is a move, not an escape. It errs towards a keep: a keep nobody needed costs a buffer living to the end of a scope; a refusal nobody needed is a correct program refused.

4. What is still refused, and why each is a real boundary

5. What this changes elsewhere

6. What is built

All of it, in 0.0.185: nikaia_std::tether (Keep, forever, Held, hold, the two unsafe functions and their contracts), contracts::keep (the plan and its fixpoint over a package), the emitter’s signature, preludes, puts, keep arguments, packed bindings and held views, --tethers in words, and crates/nikaia/tests/tether_keep.rs — the eight programs compiled and run at both settings of user_parallelism, a case that must stay untouched, and the shape of each representation. A package boundary was checked by hand: a library whose load tethers, and a program using it.

This site is open source. Improve this page.