Skip to content

Add a control port that replaces a running program with a new version of its source - #121

Draft
dpmills wants to merge 18 commits into
dmills/filtered-mut-accumfrom
dmills/live-update
Draft

Add a control port that replaces a running program with a new version of its source#121
dpmills wants to merge 18 commits into
dmills/filtered-mut-accumfrom
dmills/live-update

Conversation

@dpmills

@dpmills dpmills commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

A Cambra program that accumulates state and serves endpoints has to be restarted to change its logic, which drops the state and the open sockets with it. This adds --control (default 8081): /diff reports how a new version of the source differs from the running one and changes nothing, and /update installs it — the listeners stay bound, every binding whose computation is unchanged keeps its operator and everything that operator has accumulated, and a variable whose logic the edit did touch resumes from the value it was holding. An update that could not take over the state is refused, with the running program still serving.

src/ccl/design/live-update.md is the design of record; "The model: every carrier has its own cut" and "Rebuilding a store resumes it" are the two sections to read first.

What a version hands its successor

Three carriers, each cutting at its own point — there is no instant the whole graph stops at.

  • Endpoints. SourceSinkRegistry (src/ccl/context.rs) holds the open sources, the reply sink of each route, and the listener behind each port. Every compilation seeds a fresh LoweringContext from it, so an http_serve naming a route already held binds it instead of opening a second listener on the same address. A route the new version stops naming is retired to a 404, and the port is released once its last route goes.
  • Operators. Adopted by the α-invariant resolved_hash of the term a Let or Transact binds, and hereditarily: an operator is taken only if every binding it reads was taken too, so a carried-forward operator is never left reading a subgraph the update rebuilt.
  • State. Travels per variable rather than per store, under VarPath — the variable's own spelling plus its index among same-spelled variables in tree order. The value carries wherever the variable goes; the position carries only when both versions count it in the same Sequence.

The refusal

state_conflicts runs against the new version's planned tree, before a single operator is built from it, so a rejection leaves the running program whole. Two shapes reach it: a variable the new version no longer declares, and one it declares at a different type. Everything else is accepted, including a variable moving to another loop or into a transaction — it seeds with the value it held and starts its new count from scratch.

A write set keyed by the variable it writes

A writer decision's writes was a positional tuple, which was enough while a store's slots were only ever read by the compilation that made them. Carrying state across compilations needs a slot that names its variable, so the write set is a record keyed by the accumulator's own spelling and plan_loops names each store key after the program's variable rather than minting acc0/acc1 from position. That is the change spanning mut_elim.rs, transact_phase.rs, planning/loops.rs, and infer/emit.rs.

Where to look hardest

The releases. A subscriber registering after a swap must not claim to still want data the fan-out has already told its input will never be read again, and must not skip data no one has finished with. One rule, two implementations: FanOut seeds a new subscriber's guard from what it has already released upstream, and withholds an operator released in full from the next version altogether. ProducerReleases (src/interpreter/producer_releases.rs) is that rule for a source's per-producer records — UIntStreamBuffer and TestDataSource each kept their own copy of the bookkeeping and now hold one of these, and the trait's two handover methods lost their defaults so no source can silently fail to carry.

Two smaller ones. Source registrations are Weak (src/interpreter/scheduler.rs), so a registration lasts exactly as long as the producer it wakes. And compile_program notifies each sink once its producer slot is filled — step 6 of "Order of an update", without which an update installed while work was outstanding sat unpulled until the next arrival.

Tests and docs

37 cases in tests/programs/live_update/, all against a program that is actually running. The gallery pair is a guestbook and the same guestbook with its accumulating loop edited; every other program is inline scaffolding differing from a base by the single edit its case is about. The module doc tabulates what an update may and may not do, change by change; the README's new Control port section is the same list from outside.

The differ's stage is renamed phase, matching the Phase the control port's phase= parameter selects.

