Status: accepted (2026-08-18) - amends nothing; builds on 0029, 0034,
0044, and 0046 as accepted. docs/observability.md's non-goals stand
unedited (decision 4) - the catch-up invariant extended by ADR-0060
(2026-08-19: for a resumed session, the invariant holds over a recording
anchored at the resumed position rather than at
Interpreter.initialize/2)
Context
Statifier.Session.start_link/2 runs Statifier.Interpreter.initialize/2
to quiescence before the process is reachable: init/1 performs the
initialize call inline and the resulting effects ride a
{:continue, {:initialize, ...}} term performed before any message can
reach the process, so a pid added by subscribe/2 the instant
start_link/2 returns is still strictly after the whole initialize batch
was notified - there is no window
(docs/research/260818-st-uqo4-late-subscriber-trace-and-session-header.md).
Only the :subscribers start option sees that burst, and the option is not
always available: a Livebook cell is routinely evaluated after the session
exists, and a LiveView pane reconnects. Nothing in the repo retains an
emitted effect - notify/2 is an unconditional send/2 per subscriber
with no copy kept, and the only effect-shaped fields on %State{}
(deferred, done_effect) are a within-perform/3 work queue and a
single halted-projection value, not a history. That is half (a) of
st-uqo4.
Half (b) asked for a self-describing opening effect naming the session and
the document. Two facts have moved since the bead was filed. First,
st-1xwh landed Statifier.Effect.DatamodelInit: the stream now opens with
{:datamodel_init, _} - unconditionally, once per initialize/2, at
round: 0, ahead of the global-script and entry effects at
Interpreter.initialize/2's own return-site concatenation - and its
datamodel carries _sessionid and _name, written by
SystemVariables.initial/2 before any effect is emitted. Second, the
mirrored bead sui-t36.1 is closed: statifier-ui's ADR-0005 builds its own
session.start definition message at its subscription boundary from the
%Machine{} it holds, and records that st-uqo4 is not a precondition for
it (re-read and noted on the bead, 2026-08-18).
Four settled decisions bound the shape of any answer:
- ADR-0046 rejected a session-side stamp on effect structs because
ADR-0034's
Statifier.Replayre-derives effects from the core with no session behind it, "breaking the stream equality the round-trip suite asserts" - literallyresult.stream == streamattest/statifier/replay_round_trip_test.exs:109. Anything the session mints and notifies as an effect faces the same objection. The core cannot mint asess_id (ADR-0008), though it holds one indatamodel["_sessionid"]. - ADR-0029 / ADR-0034 already built the one artifact that reproduces
an emitted stream: a
record: truesession'sStatifier.Session.Recording, replayed byStatifier.Replay.run/1as a pure fold whoseresult().streamis the subscriber messages with the envelope stripped, and whosestatusis:runningfor a recording that has not halted -run/1does not require a finished run. - ADR-0044 decision 1 makes monotone
(macrostep, round)arrival the subscriber-stream contract, matching replay's order; anything delivered to a late subscriber must not break it. docs/observability.mdnon-goals decline "trace persistence/ rotation" - a sentence whose reading decides whether the session may retain a buffer at all (decision 4).
Decision
1. A late subscriber catches up by replaying the recording, not from a
retained buffer: subscribe/3 atomically snapshots the recording and adds
the pid, and the caller re-derives the missed prefix with
Statifier.Replay.run/1. The public shape:
Statifier.Session.subscribe(server, pid)is unchanged: adds the pid, returns:ok, delivers from now on.Statifier.Session.subscribe(server, pid, catch_up: true)returns{:ok, recording}- the session's currentStatifier.Session.Recording.t(), snapshotted in the samehandle_callthat adds the pid - or{:error, :not_recorded}for a session not started withrecord: true, in which case the pid is not added and the caller falls back to plainsubscribe/2if live-only is acceptable.catch_up: falseis the default.- The missed messages are
Statifier.Replay.run(recording)'sresult.stream, computed by the caller, never inside the session. Its elements are exactly the un-enveloped subscriber shapes ({:effect, _} | {:unroutable, _} | {:halted, _}), soprefix ++ mailbox suffixis one uniform stream.
The property that makes this exact rather than approximate is an
invariant this record names and the implementing branch must assert:
between GenServer callbacks, Replay.run/1 over the session's current
recording produces exactly the messages the session has notified so far.
It holds because each callback is atomic in the effects' terms: the
callback records its input and notifies every resulting effect - including
ADR-0044's deferred re-entry batches, drained before the callback returns,
which is why deferred is documented always-[] between callbacks - so a
subscribe call, serialized between callbacks, observes a recording whose
replay is precisely the notified prefix, and everything the new pid
subsequently receives is precisely the suffix. No overlap, no gap, no
dedup key needed, and ADR-0044's monotone-arrival contract holds across
the seam: the prefix is replay's own order and the suffix continues it.
This extends the round-trip equality the suite already asserts at
end-of-run to every quiescent point; it is a strengthening the existing
machinery already satisfies, not new behavior.
Four alternatives are rejected:
- An unbounded retained buffer of notified messages on
%State{}answers the acceptance criterion directly but grows O(effects) for the life of the session, duplicates the recording's information in a second O(run) structure, and is the exact artifact decision 4 reads the non-goal as declining. - A bounded buffer trades that for a worse contract: a consumer cannot know whether it got everything, which turns "obtain the effects it missed" into "obtain some suffix of them" with no marker saying which.
- Retaining only the initialize burst (bounded by construction,
serves the motivating case without
record: true) creates a second, partial mechanism with a seam at macrostep 1: a subscriber attaching later cannot compose "initial burst + live from now" into anything complete, and the repo would carry two catch-up stories where one honest one suffices. - Session-side backfill (the session sends the replayed prefix to the
new pid itself) either blocks the session for an O(run) core
re-derivation inside
handle_call, or delegates the sending to another process and loses ordering - the BEAM guarantees message order per sender pair, so a helper's backfill and the session's live sends would interleave arbitrarily, breaking ADR-0044 decision 1 for exactly the subscriber this feature exists for. Returning the recording as a value keeps the session's cost at one map put plus one struct reference and makes the prefix/suffix split race-free by construction.
2. record: true is the gate, deliberately. A session not recording
has nothing to re-derive from, and this record adds no second retention
mechanism to compensate; {:error, :not_recorded} is the honest answer.
The gate is acceptable because the motivating scenarios control start
options even when no subscriber pid exists yet: the Livebook user starts
the session in one cell and subscribes from another; the LiveView
application starts the session and its panes reconnect later. What they
lack at start time is a pid, not a keyword. And record: true is already
the repo's "this run is reconstructible" knob (ADR-0029) - late-join
catch-up is precisely a reconstruction want, so gating it there reuses an
existing meaning instead of minting a retain:/buffer: sibling with
overlapping semantics. The memory bill is the recording's own,
O(inputs) - already accepted by ADR-0029 - and the compute bill is
Replay.run/1's O(run) per catch-up, paid by the caller that wants it.
A subscriber that only needs the current picture, not the history, keeps
using snapshot/1 and status/1 with a plain subscribe/2; nothing here
replaces them.
A halted session composes for free: the recording is complete, replay
yields the full stream ending {:halted, reason}, the live suffix is
empty, and Replay.run/1's status says so - post-mortem attachment is
the same call as late attachment.
3. No header effect and no new envelope shape; half (b) is declined, not deferred. This settles the effect-versus-envelope question by choosing neither, on four grounds:
- A session-minted header effect fails on ADR-0046's replay ground
exactly as the rejected
roundstamp did: replay has no session behind it, so the effect would exist live and not replayed, andresult.stream == stream- now load-bearing for decision 1's catch-up exactness, not just for the test suite - would be false for every run. A core-emitted header would replay (the recorded opts carry:session_id, and the core already holds it indatamodel["_sessionid"]), so coexistence is possible - but the effect would either restate values{:datamodel_init, _}already opens the stream with, or carry machine facts thatdocs/observability.mdconstraint 3 keeps off payloads by design, the same grounds on which st-1xwh's decision 6 keptbindingand declared ids offDatamodelInit. - The stream already opens as self-describingly as constraint 3 permits:
{:datamodel_init, _}first, unconditionally,round: 0, itsdatamodelcarrying_sessionidand_name; and every message's envelope is{:statifier, session_id, message}, so a mid-stream joiner learns the session id from its first message - which is how statifier-ui's subscriber actually does it. - "The document being traced" has no identity to name:
%Machine{}carries no source path, filename, or text -nameis the whole of it, and it is already in the datamodel as_name. Inventing a document identity is a separate feature with no consumer behind it. - The consumer that wanted the header built its own: statifier-ui
ADR-0005's
session.startdefinition message is produced at its subscription boundary from the%Machine{}it holds, and sui-t36.1 closed recording that st-uqo4 is not a precondition. A wire-format header for consumers holding no%Machine{}is this repo's stated "no wire format" non-goal and, per ADR-0025, statifier-ui's half of the mirror.
An envelope sibling ({:hello, _} beside {:effect, _} / {:halted, _})
would dodge the replay objection but inherits grounds two through four
whole: it would carry nothing a consumer cannot already get sooner.
4. The non-goal is read, stated, and left unedited: "no trace persistence/rotation story" declines retained effect logs, and re-derivation is not retention. The sentence's subject is the fate of emitted trace effects - whether this repo stores them, bounds them, rotates them. An unbounded in-session buffer would be that story's first installment (and would eventually demand the rotation policy the sentence declines), so decision 1's rejection of buffers is this reading enforced. The ADR-0029 recording is not in tension with it: the recording stores inputs, a different artifact with its own accepted cost, and catch-up derives effects from it transiently, on the caller's dime, retaining nothing. The non-goals list therefore needs no amendment - which is the test this record applies to itself: a design that had required editing the non-goal would have been evidence for the other reading.
5. Ownership split with st-fd7n: this record owns single-session
catch-up; st-fd7n owns option inheritance down the invoke tree.
st-fd7n's deciding fact - "a post-hoc attach can never observe a child's
initialize burst" - is narrowed by this record: a post-hoc attach can
obtain any session's initialize burst, child included, provided that
session records. What remains genuinely st-fd7n's is whether and how a
child inherits the parent's start options (:trace, :subscribers), and
- consequential from this record, flagged here as input rather than
decided across the boundary - whether
:recordjoins that inheritance set, since a non-recording child is exactly the session decision 2's gate leaves unanswerable. Neither bead skips the child case: st-uqo4 makes the mechanism session-shape-agnostic; st-fd7n decides which children have the gate open.
6. Documentation edits this record directs, on the implementing
branch: docs/observability.md constraint 6 gains a sentence naming the
catch-up recipe and the between-callbacks invariant (the non-goals list is
untouched, decision 4); Statifier.Session's moduledoc subscriber-stream
section documents subscribe/3, the {:error, :not_recorded} contract,
and the prefix-then-mailbox consumption pattern;
Statifier.Replay.run/1's doc gains the mid-run use case it already
supports.
Consequences
- Implementation, sized separately (this record changes no code): the
subscribe/3head and itshandle_callclause inlib/statifier/session.ex(snapshot + add, or error without adding); no change tonotify/2,%State{}'s fields,lib/statifier/effect.ex's vocabulary ortrace?/1,lib/statifier/session/effects.ex's planner,lib/statifier/session/telemetry.ex(catch-up emits no events, and replay fires none, ADR-0040),lib/statifier/session/recording.ex, orlib/statifier/replay.ex. - Tests the implementing plan owes, each with its sabotage line per
docs/testing.md: a latesubscribe/3whose replayed prefix plus drained live suffix equals a from-start subscriber's full stream (the acceptance test for half (a)); a mid-run round-trip asserting the between-callbacks invariant of decision 1 directly; the{:error, :not_recorded}path leaving the pid unsubscribed; and a post-halt attach recovering the complete stream. - The bead's acceptance criterion needs amending, which is the claiming
session's call to record, not this document's edit: the first clause
("can obtain the effects emitted before it attached") is met under
record: trueviasubscribe/3+Replay.run/1; the second clause ("the stream opens with an effect naming the session id and the document being traced") is superseded by decision 3 - the stream already opens with{:datamodel_init, _}carrying_sessionidand_name, and no further header effect is added. - Because
result.stream == streamis now load-bearing for a public feature rather than only for the test suite, any future change that would break live/replay stream equality inherits a second veto from this record on top of ADR-0046's. - Open questions recorded rather than decided: whether a convenience
wrapper (one call returning the derived prefix instead of the
recording) is worth its API surface - deferred until a consumer asks,
since the two-line recipe serves Elixir callers; and whether
:recordinherits down the invoke tree, which is st-fd7n's per decision 5. - What would reopen this record: a demonstrated consumer for whom
record: trueis genuinely unavailable at start time (not merely unset), which would force the bounded-retention argument to be re-had; a measured workload where per-catch-upReplay.run/1cost is material (the re-argument would weigh incremental replay checkpoints, not buffers); or a change to ADR-0029's recording shape or ADR-0034's pure fold, whose records own those moves.