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)
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 |
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:
loader, chain,
many, merge and mapped all hand the views to a caller that uses them and
returns. No handle is needed at all: the caller can own the buffer.many and merge
keep views of many files; an append-only keep that moves nothing holds them
all, and a view stays a plain &str. The buffer table was a keep in disguise.task), and a
container that drops entries while the loop around it goes on reading
(cache), where one keep for the loop would keep every buffer it ever read.(offset, len) makes
each entry.key a slice of the container’s buffer, and an eight-byte view
instead of sixteen is not worth a second layout and a branch on every read.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.
mut parameter or its
subject takes a keep from its caller — the last parameter, __keep:
&'k Keep — and its signature carries 'k on exactly those positions. The
ledger records it (views = ["<result>: tethered"]), so it crosses a package
boundary like every other column.This is region inference without a region name — ADR-008 D9’s promise that the guarantee explicit regions offer is delivered by inference.
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.
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.
@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.
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.
NK2304):
there is no one owner — the task may outlive the caller and the caller the
task. The way out is a copy for the task.NK2304): D4 holds text one view at a time; a struct would need every read
of its fields rewritten through a handle.NK2303 no longer fires: a view of a buffer the body owns is tethered.NK2302 no longer blames a parameter for a view of text read from it: a
buffer of its own ends the chain (fs::read_to_string(path) hands back a
String, not a piece of path).?? with a text literal after a view of text no longer asks the language
below to infer a conversion (m["host"] ?? "-" over a map of views said type
annotations needed before).NK2303/NK2304 on lines of their own; NK2303
used to be counted twice.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.