@dpmills
dpmills force-pushed the dmills/live-update branch 4 times, most recently from 38cec77 to 3c65ab9 Compare August 21, 2026 22:58
@dpmills
dpmills changed the base branch from dmills/program-diffing-scoped to dmills/stream-buffer-trim August 21, 2026 23:17
@dpmills
dpmills force-pushed the dmills/live-update branch 2 times, most recently from 941e0e3 to 3a5d4e8 Compare August 21, 2026 23:50
@dpmills
dpmills force-pushed the dmills/live-update branch 3 times, most recently from 21866de to 8687ae2 Compare August 22, 2026 00:44
@dpmills
dpmills force-pushed the dmills/live-update branch 12 times, most recently from 93c5e0a to 37d3e4e Compare August 22, 2026 21:56
@dpmills
dpmills force-pushed the dmills/live-update branch from 37d3e4e to 279c53c Compare August 25, 2026 18:33
@dpmills
dpmills force-pushed the dmills/live-update branch from 279c53c to bb2f645 Compare August 25, 2026 22:00
@dpmills
dpmills force-pushed the dmills/live-update branch from bb2f645 to c67841a Compare August 26, 2026 21:42
@dpmills
dpmills force-pushed the dmills/live-update branch from c67841a to 694a665 Compare August 26, 2026 21:51
@dpmills
dpmills force-pushed the dmills/live-update branch from 694a665 to 5842c10 Compare August 27, 2026 17:40
Base automatically changed from dmills/stream-buffer-trim to main August 27, 2026 17:45
@dpmills
dpmills force-pushed the dmills/live-update branch 4 times, most recently from 42cd27b to 8c8d5db Compare August 28, 2026 05:40
@dpmills dpmills changed the title Add a control port that diffs a running program against a new version and swaps it in place Add a control port that replaces a running program with a new version of its source Aug 28, 2026
dpmills added 18 commits August 28, 2026 14:39
…ition

A mutation loop's accumulators reached the store as an unlabelled tuple, so nothing downstream could say which slot belonged to which variable. `mut_elim::fold_induction_loop` collects `(Name, Type)` per accumulator and then keeps only the types; `planning::plan_loops`, handed the result and nothing else, mints `acc0`/`acc1` from position to have something to key the mutable-variable record by. Everything after that reads the accumulator by a label the program never chose.

The commit path already does this the other way — a transactional variable's key is its own spelling — so the two writer kinds only differed in whether the write set carried names. They no longer do.

`writes` is a `Type::Record` keyed by each written variable's `field_key`, in `mut_elim` (induction) and `transact_phase` (transactions) alike, with `infer::emit` constraining the same shape. The reads follow: `__hist ≫ .writes ≫ .i` becomes `.writes ≫ .acc`, `plan_loops` takes each `TransactKey`'s name from the record label instead of inventing one, and `body_decision_at` decodes the set in `write_keys` order by name.

`KeyReadInfo.index` — "the key's position in the writer's `writes` tuple" — is deleted. Both of its readers already ignored it: a commit read is keyed by `runtime_key` and a dense induction read folds by `runtime_key`, so position had no remaining consumer.

Two `transactions.rs` assertions move off `acc0` and onto the names the programs use (`cnt`, `acc`), which is the visible half of the change.
… and swaps it in place

Changing a running program means restarting it, dropping its open sockets and everything its operators accumulated. This adds a control port (`--control`, default 8081) serving `/diff` and `/update`: a new version inherits the running program's endpoints and may add to them, keeps the operator behind every binding whose computation is unchanged, and resumes each mutable variable from the value it was holding — so an edit swaps in place while the rest keeps serving. [live-update.md](src/ccl/design/live-update.md) states the model; the sections below are a reading order over it.

Read the first commit first: keying a writer decision's write set by the variable written, rather than by position, is what lets an accumulator's carried state be named by the spelling its author chose.

