Binding leases

An exarch agent’s top-level scratch names expire when the model stops using them; nothing else about binding does. The model treats top-level let as scratch paper and never erases, so a long session accumulates dead strings, forgotten closures, and settled handles still holding captured output. The binding-lease ledger (core/src/types/shell/bindings.rs, armed only by exarch) removes a name once it has gone 256 ral calls without use — and does nothing else: ral’s semantics are untouched, the REPL never expires anything, and a pruned name afterwards reads exactly like a name that was never bound. agent-binding-reaping is the decision record; this page is how it runs.

What is leased, and what never is

Only names the model itself binds at the session’s top level. Everything visible when exarch arms the ledger — prelude, agent library, rc and host seeds, and on a fork the entire inherited parent scope — is sealed as baseline, permanently exempt (Shell::arm_binding_lease, called where each agent’s shell is installed, so /clear’s rebuilt shell re-seals for free). Bindings made inside blocks, lambdas, use bodies, or letrec fixpoint frames are invisible to the ledger by the same predicate that classifies installs (Env::at_session_scope at the install_scope_binding chokepoint): they die with their frame anyway.

The clock, and what counts as use

The ledger ticks once per committed source turn — in exarch, exactly once per ral tool call. No wall clock anywhere: a quiet weekend ages nothing.

Use is read off the program text, never off the running lookup path. When a turn compiles, an exhaustive walk over its typed IR (ir::referenced_names) collects every variable occurrence and command-head name and renews those entries; the same harvest runs when source/use compile code mid-turn, and a dispatch-time touch (classify_command’s Resolution::Env arm, BindingLedger::renew_one) catches command heads that resolve to a binding at runtime. Writing a name is also using it — a rebind restamps at the chokepoint. A turn that fails to parse or typecheck ticks the clock but renews nothing: failed turns age your scratch. Listing (/resources, any enumeration) renews nothing either — enumeration is not observation.

What pruning does — and deliberately does not do

At each ready boundary the agent’s drive loop calls Shell::prune_idle_bindings (beside the worker-reap drain, Agent::reap_bindings). Every entry idle past the bound is examined: a name whose value still structurally reaches a running worker handle is pinned and re-checked next boundary (pins_running_work — it recurses lists, maps, and variant payloads, and deliberately never looks inside a closure’s captured environment); everything else is unset — value and type-scheme seed in one act — and one dim transcript card names what fell. The verb returns the prune notices together with a post-prune MobileSnapshot the host must adopt as its panic-recovery checkpoint, so a later panic rollback cannot resurrect a pruned name; the pairing is the signature, not a discipline.

What it does not do: it never frees memory by itself (it removes the future name, not the bytes), it never touches a closure, and it never tells the model anything at prune time — the only model-visible consequence is an ordinary undefined variable if the model names the binding again, with the paper trail waiting in transcript.jsonl.

The capture scenario, worked

The case that looks alarming and isn’t:

let big = <5 MB of text>
let f = { … $big … }        # then f is called every turn

f renews every call — its name is in each turn’s text. big does not: its reference lives inside f’s stored body, compiled once on the turn that defined f; later calls recompile nothing, so nothing harvests big again. After 256 idle calls the live name big is pruned — and f keeps working, forever. A closure captures an Arc<Env> snapshot at creation and resolves its body against that captured chain; the live scopes are copy-on-write, so unsetting big in the session scope cannot reach what f already holds.

This is correct, not a near-miss: once captured, the top-level name big routes nothing. The value’s real owner is f’s capture, and f — the thing actually being used — is the thing whose lease renews. The cost is memory, not correctness: the captured bytes stay resident until f’s own name falls or is rebound and the capture drops. That residency is exactly what the large-binding warning exists to head off — bind a file path, not five megabytes of captured text.

One asymmetry is observable and blessed: command-position use inside a running body renews; value-position use does not. The dispatch-time touch is name-keyed and fires while a closure body runs (a thunk body shares the shell’s host-local state by identity), so if big is a block invoked as big args inside hot f, every call renews the live entry — while $big resolves through the captured chain and touches nothing. A false renewal only ever lengthens a lease; the harvest over-approximates in the safe direction throughout.

Where it sits in the decay ladder

Abandonment decays in layers, each with its own lease and its own log line: a worker unobserved for an hour is reaped; its settled registry entry expires after 256 unclaimed calls; and a name holding a settled handle — settled handles are ordinary scratch, and the worker registry retains the handle itself, so no name is ever load-bearing for rediscovery — prunes after 256 idle calls. The model’s later “where did my job go?”, at every layer, has an answer in the log.

See also shell-state (the ledger’s home on LocalState), agent (arming sites and the boundary drain), probe-convention (the /resources rows the ledger answers), and output-capture-and-detachment (the worker half of the same story).