AGENTS.md@packages/runtime · diff
git:20261012.2f9801d to git:20261012.2acee0c
5 added, 1 removed. Audit A to A.
# runtime
## Atomic remembered approvals
`api.approve_escalation_with_fact_at` commits a captured approval and one
`ReservedFactChange` through the same writer transaction. Both the escalation
sequence and prior fact sequence are expectations; either conflict writes
neither value and returns `RaceLost`. The helper accepts only a reserved fact
key distinct from the decision key. Runtime treats grants and the fact payload
as opaque JSON; client policy validation owns their meaning. The existing
`approve_escalation_at` remains the one-call door. See protocol 041.
`api.edit_reserved_facts` is the same guard without a decision: a list of
`ReservedFactEdit` (`ReservedFactSet` of a `ReservedFactChange`, or
`ReservedFactRemove` of a cell read at a sequence) applied in one transaction,
each under the sequence its caller observed. A cell that moved loses the whole
edit as `RaceLost`; nothing is retried, because the caller's read was a
question put to a person. Every key must be reserved. The remembered-permissions
forget is its one caller (protocol 073).
## Streamed response handoff
Generation and poll `RequestSpec` values carry the response entry already
reserved by the effect intent. The gateway uses that identity to let the
terminal retain its bounded streamed answer until the exact durable entry is
visible; request completion alone is not a presentation handoff. See
`protocol-change/036-stream-response-handoff.md`.
## Orphan recovery through the tool surface
`effects.ToolSurface.recover: Option(fn(ToolRun) -> Recovery)` lets a surface
that keeps its own record of finished calls (a remote executor's ledger) say
what became of an orphaned call: intent durable as `CallEffectPending`, no live
effect in this incarnation. `None`, every local surface, leaves `tool_key`
resolving to `planner.ObservedToolOrphaned` exactly as before. With `Some(f)`,
`tool_key` returns `KeySpawned` after `spawn_recovery` starts an effect, built
by the same `spawn_tool_worker` as `spawn_tool`, under the call's own
`ToolEffect` token. So `has_live_tool`, `KeyWait` and the abort path treat it as
the tool it stands in for. It calls `f` with the run a replay would use: the
effective arguments from `op.tool_args`, the replay policy persisted in the
`CallEffectPending`, no grants. `f` may block; the driver is not parked on it.
The answer arrives as `ToolRecovered` and `recovery_done` maps it without any
change to `machine`: `Recovered(outcome)` settles through `settle_tool`, the
path a fresh run's `ToolDone` takes; `OutcomeUnknown` is the orphan observation
with `replay_still_safe: False`; `NotStarted` is that observation with `True`
when the persisted policy is `ReplaySafe` and the current registration still
says so, otherwise a `ToolFailed("the call never reached the executor and did
not run")`. A recovery effect killed by abort, or one that dies unanswered,
takes `effect_exit`'s ordinary tool path. `runtime` reads the argument key
through `machine/internal/build.tool_args_key`, the one place that format is
spelled. See `protocol-change/078`.
## Purpose
The orchestration plane's live half: the OTP tree that turns an open
session into a running one. One StorageWriter owns the commit path, one
strand driver per strand interprets the pure machine's actions against an
injected effect seam, and `runtime/api` is the session-facing surface —
open/recover, prompt, steer, follow-up, abort, close, plus the multi-strand
operations (create a subagent strand, message another strand, await its
result), the `fact.*` blackboard, and durable escalation decisions. WP-E,
extended by the M3 runtime wave.
## Key Types
- `runtime/api.FactHandle` is an opaque internal host capability containing
only the session writer Address. `fact_handle` projects it before a host
retains a fact callback. `fact_cell_with` and
`put_reserved_fact_expecting_with` share the Runtime doors' decoding,
reserved-key check, conditional transaction and error mapping. Each request
resolves the current writer; replacement preserves the handle, while
namespace retirement makes it unavailable. A writer death after admission
still crashes the caller when the commit's acknowledgement is uncertain.
Lease loss remains `SessionStolen` and stale expectations remain
`FactConflict`. The handle carries neither Effects nor a cached PID.
- `effects.failure_observation` derives diagnostic identity from the captured
`RequestSpec`. Provider effects retain their own deadline or stop through
cancellation acknowledgement; the inner custodian records only cancellation
receipt. `settle_failure` persists these facts beside any `retry_after_ms`
in the existing assistant diagnostics field. Successful messages are unchanged.
- `runtime/api.Runtime` — the live session handle: the `SessionTree`, the
`Session`, the session's canonical `SessionId` (`api.session_id`), the
`Effects` record, the strand name, and the `RunSettings`. `api.open`
mints that id on a session that has none — the one place every session,
file-backed or in-memory, forked or fresh, passes through on its way up
(`protocol-change/008`).
It addresses **one strand at a time**; `api.on_strand` rebinds the same
tree to a sibling, so every operation works for subagents too.
- `runtime/api.{Drain, draining, drain_within}` — everything `api.drain`
reads, and nothing else: the tree it addresses strands through and the
session it reads their state from. `drain` is `drain_within` applied to
`draining`, and the contract is one shared budget, every abort requested
before any terminal is awaited, and each strand awaiting the operation it
was observed running. The projection exists for holders that must be able
to drain a session later and do nothing else with it — the daemon's session
manager keeps one per admitted session — because a held or copied `Runtime`
pays for a full copy of `Effects`, and neither field here is duplicated per
session.
- `runtime/api.open_published` and `runtime/supervisor.start_published`
expose an internal publication boundary before recovery. The first
child-start callback publishes the exact root and direct drain witness
before the writer, factories or drivers start. Refusal prevents recovery;
restarts below the significant temporary drain ledger do not republish.
The callback only transfers custody: runtime calls and synchronous close
would block startup. The caller is an exit-trapping resource owner and
must establish surviving custody itself; the API does not create it.
- `runtime/api.{CreateStrandError, Delivery}` — subagent creation, and
whether a cross-strand message landed as a `Steered(entry)` on an open
run or `Started(operation)` on an idle strand. `create_strand` is
`validate` + `seed_strand` + `adopt_strand`; `adopt_strand` is exposed
on its own because a crash between the seed commit and the brief commit
leaves a strand nothing else can finish.
`seed_strand` refuses a name that begins with `parent_reference_prefix` (`^`)
as `InvalidStrandName`, because a delegate session's children name a parent on
another orchestrator as `^main` in their lineage and the reference must never
be a strand (protocol-change/084). The `delegate/` fact prefix is the fifteenth
- reserved corner: the Agency keeps `delegate/ended/{operation}` there.
+ reserved corner: the Agency keeps `delegate/ended/{operation}` there. The
+ sixteenth and seventeenth are `remote-child/` (the stub a parent session
+ writes before it asks another orchestrator for a child) and `remote-ended/`
+ (the run-end rows it owes), and `runtime/lineage.Lineage` gains an optional
+ `orchestrator`, absent for a local cell and not written for one.
- `runtime/api.ApiError` — why an operation failed. `RuntimeUnavailable`
means the writer address had no live binding before a request was sent;
a caller can retry later. This differs from `CommitFailed(error:)` and
`SessionStolen(held_by:)`. The second is `tx.LeaseLost` classified at
the admission surface (`commit_failure`): another writer owns the
session, so nothing this runtime commits can land and the remedy is to
reopen, not to retry. The admission ladder finishes on it rather than
retrying — `retry_admission` decrements only on `Retry`, so spending
four attempts against a fence that refuses all four would report
`RaceLost` and name the wrong cause.
- `runtime/api.{put_reserved_fact, put_reserved_fact_expecting,
delete_reserved_fact, delete_reserved_prefix, edit_reserved_facts,
reserved_facts, reserved_fact_key}` — the
harness-only door to the reserved corners of `fact.custom`, and the
predicate naming them. Deliberately disjoint from `put_fact`/`facts`,
which refuse and hide the same keys. `put_reserved_fact_expecting` is
`put_fact_expecting`'s compare-and-set on the reserved side — the
`expected: None` form is how a harness component mints a record under
its own prefix exactly once instead of read-then-blind-write (#162) —
and `delete_reserved_fact` is the blackboard's one delete door, reserved
only, so a retired record leaves no tombstone for every later prefix
scan to discard (#164); nothing a model reaches can make a cell vanish.
`delete_reserved_prefix` is that delete in its **set** form and not a
loop over the singular one: it lists the prefix, commits one `core/tx`
of `DeleteRegister` writes so the set is never half retired, and
answers how many there were — the only observation left once the cells
are gone. Zero commits nothing. It refuses an unreserved prefix like
every door on this side of the reservation, so it can never become a
bulk delete over the model-writable blackboard, where no delete door
exists at all. **A prefix is a path and the caller owns that
discipline**: this door deletes what the store matches, so a prefix
stopping mid-segment (`schedule/fired/main`) reaches `mainly`'s cells
too, and callers pass prefixes ending in the separator
(`client/schedule.strand_prefixes`) because only a namespace's owner
knows where its segments end. Its caller is
`client/scheduleseam`, which retires a cancelled schedule's fired-marks
with it and a settled strand's whole scheduling footprint on a run
end.
- `runtime/supervisor.SessionTree` — a rest-for-one supervisor over six
children in order: the significant temporary **drain ledger**, the
restartable strand **registry**, the **StorageWriter**, the
**StrandSupervisor** (a `factory_supervisor` of strand drivers, one per
strand, restarted individually), the **subagent StrandSupervisor** (a
second factory with its own tolerance, for strands a host's `subagent`
predicate names), and the **strand booter** (a worker whose start lists
the `strand.*` registers and starts a driver for every strand found,
routing each to its factory). The writer, registry and drain ledger bind
reference addresses in `SessionTree.namespace`; its routing-only lifetime
ends when the root exits. It ends *only* there: the namespace owner is
spawn-linked to whoever assembled the tree, and `start_published`
unlinks it alongside the root once publication has acknowledged
custody, so a transient builder's abnormal exit can no longer strand a
fully assembled session behind addresses that resolve to nothing. `SessionTree.drains` retains the ledger's direct
subject independently, because routing death cannot certify effect drain.
- `runtime/supervisor.shutdown(tree, grace_ms:)` — the orderly stop
`api.close` is built on: children terminated in reverse start order
with reason `shutdown`. The grace bounds the `sys:terminate` handshake, but
the drain ledger then keeps the root alive until every provider reaper has
stopped; the writer lease is not released on an inconclusive drain. The tree
unlinks from its starter, so a host that wants to hear about its death
monitors `SessionTree.supervisor` (`client/host` does).
- `runtime/lineage.{Lineage, CallSite}` — the durable ledger of who
spawned whom, one `fact.custom` cell per spawned strand under the
reserved `lineage/` prefix: parent edge, depth, the minting call site,
the brief operation, the child's tools, an **absolute** deadline, the
detach flag, and the durable reap mark. `is_descendant` is the walk the
addressing rule is decided by, and it fails closed.
- `runtime/registry.Message` — the strand-incarnation registry actor: strand
name ↔ its driver's reclaimable `weft/registry.Address`. This actor owns
the reference namespace; repeated strand allocation creates no atoms.
It also retains the current typed OTP handles for the `Primary` and
`Subagent` factories. Their child-start callbacks publish before the booter
runs, and lookup rejects a dead predecessor until replacement publishes.
The factories themselves are unnamed.
Reaper claims live only
in `runtime/internal/drain_registry`; keeping them out of this restartable
actor prevents a name-registry restart from erasing ownership barriers.
- `runtime/internal/drain_registry.Message` — the session-local actor owning
each logical strand's complete unadjudicated reaper chain. It precedes the
restartable registry and is a significant temporary child: a normal reaper
`Down` retires a generation, while a `Down` that says the reaper destroyed
its ownership set kills the ledger and stops the session instead of
inventing an empty ownership history. `noproc` is the exception, and
`drain_registry.Verdict` is where the three are told apart: it is what a
monitor answers about a pid that was already gone, never a reason a process
exits with, and both the claim and the monitor it installs travel as
signals, so a reaper that ends before the monitor request lands — after
its claimant was released as readily as before — can be met no other way.
A weft scope holds itself alive until every effect it adopted has exited
and says `weft_drain_proof_lost` when it cannot, so a pid met as `noproc`
left nothing running: it retires like a normal exit.
- `runtime/internal/provider_custodian.Prepared` — one provider request as a
parked owner plus the one-way `begin` permit that releases it. The worker
behind it is a `weft/state_machine`, `Parked → Forwarding → Cancelling →
Draining` over data that is `Awaiting` before the permit and `Serving`
after it. `Parked` selects only the permit and the custodian's stop; the
step that grants the permit installs the real selector with
`sm.with_selector`, because the inner stream's events subject is created
by that handler and must be delivered to this process. Its cancellation
grace is the `Cancelling` state's **state timeout**, so it is armed by the
move in, cancelled by the move out, and never re-armed by the `keep` that
every late delta produces.
- `runtime/writer.Message` — the writer actor's mailbox; `writer.Event` is
the `Committed(ordinal, seqs, ts)` published to subscribers.
- `runtime/writer.Subscriber` distinguishes `Direct(Subject(Event))`
observers from `Routed(Address(Event))` restartable services. Publication
sends a lossy hint without executing subscriber callbacks in the writer.
A routed destination is resolved afresh; an absent binding never fails a
commit or queues a hint for a future incarnation.
- `runtime/strand_runtime.Message` — the driver's mailbox.
- `runtime/api.Options.logger` / `runtime/strand_runtime.Options.logger`
— the injected `telemetry/log.Logger` every strand of the session
logs through (§0.2; `log.discard()` by default, so a runtime nobody
configured is silent). `strand_runtime.start` scopes it to the strand
it is starting, and each dispatched effect narrows it again to
`{op, step}`.
- `runtime/effects.Effects` — everything the driver does to the world, in
one injected record: `clock`, `entropy`, `timers`, `provider`
(`ProviderSurface`), `tools` (`ToolSurface`), `hooks` (`Hooks`).
- `runtime/effects.{RequestSpec, ToolRun, ToolOutcome, Clearance,
ExecutionMode}` — the shapes the seam is written in; `conformance/wiring`
and `client/demo` fill them with the real gateway, broker, and registry.
`ToolRun.strand` carries the dispatching driver's own durable name, so a
tool can be judged against its lineage rather than against a name a
model claims. `ToolRun.grants` carries what *this call's* clearance
consumed — the far end of the channel `ClearanceQuery.grants` opens,
and the reason an approval can change a policy decision at all. A
replayed call carries none.
- `effects.Hooks.context` — the phase-3 slot: `fn(OpId,
List(AgentMessage)) -> List(AgentMessage)`, applied in
`strand_runtime` to a generation attempt's projection just before the
request goes out. The list is transient, never a durable entry, which
is what keeps the slot inside the replay rule the rest of the record
is held to: a crash before the consuming commit re-projects and
re-transforms. `default_hooks` supplies identity; the only producer is
`client/extension/hooks`, the extension hook bus.
- `runtime/hooks.Registry` — the one seam production wiring, tests, and the
simulation runner all build their `effects.Hooks` through: safe defaults,
a pipeable setter per slot, `build` at the end.
- `runtime/hooks.Projected` — what a compaction decision is taken from:
a strand's projected `messages`, how many leading ones a compaction at
the head of the branch `carried` over (its summary plus its
retained-tail copy), and that compaction's `previous_summary`.
`hooks.project` reads one from a session; `hooks.uncompacted` wraps a
plain message list.
- `runtime/hooks.with_tool_references` — enables retained-tail result
references only after the host has authorized exact history retrieval. It
preserves the frozen cut and replaces only successful text-only results of
at least 4,096 bytes when the bounded reference is smaller. Provenance is the
original durable entry UUID; copied compaction tails resolve through their
parent branch and fail closed on positional or projection mismatch.
- `runtime/hooks.{estimate_message, context_tokens, preparation,
threshold, overflow}` — the compaction arithmetic: characters over
four for a not-yet-priced message, pi's newest-durable-usage fold for
a whole context, the one preparation builder, and the two signals
built over them.
- `runtime/effects.ThresholdQuery` — `{operation, strand, messages,
carried, previous_summary}`, asked at every checkpoint boundary. It
carries the strand's projection rather than asking the hook to read
one: the driver has projected the branch for the step already, and a
hook that projected again on its own was doing the step's largest
piece of work a second time (issue #359). The three trailing fields
are `hooks.Projected` spelled out, because `effects` sits beneath
`hooks`.
- `runtime/effects.OverflowQuery` — `{operation, strand}`, asked once
when a settlement classifies as a run's first context overflow. It
names the strand because a preparation is built from a *strand's*
durable projection, and one `Effects` record serves every strand of a
session.
- `runtime/escalation.{Escalation, CallScope, Action, Claim, Status,
claimed, scoped_to, bound_to}` — the durable record of a broker denial
awaiting a decision, its decision, and its single consumed
re-execution. `CallScope` is the exact call identity (`{operation,
strand, step, source index, call id}`) an approval is attributed to;
only the clearance whose coordinates match can spend it. `Action` is
what the claimant would actually *run* — `{tool, digest, preview}`,
all three computed by `client/escalate` and stored here opaquely —
and it is what consent binds to (#65): the record id is a digest of
the *want*, which says nothing about the command behind it.
`claimed` is the transition that moves a record to a *later* call
raising the same want: pending moves and refreshes its denial and
action, approved moves and keeps its grants **only when the claimant's
action digest matches**, and everything else — a mismatched action, a
rejection, a consumption — re-opens as a fresh pending question with
no grants. Re-opening costs a human an answer, so a record counts its
`asked` questions and `claimed` answers `Exhausted` past the caller's
`max_asks` rather than re-opening again (#66). A re-opening after a
consumption restarts the count at one, since an approval that ran once is
not a question the model is wearing down. `bound_to` is
`scoped_to`'s sibling and fails the same way: a record naming no
action matches nothing.
- `runtime/api.{escalations, escalation, escalation_cell,
escalations_below, raise_escalation_for, claim_escalation,
approve_escalation, deny_escalation, consume_escalation,
consume_escalation_at}` — the durable escalation surface.
`escalation(runtime, id)` is the bounded point lookup a parked call
polls on (`client/escalate`), rather than listing and decoding the
whole reserved prefix once a second; `escalation_cell` is the same
read carrying the seq, for a caller that will decide something from
what it read; `escalations_below` is the bounded count question a
capped raiser asks; `claim_escalation` is the raise-or-take-over door
parking uses, taking the claimant's `Action` and a `max_asks` bound
and answering `Claimed` or `Exhausted`, where `raise_escalation_for`
stays write-once (and actionless) for callers that mean "record this,
once". The two consume doors differ in exactly one thing:
`consume_escalation` re-reads and consumes whatever it finds, which is
right for a host holding no earlier read, while
`consume_escalation_at` CASes at the seq the caller checked, which is
what a caller that checked a scope or an action owes its own check
(#68).
- `runtime/api.{Mark, steer_marking}` — a steer admission and a
write-once reserved claim in **one transaction**. The door an injector
needs and `steer_quietly` cannot give it: an injector that must act at
most once has to make "the message is queued" and "the claim is spent"
one durable fact, because the two orders fail in opposite directions —
marking first loses the injection to a crash in between, queueing
first double-injects after one. The mark's own stale expectation is
told apart from the operation-state seq the admission ladder retries
on: a lost race against the strand reloads, a taken mark answers
`FactConflict` at once. `client/rulescan` is the only caller.
- `runtime/api.{accept_quietly_marking, send_to_strand_marking}` —
`steer_marking`'s argument carried onto the *other* admission door.
`accept_quietly_marking` folds the same write-once `Mark` into a fresh
run's acceptance transaction instead of a steer's — `marked` and
`commit_admission` are already generic over `core/tx.Tx` and
`Option(Mark)`, so this is `accept_request` threading the mark it
already had nowhere to go, not a new commit shape.
`send_to_strand_marking` is `send_to_strand`'s reconciliation (steer an
open run, fall back to a fresh run on an idle one, retry the steer if a
run opens in the gap) with that same mark carried through whichever
path lands. `client/schedulescan` is the caller: a `wake = true`
scheduled heartbeat is the first caller ever needing exactly-once
admission on the fresh-run path, because nothing before it was allowed
to wake an idle strand at all.
- `runtime/api.{FactCell, fact_cell, put_fact_expecting}` — the
compare-and-set half of the blackboard: read a cell with the seq of
the write that put it there, then write only if it has not moved. The
only way a read-modify-write over a shared cell is expressible;
`put_fact` remains last-write-wins and now says so.
## Relationships
- **Depends on**: `core`, `storage`, `session` (`runtime/hooks.project`
reads a strand's durable projection straight from the session store),
`machine` (drives
`next_action` and reuses its codecs and queue helpers), `telemetry`
(the injected logger and the correlation context — a leaf package
over `core`, so this edge adds nothing transitively), `provider`
(`stream.StreamHandle` and `retry.classify` — the spec DAG §0.1 writes
`E → A,B,C,D`, so this provider edge is a divergence worth knowing
about), `gleam_erlang`, `gleam_otp`, `weft` (the strand driver is a
`weft/actor`, adopted for `continuing` — the recovery barrier as a
guaranteed-first message; the parked provider request worker is a
`weft/state_machine`, adopted for the state timeout that *is* the
cancellation grace; see loom#159 for the adoption plan).
- **Depended on by**: `client` (the gateway dispatches protocol commands
onto `runtime/api`), `conformance` (the simulation runner drives sessions
through `runtime/api`).
- **FFI**: two declarations, both in `runtime/internal/ffi_sup` over
`runtime_ffi.erl`. `terminate_supervisor` is `sys:terminate/3`, the only
graceful stop an OTP supervisor offers a process that is not its
parent, and the one thing `gleam/otp/static_supervisor` does not wrap.
`send_to_pid` is a bare `!` to an already-resolved `Pid` — what
`writer.publish` uses so a named subscriber's name is looked up exactly
once (issue #43); no wrapped `Subject`-taking function in `gleam_erlang`
reaches a specific pid without re-resolving it. Nothing else: time,
entropy, and timers arrive through `Effects`, and everything effectful
is injected, which is why the whole plane runs under a logical clock in
simulation.
## Traffic
- **Actor messages**
- `writer.Message` (all calls except `Subscribe`/`RenewTick`):
`Commit(tx, reply)`, `GetEntries`, `GetRegister`, `ListRegisters`,
`ScanBranch`, `ScanUsage`, `Stats`, `Subscribe(subscriber)`,
`RenewTick`. Senders: `runtime/api`, `runtime/strand_runtime`, and the
conformance harnesses — except `RenewTick`, which nobody sends. It is
a `weft/actor` **periodic timeout** armed by `writer.start` when
`Session.lease_interval_ms` is `Some`, so the writer is a `weft/actor`
rather than a `gleam/otp/actor`. The tick fires into a subject weft
creates inside the writer's own process and never registers under a
name, which is what makes a predecessor's timer unable to reach a
replacement claiming the same registered name; the writer used to
carry a private subject in its state for exactly that defence, and no
longer does. A renewal that fails stops the writer abnormally, so the
rest-for-one supervisor re-runs the open path.
- `strand_runtime.Message` (all casts): `AwaitPredecessors(resolution)`
(the guaranteed-first barrier message weft's `continuing` injects at
init — opaque, so nothing else can construct it), `Nudge` (the
doorbell), `PollTick(generation)` (the checkpoint poll, which also grants
one deferred poll permit; the generation names the arming it belongs to,
and a tick from a superseded arming is dropped), `RetryDue`,
`RequestAbort`,
`ProviderDone(token, terminal)`, `ToolDone(token, outcome)`,
`EffectExit(down)`. Callers use the stable
address resolved to the current subject, while the reaper, effect workers,
and timers use a
private direct subject bound to this driver incarnation. The split prevents
a predecessor's late result from resolving the stable name to a replacement
and settling newly replayed work with the same durable token. The ledger
acknowledgement arrives on a dedicated subject the `AwaitPredecessors`
handler blocks on; every message that lands meanwhile — aborts included —
queues in the mailbox and is handled once the barrier opens, so no effect
is driven before it and no intent needs hand-carrying through a gate.
- `registry.Message` (all calls): `Ensure(strand, reply_with)` — mint or
return the reference address a strand's driver binds — plus
`Lookup(strand, reply_with)` and `Known(reply_with)`.
`PublishFactory(kind, factory, reply_with)` replaces one typed factory
handle; `LookupFactory(kind, reply_with)` reads it. The root's child-start
callbacks are the only publishers, so replacement follows start order.
Senders: `runtime/supervisor`'s strand factory and booter, and
`runtime/api` when it rings a doorbell or addresses a sibling strand.
- `runtime/internal/drain_registry.Message` (call):
`Claim(strand, reaper, reply_with)` atomically publishes the new
incarnation, snapshots its predecessors, and releases the caller only once
the ledger's original monitors have proved that entire snapshot drained
normally. The call is made by a *claimant* the reaper's scope adopts as
a leaf owner before releasing it, naming the scope as the pid to monitor
(`strand_runtime.claim_through`); the scope cannot settle while the
claimant is inside the call, so the ledger's monitor always lands on a
live pid — a claim made from the driver instead let a driver killed
mid-claim leave the ledger monitoring a scope that had already drained.
That ordering is the claim protocol's own guarantee and not the ledger's
safety net: the monitor is still a signal in flight when the claim is
answered, and the ledger reads a `noproc` claim as a departure whatever
the claimant did. The driver blocks
on the claimant's verdict in its guaranteed-first `AwaitPredecessors`
handler; meanwhile an abort simply queues in the mailbox behind the
barrier. Sender: `runtime/strand_runtime` through the closure the
supervisor injects.
- `writer.Event.Committed` fan-out uses `writer.Subscriber`: direct
subjects for incarnation-local observers, reference addresses for
restartable services such as `client/gateway.commit_forwarder`.
- **Commits**: every post-boot `Tx` in the session flows through
`writer.commit`, whose mailbox *is* the serialization order. The driver
commits exactly what the planner hands it — `Transition`/`Finish`
transactions and `Dispatch` intent transactions — and treats
`StaleExpectation` as "reload and re-plan".
- **Registers**: the driver loads `strand.config`, `strand.state`,
`strand.leaf`, `op.meta`, `op.state`, and the sibling payload registers
(`op.tool_args`, `op.preparation`, `pending.entry`) through
`session`'s typed accessors, then writes back whatever the planner's
transactions carry. `strand.last_result` is written and never read.
`api.create_strand` seeds a new strand's `strand.config` / `strand.leaf`
/ `strand.state` before starting its driver. The blackboard and
escalations are `fact.*`: `api.put_fact` / `fact` / `facts` over
`fact.custom`, and `runtime/escalation` and `runtime/lineage` store
their records in `fact.custom` under the reserved `escalation/` and
`lineage/` key prefixes — escalations guarded by the register's own
seq, lineage cells last-write-wins. `put_fact` is last-write-wins too;
`put_fact_expecting` is the same write with the caller's read seq
asserted, which is what makes a shared cell safely updatable.
- **Wire**: consumes `provider/stream.StreamEvent` — zero or more `Delta`
then exactly one `Settled` or `Failed` — from a cancellable `StreamHandle`,
and `effects.ToolOutcome` from the tool surface. A provider wait deadline
invokes the handle's cancellation capability before returning a terminal
observation.
## Invariants
- **A provider retry hint survives settlement and recovery.** The runtime
preserves a retryable error's `retry_after_ms` in the existing assistant
diagnostics JSON. The machine persists the later of that minimum delay and
its captured exponential policy as `GenerationRetryWait.not_before`.
Missing, malformed, negative, or shorter hints cannot shorten the policy.
Summary failures use the same convention in `OperationError.details` when
their producer supplies it. Terminal cancellation remains terminal.
- **An idle assembly actor hibernates, and the interval is one value.**
`runtime/residency.hibernate_after_ms` is the quiet period after which a
session's weft actors shed their heaps, and every site takes that constant
rather than a number of its own; in this package `registry` and
`strand_runtime` take it. Two actors deliberately do not. `writer` holds a
`RenewTick` heartbeat through `actor.periodic`, which a handler can neither
cancel nor re-time, so its mailbox is never quiet. The booter is a
stateless placeholder with nothing to reclaim.
Hibernation returns garbage
only — the live set, which is dominated by this session's `Effects` copies,
survives it — so it is a complement to sharing that value, never a
substitute. `docs/design-notes/daemon-memory.md` has the measurement.
- **The checkpoint poll runs at two periods, and which one is decided by the
drive it follows.** `strand_runtime.State.occupancy` records what the last
completed drive's `load` found. `Occupied` re-arms at `poll_interval_ms`
(200 ms), which is the rate a deferred suspension is granted its next
permit at and nothing else — every other in-flight step is answered by its
own monitor or its own timer; `Unoccupied`
re-arms at `idle_poll_interval_ms` (two minutes), which clears
`residency.hibernate_after_ms` and is the reason the strand can hibernate
at all — it holds the largest `Effects` heap in an assembly and used to be
permanently awake. The backstop is *kept* in both states rather than
dropped in one, so the doctrine of design §4.6 still holds verbatim: a
doorbell lost between a caller's commit and its nudge costs latency, and
the latency it costs an idle strand is the idle period rather than never.
Every drive re-arms, through the single `arm_poll` call in `finish`, so the
period follows the occupancy immediately: a message that opens work on an
idle strand arms the short tick while handling that message. That is
load-bearing rather than a nicety. A turn is almost always shorter than the
idle period, so a strand that had to wait for the pending idle tick before
its period could change would run the whole turn without a short tick, and
the short period would be unreachable in ordinary use — which is what it
was until the correction recorded in
`docs/design-notes/daemon-memory.md`. Two things wait on it: a deferred
suspension's permit, and `api.steer_marking`, the quiet marked injection
`client/rulescan` and `client/schedulescan` commit onto an *open* run
without ringing.
Two rules keep one live chain. A drive whose occupancy names the period
already outstanding (`State.armed_poll_ms`) arms nothing, so a session
configured with one period for both occupancies arms exactly one deadline
for its whole life — which is what `client@schedulescan_test`'s
one-deadline assertion reads. A drive that wants the other period arms it
under a new `State.poll_generation`, and the tick it superseded — which the
`Timers` seam gives nothing to cancel with — is dropped by `polled` on
arrival for carrying the older number. The price is one stale wake per
period change, two per turn. The arming
also follows the drive rather than opening it, which is what lets the period
be read from what that drive found and stops a halting strand leaving a
timer behind. `runtime/idle_poll_test` asserts both periods, the single
chain, and that becoming occupied arms the short period without waiting.
- **A call the model keeps repeating into failure is stopped at
clearance.** Before `clear_tool_call`, `tool_clearance_key` reads the
branch's newest `repeat_guard.window` message entries back to the latest
compaction and asks `repeat_guard.judge` how many consecutive turns made
this call and nothing else, by name and `core/json.canonical`
arguments, and saw every copy fail; a turn that also did other work is
progress and breaks the streak. At `refuse_after` (3) the call is refused unrun with
`RefusalContinues`; at `end_after` (4), which only a repeat past that
refusal reaches, it is refused with `RefusalEndsRun` and the run
completes as `CompletedByTerminatedTools`. Any user message breaks a
streak, so an operator's reply or an advisor's nudge buys the model a
fresh start. The count is read from write-once entries, so recovery
judges from the same entries the first incarnation did, and a read that
fails reads as no history rather than halting the strand: the guard is
a brake on a loop, not a correctness condition. A call whose arguments
never parsed comes through the same key: the guard judges it first, and
otherwise it is refused with `planner.malformed_refusal`'s text, never
handed to `clear_tool_call`, so a model repeating broken JSON is bounded
the same way.
- **One writer, structurally.** All commits are calls into one actor, so
"transactions on one session are serialized" is a property of the process
topology, not a convention. Reads route through it too, keeping a single
storage path and one instrumentation point.
- **The drive loop is the machine's contract, verbatim**: load registers →
build `PlannerInputs` with a fresh id generator each pass → `next_action`
→ commit and re-plan on `Transition`/`Finish`; on `Dispatch` commit the
intent *then* start the effect; on `AwaitEffect(key)` resolve the key; on
`Wait` schedule a timer or a poll permit; on `Fault` stop abnormally.
- **Restart callbacks retain only their own inputs.** `api.open_published`
projects option fields before constructing runtime and driver builders.
`supervisor.start_published` captures the classifier and driver builder
separately, so publication and booter specifications do not inherit the
entire effects graph. An actual started-supervisor regression grows the
writer subscriber list and checks that only OTP's initial writer specification
and active writer entry retain it.
- **Crash recovery and cold start are the same code.** A restarted strand's
first drive re-reads its registers and resumes (spec §3.1); the tree is
rest-for-one, so a writer crash restarts the writer *and* the whole
strand factory, while a strand crash restarts only that strand.
- **Recovery boots *all* strands, not just `"main"`.** The booter lists the
`strand.*` registers and starts a driver for every strand it finds, so a
cold open, a writer crash (the factory restarts empty and the booter
repopulates it), and a booter crash all converge on "list the store,
start what is missing". Subagents created mid-session seed their
registers first, which is why every later reboot finds them.
- **Reference addresses are minted once per strand and outlive their drivers.**
The registry precedes the writer in the rest-for-one order, so it survives
writer and strand crashes; a replacement driver binds the *same*
reference and stays addressable, and doorbells resolve through `lookup` at
ring time rather than caching pids. A registry crash replaces its namespace
while the
earlier drain ledger preserves effect ordering; only a whole-tree reboot
starts both actors empty.
- **Factory handles belong to an incarnation, not a permanent name.** The
root publishes each unnamed factory's PID and typed OTP handle in the
registry before acknowledging its start. Each strand start resolves that
slot again. A subagent-factory restart replaces only its own slot, retaining
the primary factory and its drivers; registry restart rebuilds both slots
through the existing rest-for-one order.
- **Inter-strand messaging is the queue machinery, not a mailbox.**
`send_to_strand` durably enqueues a steer onto the target's open run — or
accepts a fresh run when it is idle — and only then rings the doorbell,
so the payload is durable before any ephemeral signal (design §4.6).
- **Doorbell loss is harmless by construction.** `Nudge` only wakes the
strand early; the payload always travels in the commit. The periodic
`PollTick` finds queued work anyway, and any commit racing the strand's
own surfaces as a stale expectation, forcing a reload that sees it. The
`_quietly` admission variants commit without ringing at all.
- **Effect processes are monitored by the driver and adopted by its
reaper.** A tool process that dies without reporting settles as a synthetic
in-band tool error. A provider death instead faults the driver: fabricating
a retryable transport failure could dispatch beside the stream subtree that
outlived it. Each driver incarnation also starts a *reaper*: a weft
witnessed run (`weft.managed` task parked for the incarnation's life,
`weft.start_witnessed`, `weft.CancelSiblings`) linked to the driver, whose
ledger claim is made by an adopted claimant so the scope outlives the
claim. Every
effect adopts itself as a leaf owner (`weft.adopt_leaf`, cancel = its stop
capability) and runs only on `Adopted`. On driver death the scope asks
every adopted effect to stop and remains alive until all their exits
arrive. A provider effect first creates a parked request worker inside a
minimal custodian, publishes that custodian to the reaper beneath itself
(`weft.adopt_under`, cancel = `stream.cancel`), and only then permits the
worker to call the frozen provider surface. The scope therefore cancels
the public owner when the effect exits, retains it even when the
publication was refused, and cannot finish until both are gone; an
abnormal owner exit is a lost proof that cancels the run and leaves the
scope's exit reason abnormal. The parked worker is linked to the provider
effect because they are one failure domain: an unexpected surface crash must
still fault the effect and recover, not become a fabricated provider
response. That worker is a `weft/state_machine`, started with a plain linked
`sm.start` from the effect, so the link is weft's rather than something this
module arranges. Production surfaces return a `PreparedStream`: the worker
publishes its parked owner to the reaper before granting the begin permit,
so a rejected publication cannot leave unowned provider work. Immediate
`ProviderSurface` values remain only for in-memory fakes with no external
work; they may still carry self-reaping owners to model cancellation. The
custodian is unlinked and survives both long enough to drain their adopted
descendants. The separate drain ledger remembers every unadjudicated reaper
for the strand, and releases a replacement only after its original monitors
prove that exact predecessor snapshot drained normally.
The effect installs a `DrainWitness` before granting begin, so it can delay
`ProviderDone` until the original monitor reports `Drained`; the reaper holds
an independent monitor for recovery. A provider owner or reaper must exit
normally to discharge its obligation; killed and abnormal observations fail
closed. A reaper the ledger meets as `noproc` is the one exception, and
retires: the pid is gone, and a weft scope cannot be gone with effects
still running. `SessionTree`
retains the ledger's direct subject, so
`shutdown` waits it independently even when the root supervisor was killed
abnormally and the service namespace has stopped. This transitive drain barrier, rather than scheduler
timing, makes the incarnation-local `live` list sound.
- **Internal events are addressed to one incarnation, not one logical
strand.** Public `Nudge` and external abort requests resolve the logical
reference address across a restart. Provider completions, tool
completions, recovery acknowledgements, poll ticks, retry wakes, and internal
abort retries instead capture the driver's direct subject. A durable effect
token is intentionally reused when recovery replays an effect, so token-only
correlation is safe only after a message has first been confined to the
incarnation that created its `Live` entry. Sending those messages through
the stable name would let a predecessor remove a replacement's live worker.
- **Provider ownership continues below the effect process.** A provider
effect has one absolute receive deadline which deltas cannot renew. Reaching
it cancels the stream and waits a
bounded acknowledgement grace. An owner-authored `ProviderCancelled` and a
locally reported `CancellationUnconfirmed` are both terminal under retry
classification; the latter says teardown could not be proved and therefore
cannot authorize another attempt. The acknowledgement grace is a second,
single scheduled timer, so late deltas cannot extend it. The effect withholds
`ProviderDone` until its public stream owner exits, so the current driver
cannot progress beside the old subtree either. An abort keeps the driver alive and spends the
acknowledgement grace because a real terminal may already be queued; its
usage still belongs in the aborted result. Driver death has no remaining
terminal consumer, so the effect requests cancellation and exits without
spending that grace. Its reaper already monitors the public owner and is the
surviving drain witness: a provider pump or HTTP request may temporarily
outlive the effect pid, but never the barrier that prevents replacement.
- **`after_commit` is the crash seam.** It runs in the writer process after
the commit is durable and published but *before* the committer's reply,
so an observer that kills the writer produces exactly "commit N durable,
committer never saw it succeed". Production passes a no-op. No terminal
result is accepted while the seam is open.
- **Abort is strand-routed and idempotent.** The durable
`cancel_requested` marker serializes with the strand's own transitions
and live effects are cancelled by their owner; a pre-commit crash loses
the request exactly as pi's no-live-task case does, and callers
re-request.
- **A lost abort race re-delivers, it never halts the strand** (ORCH-L5).
Exhausting the stale-retry ladder must not drop a fire-and-forget request
(nobody would learn it was lost) and must not restart the strand for a
transient race, so the driver re-sends `RequestAbort(operation)` to itself after a
pacing delay and keeps running. Every retry compares that same captured
identity with durable state. An idle or successor operation makes the request
obsolete without cancelling effects. `api.abort_operation` is the explicit
identity seam; `api.abort` captures the current identity before sending.
- **Interrupted effects stay registered, and settle faithfully.** Live
effects are cancelled *after* the durable marker (pi §4.6 order) but are
not deregistered: an effect that already delivered a real settlement
still queued in the mailbox commits under its reserved ids as `aborted`
**retaining its reported usage** (ORCH-M3), while one that dies unreported
settles through the monitor as a synthetic abort with unknown usage coverage.
- **Fourteen corners of `fact.custom` are reserved, and reserving hides as
well as refuses.** `escalation/`, `operation-result/`, `lineage/`,
`child-run/`, `prompt/`, `session/`, `rule/`, `schedule/`, `ext/`,
`job/`, `advisor/`, `goal/`, `summary/` and `client/` are
refused to
`put_fact` and filtered out of `facts`, so no blackboard write can
forge an approval, shadow a terminal result, rewrite a parent edge,
rewrite a child run's stop record or budget,
overwrite the pinned system prompt, re-point the session's own
identity, mark an operator's triggered project rule or scheduled
heartbeat as already fired so that it never fires, forge and read
an installed extension's durable memory, mark a background job
terminal while its process still runs, move the advisor's feed cursor
past everything the primary will ever append and so silence the
session's reviewer, fake the goal cell's status or accounting, write
the summary a terminal shows in place of a reasoning block
(`summary/<entry>/<block>`, protocol 050), or rewrite the shared run
settings — `client/run_settings`, the one cell under `client/` — that
every admission compares against, and so choose the queue mode and
tool-execution mode of every later run of the session. Both are named
constants, `api.client_fact_prefix` and `api.run_settings_key`: the
key is a CAS expectation folded into the admission transaction, and a
typo in a literal spelling of it would not fail to compile, it would
silently drop the compare-and-set. Because
the reservation also hides a namespace from its own owner, harness code
reads and writes it through `reserved_facts` / `put_reserved_fact`,
which refuse everything *outside* the reserved set — the two doors are
disjoint so neither can be pressed into service as the other. The
ledger prefix is `lineage/` rather than anything near the
model-writable `agent/`: an integrity-critical namespace two letters
from a model-writable one is one typo away from lineage forgery.
`ext/` is the newest and the widest: the whole prefix is reserved
rather than a corner of it, because the extension's own name is the
second segment and `client/extension/memory` is what binds it — an
extension writes `ext/<its installed name>/<key>` through
`put_reserved_fact` and reads it back with the plain `fact`, and a
model-writable namespace there would let a model forge what an
extension remembers and read what it was never shown.
`rule/` and `schedule/` are held to the same rule for the same reason
— a fired-mark a model could write is a project rule or a scheduled
heartbeat it could silence in advance — and to each other's, kept
disjoint rather than sharing one prefix: a security-relevant write-once
mark earns its own corner instead of one shared with a mechanically
similar but distinct feature.
- **A model-spawned strand's crash loop cannot reboot `main`.** The tree
carries two strand factories and `Config.subagent` decides, by name
alone, which one starts a strand. The subagent factory sits *after* the
primary one in the rest-for-one order, so its death restarts only
itself and the booter. The runtime cannot tell a model-spawned strand
from an operator-spawned one — lineage is a layer up — so the host
injects the predicate, and the default says nobody is a subagent,
which is exactly the single-factory behaviour that came before.
- **Escalations are registers, not entries.** An escalation is current
mutable state (pending → approved → consumed) needing a bounded point
lookup on the clearance path, and it must never move a strand's leaf or
enter a context projection — a mid-batch denial cannot be allowed to
reparent the conversation. Status transitions are CAS-guarded through
the writer, so a lost race is a refused commit, never a double consume.
- **An approval is attributed, and consumed before its grants are used.**
The record carries the exact `CallScope` it is currently raised for; a
clearance loads only the approvals scoped to the call it is clearing
(unscoped records match nothing — an unattributable grant must not
widen anything), consumes them by CAS *first*, and passes into
`tools.clear` only the grants whose consumption commit won. A lost CAS
drops that record's grants and the clearance proceeds under the base
policy; a crash after the consumption spends the approval without an
execution. Both directions fail safe: one approval is worth at most one
widened execution, of exactly the call a human approved. What the
clearance won then travels onto the dispatch it authorized
(`ToolRun.grants`), keyed by the call's own `{step, source index}`, so
a carry left behind by a clearance whose dispatch never happened — a
stale commit that re-plans, a fault — can never widen a different call.
A **replay** carries no grants at all: its clearance belonged to a dead
incarnation whose approval is already marked consumed, and re-widening
from it is the one direction that would turn one approval into two.
- **A record's scope is the call holding the claim, and a claim moves.**
The id a raiser files under is a digest of the *want*, not of the call
(`client/escalate.record_id`), so under a retry the row is already
there — and the call that opened it has settled, because a model that
reads an in-band refusal retries under a freshly minted call id. So
`claim_escalation` takes the record over under a CAS rather than
refusing the duplicate; a scope frozen to the first attempt would name
a call nothing can re-clear, leaving an approval nothing can spend and
a write-once register nothing can replace. The exactness of
`scoped_to` is untouched and is what still holds the line: one
claimant at a time, one CAS from `Approved` to `Consumed`, and only a
call whose denial digests to the same id can ever hold the claim —
same want, same strand, same tool. A decided record re-opens with no
grants, so a new cycle needs a new decision.
- **Grant and denial payloads cross the runtime opaquely.** They are stored
as JSON in the broker's escalation vocabulary and returned uninterpreted,
which is what keeps the spec's `E → A,B,C,D` direction intact — there is
no broker import here. `client/grants` does the decoding.
- **Hooks decide from durable state.** The `hooks.threshold` and
`hooks.overflow` constructors derive their signals from the strand's
*durable* projection (`hooks.project`, read straight from the session
store rather than through the writer, which is what makes it callable
from a hook), so a decision taken before a crash is taken again after
it — the same rule the simulation hooks follow. A read that fails
projects as empty, which reads downstream as "nothing to compact": a
strand must not be halted because a token count could not be taken.
`compaction_note` is inside the same rule for the reason `context` is
— its answer is transient input to a request whose consuming commit is
the summary — and `usage` is deliberately outside it, because it fires
*after* its commit rather than before one.
The same rule governs `run_start`, whose first production filler is
`client/notes`'s `agent/` digest: a hook runs on the driver, so it must
be synchronous and non-blocking, and it is built *before* `api.open`
returns, so there is no runtime handle to reach through — a register
read through the session store is the sanctioned pattern for both.
- **A hook slot is set by replacement, so layers compose by wrapping.**
Every `hooks.with_*` setter replaces its slot outright. A second layer
that wants to add to one — `client/agency.reaping_hooks` on `run_end`,
`client/notes.digest_hooks` on `run_start` — therefore takes the built
`Hooks` and wraps the function already in the slot, calling it and
adding to its result. A layer that set the slot instead would silently
drop whatever the layer beneath it installed.
- **The threshold is computed on every pass, from one projection per
step.** `PlannerInputs.threshold` is a value rather than a thunk
(frozen, spec Part 1), so an open operation projects its branch on
every driver message. The driver makes that projection once, in
`plan`, hands it to the threshold hook inside `ThresholdQuery` and
reads it again for the generation request, so the request and the
threshold see one branch and the branch is scanned once. The scan is
memoised together with its pure `hooks.Projected` result in the private
`CachedProjection` held by `State.projection`, keyed by the leaf it was
made from. A step whose leaf has not moved reuses both, and one whose leaf moved
forward scans only the entries past the cached leaf's seq and joins
them on when the oldest of them names the cached leaf as its parent
and none is a compaction; anything else, a rewind, a fork, a
compaction, is a full rescan. Only write-once entries and their pure
default projection are cached; threshold decisions and request-local
context hooks still run at their original boundaries. The request hook's
transformed list never enters the cache. Provider worker construction projects
the reaper before making its closure, so custody does not copy the whole
driver's scan, projection and sibling Effects into each worker. A fresh incarnation starts without
a cache, which is the replay rule every projection here is held to. Before this a
1,200-entry branch was decoded three times per step (issue #359).
- **Read-only context inspection shares the compaction projection.**
`hooks.project_from_scan` accepts a newest-first branch stopped inclusively at
compaction, so observers can capture their own immutable endpoint without
rereading a moving leaf. `hooks.has_reported_usage` uses the same newest
positive post-compaction usage as `context_tokens`; it never treats the
retained tail's old usage as a new baseline. These helpers add no effects or
changes to compaction policy.
- **A context is counted the way the provider counts it.**
`context_tokens` takes the newest settled assistant message's
*provider-reported* usage and estimates only what came after it (pi's
`estimateContextTokens`). An estimate-only fold drifts low against the
provider — cache reads, thinking tokens, serialization overhead — and
low is the dangerous direction: a run that believes it has room
overflows instead of compacting.
- **Carried usage is never read.** A `CompactionEntry` carries a *copy*
of its retained tail, so the assistant messages at the head of a
post-compaction projection report the size of the context the
compaction replaced. `Projected.carried` names them and the fold
starts after them. Without the guard the threshold re-fires on the
turn after every compaction, forever — pi guards the same way, by
rejecting usage older than the latest compaction.
- **A retained tail never opens on a tool result.** `preparation` treats
keep-recent as a target, preserves the newest assistant exchange and all
following input, then aligns a result boundary backward to its call.
This prevents threshold-triggering results from disappearing before the
model reads them. Before the first assistant, all input is protected.
An exchange larger than the model window can fail overflow recovery;
the cut must not silently remove it to meet the target.
- **One preparation builder serves all three triggers.** Threshold,
overflow and operator `Compact` use `hooks.preparation`. Prior checkpoint
text travels separately as `previous_summary` in the frozen contract;
the host supplies the replacement. A previously retained exchange can
be cut once a newer exchange exists.
- **Close is an orderly shutdown, and lease release depends on its drain
barrier.** `close` terminates the tree the way OTP terminates one —
reverse start order, reason `shutdown` — so every strand driver is
gone before the writer it commits through, and a close that was asked
for is distinguishable in the logs from a fault. Durable state stops
at a commit boundary either way, because commits are atomic in the
storage actor. The configured grace bounds only the root's system-message
handshake. The drain ledger has an unbounded OTP shutdown interval and keeps
the root alive until every provider reaper exits; `close` seals the handle
and releases the writer lease only afterward. A slow close is preferable to
reopening beside an unconfirmed native request. Nothing terminal is written;
reopening recovers the open operation.
- **A hint with nowhere to go is a non-event, and never the writer's
death.** Post-commit publication skips a subscriber whose owner is
gone. Sending into an *unregistered name* crashes the sender, so
without a guard a supervised, restartable subscriber held by name would
make the writer — and the whole rest-for-one tree beneath it — hostage
to that subscriber's restart window. Events are hints and pulls are
truth (design §3.6), so dropping one costs latency and nothing else.
`publish_direct` resolves a named direct subscriber's pid **exactly once** — via
`process.named`, straight into `ffi_sup.send_to_pid` — rather than
resolving it a second time inside an ordinary `process.send`, which is
what `process.send` does internally for a `NamedSubject` on every call.
Before issue #43 those were two separate resolutions, and a name that
unregistered in the gap between them still crashed the writer for a
hint nobody needed; a plain, pid-backed subject never had this problem
at all; `process.send` on it never re-resolves anything.
Routed subscribers use Weft's reference lookup and send instead. The
destination survives service replacement, but missed hints are not replayed.
- **A lost lease stops the writer abnormally** so the supervisor reboots
the tree, whose reopen path re-acquires or fails loudly. Renewal runs on
an idle timer at a third of the TTL. That timer targets a private subject
bound to the current writer PID; the public reference address is only
for callers. Otherwise each predecessor timer would resolve the address
to the replacement and add another permanent renewal cadence after every
restart. A commit that races the renewal loses too, and arrives as
`tx.LeaseLost`: `runtime/api` turns it into `SessionStolen`, and
`runtime/strand_runtime` halts the strand on it rather than reloading,
because reloading reads the same file and meets the same fence.
- **Logical addresses, not cached pids, identify restartable services.** The
writer and strand drivers bind reclaimable reference addresses.
Loss-tolerant public strand messages resolve the address once and
send the tagged envelope directly to the resolved PID; checking and then
sending through the name would leave an unregistration race between two
lookups. Writer calls also resolve once: an absent binding yields
`writer.Unavailable`, while a reply carries `writer.Underlying(cause)` on
failure. Death after sending is still an unknown outcome, not a safe retry.
The runtime allocates no dynamic process names. A 50-cycle test executes a
turn per session, closes it, checks root/driver/namespace death and stale
address failure, and asserts zero atom growth after a fresh-VM warm-up.
- **Log context reaches an effect process because the closure carries
it, not because anything is inherited.** `spawn_effect` takes the
step-scoped logger as an argument, and the body that runs on the new
process closes over it — Erlang `logger` metadata does not survive a
spawn, so a driver that relied on inheritance would emit its most
interesting lines uncorrelated. The spawned body additionally calls
`log.adopt`, which stamps the same `{session, strand, op, step}` into
that process's `logger` metadata so an OTP crash report *about* the
effect process is correlated too. See `telemetry/context` for why the
value is the mechanism and the metadata only the fallback.
- **What the drive loop logs, and at which level.** `info` for the
durable state changes — `strand.started`, `operation.settled`,
`operation.aborted`; `warning` for degraded-but-progressing —
`retry.armed`, `effect.exited`; `error` only for `strand.halted`,
which is the one thing the strand cannot recover from on its own;
`debug` for `effect.dispatched` / `effect.settled`, which is per step
and off by default. A tool that failed *in band* is a settlement like
any other and stays at `debug`: that is the system working.
## Deep Docs
- [docs/architecture/orchestration.md](../../docs/architecture/orchestration.md)
— the drive loop, the supervision tree, doorbells, the interleave
harness.
- [docs/architecture/simulation.md](../../docs/architecture/simulation.md) —
what the deterministic runner does to this tree.
- [docs/spec-gaps.md](../../docs/spec-gaps.md) — "From WP-E": crash
semantics, boot seeding, close-as-crash, injected entropy, minimal writer
events. "From the M3 runtime wave": escalations as registers, opaque
grant JSON, abort-race pacing, parallel dispatch's exclusivity.
- [Root CLAUDE.md](../../CLAUDE.md) — repo ground rules and the doc graph.
## Child operation metadata
`runtime/child_run.Run` is a total-decoded reserved fact under
`child-run/{operation}`. `api.accept_with_child_run` commits it with continued
run admission when the target has lineage. The same transaction checks the
lineage, parent strand and parent operation sequences. `send_to_child` requires
a current sending operation for idle admission, supports an explicit positive
budget, and refuses an explicit budget for an active child. A finishable parent
checkpoint cannot transfer cleanup custody because run-end hooks precede the
terminal commit. Ordinary operator admission at that boundary is unowned.
Lineage retains original spawn identity and a default budget for later runs.
Proposal 042 specifies compatibility and the additional wire metadata.
## Async custody and guarded admission (protocol 048)
`async_execution.Execution` is the total-decoded record under
`client/async/record/{id}`. `child_run.Owner` distinguishes `ParentRun` from
`AsyncExecution`; old string owners still decode as parent custody.
`send_to_async_child` compares live execution custody in the same transaction
as run admission. `child_run.initial_key` retains the original operation
atomically before Agency lineage publication. Later operations cannot replace
that recovery identity. `steer_async` checks the same custody without waking.
`GuardedMark` extends a write-once receipt with exact sequence expectations.
A failed receipt or authority expectation returns `FactConflict`; admission
must not retry past a changed grant. This lets peer delivery atomically commit
provenance, receipt and prompt through the existing queue/acceptance paths.
`async_execution.Readiness` is stored separately under
`client/async/ready/{id}`. Its total decoder validates the unique bounded endpoint
names and idle interval. Readiness does not replace lifecycle: a finished or
lost record may retain its endpoint declaration. Peer entries carry the core
`PeerOrigin` variant through the same durable conversation codec as human
entries; grant and receipt checks remain in the existing admission transaction.
## Provider reports (protocol 081)
`strand_runtime.provider_terminal_observation` preserves the independent
request report on generation, polling, and nested-summary terminals.
`effects.settle_failure` uses the report's final attempt for the synthetic
assistant while passing the full report to the machine. A local empty
report establishes no consumption; owner loss without a report becomes an
unknown observation. The existing usage hook sees the committed aggregate
row once, and context readers use the assistant's final response usage.
## Writer residency
A writer uses `runtime/residency.hibernate_after_ms` when it has no lease.
For a leased session it uses the smaller of that threshold and half the
renewal interval (at least one millisecond). The renewal remains Weft's
fixed-delay periodic timer; hibernation neither cancels nor re-arms it.
A message wakes the same writer, preserving commit ordering, subscribers
and lease custody. The shorter quiet period lets a parked writer release
transient commit/read heap before the next heartbeat wakes its mailbox.
`writer_renewal_test` observes actual hibernation, request wake-up, repeated
renewal and abnormal retirement after lease loss.