`SourceSinkRegistry` (`src/ccl/context.rs`) holds the open sources, each route's reply sink, and each bound port's listener on `GlobalContext` rather than per compilation, so a replacement binds a route the registry already holds — keeping its listener and everything buffered behind it — and opens one it does not. A version that stops serving a route retires it from its `SharedHttpServer`, so the address answers 404 rather than buffering requests for a reader that no longer exists.

`LiveProgram::update` reads the variables the new version declares off its planned tree and refuses if the running program holds one it no longer declares, declares at a different type, or declares under a different store — the three shapes `StateConflict` names. Everything else is allowed. Only a value with nowhere to be seeded from is refused, because that is the one failure an author cannot observe: the program carries on answering and only the accumulated history is gone.

Conversion keys each `Let` binding and each `Transact` store by the `resolved_hash` of the term it realizes and adopts the matching one. The key is stable across compilations, so an unchanged part of a program is recognized on that program's *first* update; heredity is tracked separately, by the set of bindings this compilation rebuilt.

A carrier that holds nothing takes its cut from its source's agreed release; one that holds a value takes it from its own frontier. Crossing those two is what both defects here were, and the design doc tabulates every carrier.

A rebuilt store therefore resumes rather than restarting. Its value already rides its own cyclic fan as a `Tile::Store`, so `live_state` reads each carried key off `FanOut::cached_tile` — no second channel out of the operator, which is what keeps this off the ledger `ci_shared_state` maintains. Each store hands its replacement one `CarriedState`: the values and the position they were read at, together, because seeding from one position and resuming at another decides a position twice or skips it. Both defects were a position taken from the wrong side of that — a resuming `DriverWindow` left based at `0`, and `TransactDriver` naming the item it was attempting by its index among the source's offered columns and never releasing a finished one, so a replacement re-attempted every committed transaction. Items now carry absolute source positions and are released on the commit-ack, which also bounds a transactional source's buffer.

Sharing an operator across a swap requires knowing which subscriptions are still real, so a subscription now lasts exactly as long as its subscriber: `FanOutShared::subscribers` and `Scheduler::add_source_handle` hold `Weak` references, and `SinkConsumer::detach` clears the producer slot. This changes behavior for every program, not only an updated one — a dead fan-out slot no longer pins the release-guard intersection.

Nothing on the interpreter side is `Send`, so the `src/control_port.rs` server thread only parses a request and blocks; the main loop services one per tick. `LiveProgram::update` compiles to `Phase::Planning` and runs the state guard before touching the running program, then tears down and recompiles. `main.rs` becomes a driver over `LiveProgram`.

`tests/programs/live_update/` has 23 cases and a module doc tabulating its base programs against what each edit shape is expected to do. Eighteen drive six base programs over real HTTP, each variant named for the one edit that distinguishes it from its base; five drive the binary as a subprocess over `stdin`, covering a program whose output is its `main` value rather than a sink. The regressions for defects found while building this are worth reading first: `an_edit_to_the_accumulating_loop_takes_effect`, `reuse_does_not_depend_on_how_many_updates_came_before`, `a_store_resumes_however_far_its_source_has_advanced`, `a_transactional_variable_survives_an_edit_to_its_writer`, `a_transaction_writer_does_not_replay_what_it_committed`, and `reordering_two_accumulators_does_not_cross_their_state`. `UIntStreamBuffer`'s own unit tests pin what a producer registering after the swap reads.
`Phase` is the type, `phase` is the parameter every entry point takes, and
`phase=` is what the control port reads, but `src/ccl/diff.rs` and
`src/ccl/design/diffing.md` called the same thing a stage across 47 lines —
including `diff_programs`'s own `stage: Phase` parameter and a
`# Stage-agnostic` module heading. One name per concept, and this is the pair
that already disagreed with itself: `diffing.md`'s test table said "at every
phase" while the test it named was `every_stage_diffs_identical_source_as_identical`.

The test is renamed to match, and `### The one that needs more than a stage`
becomes `### ... than a phase`; neither had an inbound reference, which
`./ci.sh doc_refs` confirms.

One site was the other sense of the word: `let staged = compile_to(src,
Phase::Planning)` meant "prepared", not "a pipeline position". It is `planned`
now, after the phase it stops at, so the word is free.

`stage` remains elsewhere in the tree in both senses — `context.rs`'s per-phase
registries and error variants are the pipeline position, while `planning stages
the carrier's writer sources` and `groupby`'s single- versus multi-stage key
extraction are the verb and an unrelated internal sequence. Converting those
needs a judgment per site rather than a rename, and none of them is what this
branch touches.
`/diff` unregistered any route the version it compared against had stopped serving. A diff compiles the new version against the *running* registry, so the listener it sees is the running program's, and `run_frontend` retired routes on every compile — a question changed what the program served, and the address it answered on started replying 404. Retiring belongs to installing a version, so it moves to `compile_program`, past the point a scratch compile reaches.

## A port is held while a route is registered on it, and no longer

`SharedHttpServer` moved its `tiny_http::Server` into the dispatcher thread, so nothing could reach it to stop the loop: the doc's "runs until the server is dropped" was untrue and dropping the handle released nothing. It holds the listener alongside the thread now and unblocks it on drop, which runs the shutdown path the loop already had — the one `register` documents by sending `None` to every route. `release_unrouted_ports` then drops the listener for a port no route remains on, and `absorb` takes the lowering pass's listeners rather than copying them so the registry is the only owner and the drop really closes the socket.

A retired route whose port still serves a sibling answers 404 as before; only the port's last route takes the port with it.

## A fan-out reclaims its dead subscriber slots

`FanOutShared`'s three parallel slot vectors were append-only, because a producer addresses its release guard by index and renumbering would point it at another subscriber's slot. Every update therefore left one dead slot per replaced subscriber, forever, and both the notify walk and the release intersection scan them. The index now lives in a `Cell` the producer and the registry share — the same object as the liveness token, since the slot number *is* the subscription's identity — so `reopen` drops the dead slots and writes each survivor its new number. It runs at a version handover with the graph already torn down, which is what makes renumbering safe.

## What the docs claimed and the code did not

- `bind_store` described the correspondent as "a value unique to this compilation when rebuilt". It is the term's identity either way, exactly as `LetBinding::correspondent` is, and `rebuilt` is what keeps a term reading a rebuilt store from adoption.
- Two doc blocks belonged to items that no longer exist: `inherited` carried the doc of the map `Inheritance` replaced, and `state_conflicts` carried `into_inheritance`'s.
- "Order of an update" named `Endpoints::Inherited`, which does not exist, and listed two compiles where an accepted update runs four. The section now says which four and why the guard's tree cannot be the one that gets built.
- `StateConflict::Moved` named an arbitrary one of several stores declaring the name, so the diagnostic varied with hash order.

## What `live_state` may skip

It walks the same stores as the guard under three more conditions, and a store the guard promised to carry that this drops is the outcome the guard exists to prevent. An absent cached tile is now an assertion, since a store's fan is cyclic and a cyclic fan has one. The other two are ordinary and stay skips: a store that has decided no position has no frontier, and a variable no position has written has no value at one. Writing the assertion is what found that out — the frontier case fires on an empty store.

## Tests

`a_request_that_arrived_before_the_swap_is_answered_after_it` covers the end-to-end half of the release carry, which only `UIntStreamBuffer`'s unit tests reached. `the_control_port_answers_a_diff_request` covers `/diff` over the wire — both ways of carrying the source, the leading `phase=`, and the 400 for a phase the port does not offer — which no case reached; `raw_http_response` exists because `raw_http` drops the status line, and the control port's contract is partly the status. `a_port_whose_last_route_goes_is_released` is the first case on two ports. `diffing_against_a_version_that_drops_a_route_does_not_retire_it` and `reopening_a_fan_out_drops_dead_slots_and_renumbers_the_rest` pin the two changes above.

`launch_under_control` is split out of `stdin_across_update`, which was spawn, feed, update, and collect in one function; a test that only asks the control port a question needs the launch and none of the rest.
…ey share

A program has one commit store per causal group, not one commit store. Every group's store answers to the same `__txn` id, so `live_state` bucketed them together and each wrote its own frontier into the bucket's single `resume_at` — two groups at different frontiers raced, and `minted.stores` being keyed by content hash meant hash order settled which position a replacement resumed at.

`CarriedState` now carries one variable's value and the position it was read at, and the bucket holds one per runtime key. A store rebuilt in a retired store's place reads the position back off any variable it declares, asserting its variables agree, so nothing depends on the id naming one store. `two_transaction_groups_resume_at_their_own_positions` drives four commits into one group and one into the other, edits one group's writer, and expects each to stand where it stood.

The id is still half the state key, and it is still not an identity: a loop's id is the collection it reads rather than the loop, and `__txn` is shared. What makes the pair sufficient today is that `Name::field_key` spellings are distinct within the record that declares them and no two records in reach declare the same one. The design doc and `TRANSACTION_STORE_ID` now say that rather than claiming the id identifies a store, and name what closing it takes: keying state by the variable's declaration, which needs neither half.
… holds it

Two calls to one function, each carrying its own loop and its own accumulator, crossed their state on update. Inlining clones the function body per call site, so both accumulators are the same source declaration under the same spelling in the same store id — one entry, and both replacements read it. `two_instantiations_of_one_function_keep_their_accumulators_apart` is that program: it read `6042` where `6060` is right, the second accumulator seeded from the first's value, with no diagnostic and a successful update. Shadowing one declaration with another does the same thing without a function in sight.

State is now keyed by a `VarPath` — the variable's spelling plus its index among the variables of that spelling, in tree order. The spelling carries the meaning and the index only disambiguates, counted among the variables sharing the spelling rather than among all of them, so a stateful loop added anywhere shifts nothing unless it declares that same name.

Nothing the program computes can stand in for the index. Two instantiations of one function differ *only* in their writer bodies once arguments are substituted, so a content-derived identity either fails to tell them apart or changes under exactly the edit state has to survive. Nor can a lexical path: both declarations are the same point in the same source text.

One walk assigns every identity (`OpConversionContext::set_var_paths`), and both the guard and conversion read its answers, so a store is built under the identity it was checked against. Before, `declared_state` derived the key from the planned tree and the store builders derived it again from what they were building — two computations that had to agree, where disagreeing reads as a still-declared variable having been dropped.

`store_id` stays, in the role it can actually fill: `PositionSpace`, the space a `resume_at` was counted in. A data source, a transaction's private and restartable clock, or a fixed collection. A variable over a fixed collection carries nothing — its replacement recomputes the fold from the collection its own version declares, and seeding it would count the elements twice.

`StateConflict::Moved` is now stated in terms of that space rather than a store id, which is the same refusal on the same programs. Its doc records that this is policy, not constraint: a value can be seeded into a recurrence that counts elsewhere, starting at `0`, and relaxing the refusal is deleting it — `build_induction_store_single` asserts the spaces agree by the time it seeds.

`state_store_id` and `TRANSACTION_STORE_ID` are gone. So is `KeyReadInfo::carry_forward`, folded into `carried: Option<VarPath>`: a key that carries no value between commit ticks has no state to carry between versions either, and one field cannot disagree with itself.

`tests/programs/live_update/` held twenty `.cambra` files where every other gallery directory holds one. It now holds `program.cambra` and the `updated.cambra` that replaces it — a reader looking for what an update *is* reads that pair. The eighteen variants are scaffolding and live inline in the test module's `fixtures`, each an `indoc!` beside the cases that drive it.
`PositionSpace` restated the `Transact`'s `domain` field: `Transaction` for `Type::Txn`, `Source(name)` for a data source, `Finite` for anything else. The node already carries that — its doc calls `domain` "the carrier's sequencing domain, the index of every key's history: a concrete iteration extent for a `mut` accumulator, `Type::Txn` for a transactional commit clock" — so the enum was a lossy copy of a field, given a second name.

The domain itself is what a carried position is now compared against, and `carries_state` is the one thing the three cases were really distinguishing: a data source and a commit clock outlive the version reading them, while a concrete extent is part of the program, so a variable sequenced by one carries nothing and its replacement recomputes the fold.

`store_id` goes with it. It derived the source from `writers.first()`, which silently rested on an induction store being single-writer — true, but asserted a phase later than the derivation. Reading the node's own field needs no writer, so the walk no longer looks at `writers` at all. A `debug_assert` confirmed the two agreed across the suite before the swap.
Changing where a loop reads from is an ordinary edit — moving a service to another port, or moving an accumulator between two loops — and the guard refused all of it, because the value and the position were carried as one thing. They are not one thing. The value is the variable's; the position belongs to the collection it was counted in.

So a variable whose new version is sequenced by a different domain seeds with the value it held and decides from `0`. The positions it is about to decide belong to a collection its predecessor never read, so none is decided twice and none is skipped — which is the same argument that justified resuming at the frontier when the domain *is* the same, applied to the case where it is not.

`moving_a_program_to_another_port_keeps_its_state` signs a guestbook twice on one port, moves the program to another, and signs again: all three entries. The port it left is released with its last route, so the old address stops answering. `a_variable_that_moves_to_another_loop_takes_its_value_and_restarts` moves an accumulator between two loops of one program and reads its `aa` back out of the loop it moved to.

`StateConflict::Moved` is deleted. Two refusals are left, and both are a value with nowhere to go: a variable the new version does not declare, and one it declares at another type.

## A variable a version recomputes is not one it dropped

A variable sequenced by a fixed collection carries nothing — its fold is recomputed from the collection its own version declares, and seeding it would count the elements twice. `declared_state` used to leave those out, so a variable moving from a source loop to a list loop read as *undeclared* and the update was refused for discarding a value that was going to be recomputed correctly. It reports every declared variable now, and the two callers ask what they each need: the guard skips the type check for one it will not seed, and the induction builder seeds from nothing when its own domain recomputes.
Swapping which source two loops read stalled the program. Both variables change domain at once, so neither resumes at its predecessor's frontier, and starting them at `0` based each drive below every position its new source will ever offer: the other loop had read that source all along and released what it consumed, so the elements the drive waited for were never coming. A silent hang rather than a wrong answer, and the same fault reached a case the design doc listed as working — a stateless loop that gains an accumulator over a source the program was already reading has nothing carried at all and equally cannot start at `0`.

A store now starts at `first_position_for_a_new_producer`: `0` for a source nothing has read, which is every source of a program's first version, and the released frontier for one a retired version advanced. The exception stays the exception — a store replacing one over the *same* domain resumes at that store's frontier, deliberately behind its source, because a drive reads one position back through its input.

`two_loops_may_swap_which_source_they_read` and `a_stateless_loop_may_gain_an_accumulator_over_an_advanced_source` cover the two shapes. Both hang without the change.

## A compile that answers a question opens nothing

`moving_a_program_to_another_port_keeps_its_state` then failed to bind the port it was moving to, which turned out to be the update's own doing: `/diff` and the state guard compile the new version against the running registry, and a route that registry does not hold was *opened* — binding a socket and registering a route that outlive the context they were made in. Asking a question changed what the program served, and the compile that installs the version found the port taken by the compile that had only been asked about it.

Such a route now lowers to an `UnopenedRoute`, which answers the type questions lowering and inference put to a source and makes every runtime method `unreachable`, since a compile that stops above operator conversion asks nothing else. `Endpoints::Inherited` selects it — the name the design doc had already given this, describing a distinction the code did not yet draw.

`assert_port_released` polls rather than asserting outright. Releasing a port is not synchronous with the update that stopped serving it: dropping the last handle unblocks the dispatcher thread and the socket closes when that thread notices, so the contract is that the port is released, not that it is released before `update` returns.
Two doc comments still explained themselves through the store id: the `stdin`
guard case named one, and the two-transaction case said its stores answer to
`__txn`. Neither exists. The guard reads what a version declares off its planned
tree, which is the same for `stdin` and for `http_serve`, and two commit stores
are told apart by being sequenced by the same `Txn` domain rather than by sharing
a bucket.
The design doc's table says such a variable is recomputed rather than seeded, and
nothing checked it. Relaxing the refusal on a changed sequencing domain is what
made the case reachable, and the row went in without the test.

`a_variable_that_moves_to_a_fixed_collection_is_recomputed` bumps an accumulator
twice over an HTTP source, moves it to a loop over a two-element list, and reads
the list's fold alone.
A `FanOut` forwards the intersection of its subscribers' release guards to
its input, so the input is free to drop what that covers — a `Memo` does,
evicting its cache. A subscriber registering afterwards was given an empty
guard, so it claimed to still want data the fan-out had already said would
never be read again. Across a version handover that is the whole population:
every subscriber the update rebuilds registers after the retired ones
released.

A new subscriber now starts at what the fan-out has released. Within one
version every subscription is made before any data flows, so the seed is the
empty guard and nothing changes; across a handover it is what the rebuilt
subscriber inherits from the one it replaces.

Where the agreement covers everything, the operator can only answer empty,
and a version no longer offers such an operator to the one replacing it.
Adopting one bound a name to nothing: a `Let`-bound `n := ""` read by a
retired store and by the reader of its final value is released in full by
both, and the store rebuilt in its place then panicked reading its own init.
The value each variable holds is taken off its store before the withdrawal,
so a store released in full still hands its variables on.
A variable's position and its value were carried under one rule keyed on the
sequencing domain: `Txn` and a data source carried, a concrete iteration
extent carried neither, so a variable that moved to a loop over a fixed
collection lost what it held and that fold was recomputed from scratch on
every update. The reason given — that seeding would count the elements twice
— held only because the replacement also restarted at `0`.

They are two decisions. The value belongs to the variable, so it carries
wherever the variable goes, exactly as it already did between two loops over
sources and into and out of a transaction. The position belongs to the
sequence it was counted in, and a replacement counting in another one starts
its own count.

`Sequence` is what two versions compare. A source is named by itself, since
it outlives every version reading it. A collection is named by the identity
of the term that computes it: `[0, 2]` is the extent of `["y", "z"]` and of
`["p", "q"]` alike, so resuming the second fold at the first's frontier would
skip elements nothing ever read. A transaction hands on no position — its
clock restarts with the store that counts it — which is why `CarriedState`'s
resumption is optional rather than a third variant nothing compares.

A resumed store now seeds tick `0` rather than the tick it resumes at, and
starts its clock where it did before. A reader enumerating a fixed collection
asks about every position of it, including the ones the predecessor decided;
folding those to the value handed over answers them with the value the read
is after, where before the fold resolved to nothing and tripped the
changelog's seeding assertion.

`DeclaredVariable` no longer carries a domain: with the value carrying
unconditionally, the state guard is about the value having somewhere to go
and nothing else.
`carry_release_to_new_producers` had a default that did nothing and was
implemented only by the two sources backed by `UIntStreamBuffer`, so carrying
a source's agreed release across a version handover was a property of how
those two happened to store their releases. A source that stored them
otherwise silently did not carry, which reads as the replacement answering
every request the retired version had already answered.

`ProducerReleases` is that bookkeeping named once: what each producer has
released, accumulated by union; the agreement, which is what the source may
drop and what a newcomer may skip; and the starting point a producer
registering after the carry is recorded at. `UIntStreamBuffer` and
`TestDataSource` each kept their own copy of it, and now hold one of these
instead — `TestDataSource` gains the carry it was missing.

The trait's two handover questions lose their defaults, so a source has to
answer both. `UnopenedRoute` answers them the way it answers every other
runtime question, by being unreachable.
`compile_program` fills a `SinkConsumer`'s producer slot after `subscribe`
returns, so a notification raised from inside `subscribe` — an induction
store raises one to start its loop — reaches a consumer with nothing to pull
and is dropped. A first compile does not notice: a source holding data
reports it as new on the next poll, and that drives everything. A replacement
is not covered by that, because the version it replaces already took the
report.

So an update installed while work was outstanding sat until the next arrival.
A fold over a fixed collection is where that is easiest to reach: it advances
one position per pull, so a twenty-element list outlasts the notification
round that starts it, and a request accepted before the swap went unanswered.
Each sink is now notified once its slot is filled.

Two tests, covering different halves. The first pulls the program's value
itself rather than going through a sink, because that is what makes the
position the update lands on nameable: one pull decides one position, so
pulling eight times and swapping cuts the fold at seven, and the elements
below the cut keep what the retired version decided. The second drives the
same fold behind a route, which is the shape that stalls; it pins the
invariant rather than the position, since where a socket lands in a
notification round is not something the language promises.
`README.md` and `docs/design.md` both list a variable moving to another loop
among the updates that are refused. It is accepted, and has been since the
value started carrying into a recurrence that reads something else.
Six claims the code no longer supports.

`bind_let` said a reused operator "hands the new version everything it has
accumulated", because a `Memo`'s cache is cumulative. It is not: the cache
drops what its consumers release, which is why a binding released in full is
withheld from the next version at all. What a new version inherits is bounded
by what the retired one had finished with, and the new subscriber is told so.

`LiveProgram::update` said a variable must be declared "at the same type and
under the same store", and called that three refusals. There are two, and a
variable may move to another loop or into a transaction.

`SinkConsumer::new` asked its caller to fill the producer slot "before the
first notification fires". Nothing can: `subscribe` is what raises the first
notifications and also what produces the producer, so the slot is empty when
they arrive. Saying so is what makes the notify that follows the fill read as
necessary rather than as belt-and-braces.

`stream_buffer`'s module doc claimed the per-producer release bookkeeping as
its own; it is `ProducerReleases`, which every source keeps.

The subscription-lifetime section of `live-update.md` typed a fan-out slot's
weak reference as `Weak<()>`, contradicting its own next sentence about the
`Cell` the number lives in. And `transact_phase` still named a zip slot
`acc0-view`, from before store keys took the program's own variable names.
The window under both drivers now carries a position on every row, so a
resuming induction driver no longer needs one: `DriverWindow::new` loses the
base it was given and the driver says where it starts by inheriting its
predecessor's two cursors instead.

`emitted_through` is the item cursor — the next position to iterate is the
smallest delivered position above it, and the store's frontier is checked
against it — so a driver resuming at `p` has emitted through `p - 1`, by its
predecessor. `source_released_through` comes with it: the prefix below `p` is
one this driver will not read, which is what that cursor records. Left empty,
it would have this driver re-release a prefix its predecessor already
released, and read a position the source re-offers there as an arrival out of
order.

That is also what makes a resume compose with a *filtered* source, whose
decided positions are a subset of its extent and not contiguous. The resume
position is one of the collection's positions rather than a count of decided
elements, and both cursors take it as such.
@dpmills
dpmills force-pushed the dmills/live-update branch from ef7d734 to 5130e01 Compare August 28, 2026 21:50
@dpmills
dpmills changed the base branch from main to dmills/filtered-mut-accum August 28, 2026 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant