@alma-harness/conversation
v0.41.0
Published
Governed buffered and settled-step streaming conversations for Alma.
Readme
@alma-harness/conversation
Governed buffered and settled-step streaming conversations. Use matching core, execution, loop
and single-call packages. createConversationRunner(config) composes durable
session admission, operation roots, governed model steps and final result storage.
It is the sole conversation entrypoint; the standalone agent and its legacy
lease/claim, streaming and financial writers have been removed (spec: retire-legacy-conversation-engine).
Supply step: GovernedStepConfig, admissions, rootResults, sessions, ordinary
agent prompt/policy/tools/profiles/hooks/media/recall options, caps, prices,
policyVersion, priceVersion, configRevision, resultContractVersion,
resultRetentionMs, runTimeoutMs, maxCalls, maxInputChars and consumers.
runTimeoutMs is at most ten minutes. Clients must be single-dispatch. Ordinary
configuration is copied; store/client/policy/schema implementations and revision
mappings are trusted host capabilities. Tools use ConversationTool and receive
ConversationToolCtx, including models.direct({tier,sensitivity,prompt}) for a
single tool-free child call, and the narrowing models.delegate gateway.
runTurn({scope,sessionId,idempotencyKey,input,intent,trigger?,toolProfile?,signal?})
accepts an inline user Msg and a required stable delivery key. It snapshots bounded
plain JSON before I/O and derives scoped root identity and input/config bindings.
Retries return original outcomes without renewing deadlines or dispatching again;
changed content/options/config conflicts. Use immutable media references, not inline
wire content. Host configuration revisions must cover all behavior, and referenced
media bytes must remain bound to the original request. Undefined optional object
fields are omitted; arrays remain ordered. Non-JSON/accessor/cyclic/oversized input
is rejected before admission. The canonical payload ceiling includes 4 KiB metadata
overhead in addition to maxInputChars; traversal is bounded at 64 levels/100k nodes.
conversationRootKey(scope,sessionId,idempotencyKey) lets a host call read without
loading content. createConversationDescriptor(config,input,deadlineAt) is a pure
helper for explicit hosts interoperating with simple requests. runTurn also accepts
existing explicit input. Its reserved conversation-v1 identities use the same narrow
clock-race rule: differing proposed deadlines reuse the first descriptor only if all
other bindings match. This is a trusted-host convention, not proof of hashed content.
Arbitrary explicit roots keep exact binding semantics. Generic or lost claim ACKs
never grant ownership. Input signal is execution cancellation, not a socket signal.
The legacy run method is removed (spec: canonical-conversation-api-cutover).
Use runTurn for both simple and explicit input. It always returns a Promise;
invalid input rejects that promise and can be handled with .catch(). Input
normalization and snapshot happen immediately during the call, before any await.
Constructor and streamTurn validation remain synchronous.
@alma-harness/runtime/memory and /postgres provide storage composition.
Canonical APIs first observe the scoped admission and compare original bindings.
Matching retries never resolve policy again. For unseen work, policy.resolve
must be synchronous, pure and bounded: it receives only a copied RoutingIntent,
including task. A throw or absent selected client returns
{status:"not_admitted",reason:"routing_unavailable"} before any claim, context,
hook, provider validation or financial write. A final admission read takes
precedence if a matching competitor became visible; conflicts still reject.
Lookup failure or cancellation never claims a routing refusal.
This refusal is not durable and describes this invocation, not global absence.
read returns null for an unadmitted key and may observe a later competing claim.
Fix the configuration with a new configRevision before retrying; do not retry a
configuration refusal automatically. No receipt or session release is fabricated.
Successful validation snapshots the complete main choice for all main steps;
summary/direct/delegate routing remains independent. A turn uncertain after its
admission frees its session before its view returns (spec: failed-turn-release).
Settled-step streaming
streamTurn(input) accepts the same input as runTurn and starts the same engine
eagerly. It returns one async iterator and an independent result promise:
const stream = runner.streamTurn(input);
try {
for await (const event of stream) {
if (event.type === "text") showProgress(event.step, event.text);
if (event.type === "result") showStatus(event.view.status);
}
} catch (error) {
if (!(error instanceof ConversationStreamResyncError)) throw error;
// Resynchronize using runner.read(scope, originalRootKey).
} finally {
const view = await stream.result; // Worker completion is independent of the reader.
}Import ConversationStreamResyncError from this package. Text spans arrive in
main-step block order only after settlement and fresh authorization; there are no
provider-token deltas. A preamble may be visible before a later tool failure, cap
stop or uncertain root. Progress is not final completion and concatenated progress
is not generally the final reply. Final-message channels should use runTurn.
Progress tool/provider events contain metadata only; reasoning and child output
are withheld. Usage events are original main-call receipts; child costs can make
the final total larger than their sum. The terminal result event preserves the
existing host-facing ConversationView, including authorized tool/provider blocks
in its reply. Chat renderers select text blocks rather than serializing the envelope.
Fresh root authorization precedes final delivery and preserves owner/replay flags.
Break, return, throw and socket closure detach only the reader. Keep the worker
alive and await result separately; never wire socket close to input.signal.
Only that execution signal/deadline cancels execution. Detach is not durable
background scheduling, and uncertain execution still requires reconciliation.
Each stream permits one iterator and one pending next. Queues hold references,
not output, with limits of 128 references and 256 KiB metadata. Overflow, erased/
expired/denied step output or a failed authorization read detach with typed reasons
overflow, unavailable, or read_failed. Reconnect through read; there is no
durable intermediate replay. A late first iterator after result settlement receives
only a freshly authorized final view, unless already overflowed. That view may be
unavailable after erasure even if the earlier result promise completed. Detached
iterators cannot reattach, and pending store reads cannot deliver late events.
runTurn({root,intent,loadInput,trigger?,toolProfile?,signal?}) requires the root's
closed request descriptor. Its configuration/output versions and retention must
match construction, caps must match, maxCalls may narrow, and the declared root
sensitivity must equal the intent. The host assigns inputRevision to the complete
immutable request, including profile/trigger/intent. Changed metadata conflicts
before content access; never accept browser/model identities as authority.
The root ID is the turn ID; only the runner allocates child slots, IDs and parents.
Admission, root claim and pending final-result reservation precede loadInput, hooks, recall, history and media. Busy/replayed requests run nothing. Successful steps expose usable output only behind governed receipts. Main, direct, delegate and cold-start summary calls share the root; inclusive totals are not new charges. Explicit warn caps preserve results. Numeric/explicit block caps retain their stopping behavior, including zero-cost preflight refusal. The legacy SpendStore.add and AuditLog.cost writers are never called by this runner.
Final results and deployment
rootResults MUST be physically separate from step.results. For PostgreSQL,
create a dedicated schema and pool whose search_path resolves only that schema,
then use migrateExecutionResultStore/PostgresExecutionResultStore there. Configure
the same scoped roles and grant schema USAGE. Object inequality is checked but
cannot prove database isolation: two adapters pointing to the same table are an
invalid host composition. Final IDs are scoped root keys in this namespace; no
billable call or ordinal is fabricated. Do not use untrusted search_path entries.
Deploy all execution/accounting/admission migrations before the matching readers. Register both result namespaces and sessions in host erasure/retention workflows; createMemoryErasure's single results handle alone cannot cover both. Erasure or expiry of completed output returns unavailable and never regenerates content. Provider live smokes, publication and product delivery are separate evidence.
Completion and reconciliation
The result is a status view: completed with a TurnResult, busy with a root key,
in_progress, reconciliation_required, unavailable, or a non-durable not_admitted
routing refusal. read(scope,key) rechecks
admission, closed root, financial completion and authorized final content. It does
not dispatch, repair effects or load conversation context. Replays retain original
usage/cost and mark replayed: true; do not add them to new spend. Root warnings
are historical deduplicated evidence; notification delivery is a host concern.
Tool calls of the turn (spec: turn-tool-calls). A completed view's
result.toolCalls lists the calls the turn committed to the session, in order
across its steps: { id, name, input, isError }.
Main thread only: a delegate's calls never reach the session, and a step that trips a block cap commits none.
isErroris the stored result's flag. In a completed view the dispatcher sets it for:an unknown or invalid call, a hook veto, or a sensitivity refusal. The handler did not run;
a
tryDelegaterefusal the handler returned, or a handler output that cannot be serialized. The handler ran, and may have had effects.a call whose handler returned
toolError(payload)(spec: tool-error): an expected, completed failure, such as "not found". The turn continues and the model reads a native error (ERROR:on OpenAI and OpenRouter,is_erroron Anthropic). Throw only when the effects are unknown.
An error-shaped payload does not set it by itself, and a call with no paired result is
false. So the flag proves neither that a handler ran nor that it did not. A handler that throws, or a child call in it that fails or trips a block cap, makes the turn uncertain: it ends inreconciliation_required, without a list. A host that must not lose an effect keeps its own durable record of what the handler did.inputis the model's argument.Durable: the list lives in the final result, so replay,
readand the stream's final result return it, and erasure or expiry makes the view unavailable.Absent or empty: it is
[]for a turn without calls, and absent on a turn completed before it existed.Size: set the root result policy's
maxChars, at most 1,000,000, for the envelope with the reply and every tool input. A final result over it ends the turn inreconciliation_required.
The call limit (spec: call-limit-final-answer). maxCalls bounds every
model call of a turn: main steps, summaries, delegates and direct children.
- The last call asks for an answer. When one call is left and the step
advertises tools, the turn's messages gain a harness notice (a user message,
origin: "harness") telling the model to answer without tools. The tools stay advertised, so the cached prefix holds. A function tool it calls anyway never runs; a provider web search runs and is charged inside that call. - No call left ends the turn. A step's later tool calls do not run once a handler's children used the last call, and no step starts without one. A reactive rotation needs two calls left.
- The view is
completedwithresult.callLimit: { maxCalls, answered }. Withanswered: falsethe reply is empty, andtoolCallslists the unrun calls withisError: true. Hosts must inspectcallLimit, and show their own limit message when nothing was answered.readrefuses a malformed field. - Still uncertain: a child refused by the store inside a handler, a detached child, a failed or lost reservation acknowledgement, and every failed completion check. The store's refusal is never read as the limit.
- The step limit (spec: step-limit-final-answer).
maxStepsdefaults tomaxCalls, so it binds only when the host sets it lower; bumpconfigRevisionwhen upgrading, as for any behavior change. Reaching it ends the turn the same way, withresult.stepLimit: { maxSteps, answered }instead ofcallLimit(never both; a tie reportscallLimit). A delegate exceeding its ownmaxStepsstill makes the turn uncertain.
A cut step (spec: uncertain-turn-causes). A step the governed collector
refuses while the provider stopped at max_tokens is cut like any truncation:
nothing of it is kept, its receipt is. A main step completes the turn with
stopReason: "max_tokens" and an empty reply, toolCalls holding the earlier
steps' calls; a direct or delegate child hands its handler empty text. A
refused cold-start summary, or a refused step at any other stop, stays
uncertain.
Why a turn is uncertain (spec: uncertain-turn-causes). onUncertainTurn(event),
an optional synchronous observer, receives { scope, sessionId, rootKey, cause,
persisted, released } once, as the view of a turn this invocation tried to claim
settles reconciliation_required, after the runner's release steps (below). A replay, read, busy or not_admitted never
calls it, and a throw from it is swallowed. It is not behavior: changing it
needs no new configRevision. The cause is the first site that noted it, a
closed name and never an error's text:
deadline,cancelled: the turn's deadline or run timeout, the caller's signal.call:<kind>:<outcome>:<stop>(e.g.call:main:unavailable:end_turn;<stop>isnonewhen null). A call the collector refused names why:call:<kind>:invalid:<stop>:<reason>, such ascall:main:invalid:end_turn:output_chars, from the step runner's closedinvalidReason(spec: invalid-output-reason). A host that matches causes matches the prefix; a turn stored before it has no reason.call:summary:empty,call:<kind>:rejected:<proof>,call:<kind>:<status>for a call that did not complete,call:<kind>:reconciliation_required:<reason>for a call cut for a reasoning loop, its cost unknown (spec: loop-abort-unsettled),call:<kind>:abortedfor a call its step runner aborted (usuallystepTimeoutMs), andcall:<kind>:errorfor anything else the step runner threw.<kind>ismain,summary,directordelegate.tool:<name>,hook:<name>: a handler or hook that threw.session_load,session_append,recall,media,input: those sources.child: a child failure no call named first.children_pending,accounting_mismatch,root_store,result_store,admission_store: a completion check.loop: anything else the loop handled itself.
A handler whose child call failed reports the child's cause, not tool:<name>.
Log the cause beside the root key; the view itself stays
{ status: "reconciliation_required" }.
Failed calls (spec: call-retry). With callAttempts (1 to 5), a turn
does not stop on a failed model call. Set it only when the host handles
unanswered, and bump configRevision, as for any behavior.
- What is tried again. A call of any kind that the step runner timed out
(
call:<kind>:aborted) or cut at the answer-by time (call:<kind>:deadline), that did not complete (unknown usage, never dispatched), that settled without usable output (unavailable, orinvalidat any stop butmax_tokens), a pre-stream refusal the turn does not release, or an empty summary. Each attempt is a new call under the same root, while attempts,maxCallsand time are left; there is no backoff, and each is paid. - A store failure before the provider (spec: undispatched-call-retry). A
store, audit sink or reservation failure is tried again only when the call's
journal proves it was never sent: no record, or
not_dispatched. Its cause iscall:<kind>:error, and a reservation it left counts inunsettledCalls. - A refused request (spec: refused-request). When the call's journal
reads
not_dispatchedwith causeinvalidfor the attempt's own input, the step runner refused the request before dispatch: most often it is overmaxRequestChars, or the provider adapter refused it. The same request is refused again, so the call ends without another attempt, with causecall:<kind>:request_invalid. If media made the request too large, the next turn of the session may be refused too, at no provider cost: raisemaxRequestCharsor shrink media when it enters. Alma does not prune the history, which would break the cached prefix. - A retry's reasoning (spec: retry-routing). Before the second and later
attempts, the policy is asked again with the call's
tier,sensitivityandtask, plusattemptandpreviousCause(the cause before, such ascall:main:reconciliation_required:reasoning_loop). It may lower thereasoning; the model, tier and cache must stay, andstep:preruns again on the retry's request. The first attempt is always the admitted choice. A policy that throws or asks for more ends the call withcall:<kind>:routing_unavailable, without another attempt. - What is not. Any other store failure, any
AbortError(a store's own, or the step runner's after a store failure), a store write the stream saw fail, an identity conflict, a fenced scope, a block cap, cancellation and the turn's deadline stay as they were: uncertain, or the budget stop. - The answer-by time. Calls end
min(15 s, runTimeoutMs / 4)before the earlier of the root's deadline and the invocation's timeout, leaving room to close. A handler still running at that end keeps the turn uncertain. - The final answer by time. From the second main step on, when less than
the step runner's
runTimeoutMsis left before the answer-by time, the next step is the last, like the last call of the call limit; the result carriestimeLimit: { answered }. Keep the step timeout well below half the turn's. - Out of attempts. A main step completes the turn with
unanswered: { cause }, an empty reply andstopReason: null; show your own message. Adirectordelegatechild hands its handler empty text. A summary stays uncertain. - Recall that rejects is called again within
recallTimeoutMs; when it keeps failing or times out, the turn goes on without it, withdegradedTiers: ["unavailable"]on the trail. - The result adds
failures(every failed call and skipped recall, by cause, in order) andunsettledCalls(reserved calls without a settled cost at completion;costUsdis what was known then, and a late settlement does not change the stored turn). Alert on any of them. An unsettled call counts toward no cap, and staysreconciliation_requiredin the journal.
finalAnswerNotice (1 to 1,000 characters) replaces the English text of every
final-answer notice: the call, step and time limits. Bump configRevision.
terminalTools (spec: terminal-tools) names registered tools whose success
ends the turn: when every call of a main step returned a result that is not
an error and one of them named a terminal tool, the turn completes on that
step's tool_use without another model call. An error on any call of the
step keeps the turn going, so the model reads it (spec:
terminal-sibling-error). Read the turn's toolCalls to know the terminal
call ran; tool_use alone also follows a provider stop with no call. The list
is copied when the runner is built. Bump configRevision.
A fenced scope (spec: scope-fence). A new key rejects with
ScopeFencedError before admission, from runTurn and from both the result
and the iterator of streamTurn, so nothing is left to reconcile. A stored key
still replays. A turn admitted before the fence ends its next step or session
append in reconciliation_required and dispatches nothing more. On PostgreSQL, a new key
in a session that turn still occupies answers busy, since occupancy is checked
before the refused insert.
Normal completion awaits handlers/children, append and turn:end, closes the root,
persists and rechecks the final envelope, then finishes admission. A lost finish
ACK is accepted only after reading back matching owner release, never operator
resolution. Other uncertain writes, failures, cancellation and timeouts end the
turn reconciliation_required, and the runner frees its session before the view
returns (spec: failed-turn-release, below). Available content alone is not
proof of session/effect completion. No runner path calls resolve or retries append.
If the public timeout or cancellation wins while the turn's finish commits, the
turn completed: the observer reports released: true, and read and a retry
under the key return completed. A finish that comes after the release steps
marked the turn uncertain is refused, and the turn stays uncertain.
Handlers MUST await all external work. The harness tracks started model children, poisons swallowed child failures, and revokes gateways after each handler settles; it cannot discover arbitrary detached work hidden inside trusted host code. An ordinary handler rejection is uncertain even if labelled read-only. Hanging or failed context/hook work cannot authorize further effects. Timed-out dependencies may finish late; settlement evidence can drain in the background, but the turn never resumes automatically, and none of its history lands once it is uncertain. Use the step runner's recovery APIs for accounting; that does not complete a conversation.
Governed output preserves cross-category block order and omits reasoning from the reply. Refusal/truncation obey the governed-step output contract. There is no token streaming or automatic reactive context-window retry. Cold-start rotation is supported; uncertain provider failures require reconciliation. Batch/routines, history import and channel delivery remain separate adoption work.
The full PostgreSQL-backed gate exercises deterministic conversation composition, separate result namespaces, durable replay/erasure and lost append acknowledgements. No Prumo tour scene is added here: its source budget is nearly full, and the new capability's executable acceptance path is packages/conversation/test. A tour integration needs a separate budget-preserving scene slice.
Tools can use ctx.models.tryDelegate to handle structural delegation refusals.
Returning that exact invocation-local object emits an error tool result while the
parent continues. A refusal is not evidence that the handler had no effects;
throwing handlers, failed siblings, outstanding children and persistence failures
still require reconciliation. The existing delegate method retains its throwing
contract. S5 demonstrates explicit forwarding and settled parent/child accounting.
Safe context rotation
Cold rotation is unchanged. Reactive rotation requires a retained, authorized Anthropic pre-generation rejection plus its original zero call/root receipts. One summary and one replacement main call use new child identities under the same root/caps/deadline. Generic errors, ordinary zero usage, contradictory content, erased/expired proof, lost acknowledgements and cancellation cannot permit continuation. Earlier tools are never replayed. The OpenAI/OpenRouter parity exception remains for consolidated maintainer acceptance before cutover (spec: safe-context-rejection-rotation).
When an attested refusal finishes without another rotation (no policy, no removable
head, or a second refusal), the view is completed, result.terminalReason is
completed, result.stopReason is context_window_exceeded, and
result.reply.blocks is empty. Hosts must inspect stopReason, not just the
completed status: show a context-limit outcome instead of delivering a blank answer
as success. This replaces the legacy typed, retryable window error. The same admitted
key replays the original outcome; it does not retry the provider. A new attempt
requires a deliberate host decision and an appropriate context change. Generic
provider errors without attestation still require reconciliation.
A refused first call (spec: pre-stream-rejections)
When a session is released. A provider may refuse a turn's first call
before any stream, with a rate limit or an Anthropic overload that the adapter
attests. That turn ends without occupying the session only if the
configuration sets releasePreStreamRejections: true. The flag is the host's
declaration that everything it supplied that runs before the first call, or in
turn:end, may run again for the same input under a new key: the input
loader, volatile, hooks, recall and the media source. It is part of the
configuration, so changing it needs a new configRevision.
What the host gets. The view is completed with:
result.terminalReason: "error";result.failurewith kindrate_limitedoroverloaded, retryable;- an empty reply, and no message of the turn in the session, the input included.
The admission is released, the same key replays that outcome, and a retry sends the same input under a new key after the host's own pause.
Retryable is not a promise. An Anthropic spend cap answers with the same rate-limit error and keeps failing, so the retry policy must be bounded.
When the turn stays uncertain. Without the flag, on any later call, a
summary or a child, after the last-call notice, or when any completion step
fails, the turn stays reconciliation_required, as before, and frees its
session like any uncertain turn (spec: failed-turn-release). The refused call
is settled at zero in every case.
An uncertain turn frees its session (spec: failed-turn-release). Before
it returns reconciliation_required, the runner:
- marks the turn's admission uncertain. Every history append of a turn is fenced on its admission, as an event's is (spec: expired-turn-release): it writes only while the admission is active and inside its deadline, holding it until it commits, so once the mark returns nothing of the turn lands;
- reads the session's log:
persistedis whether a message carries the turn's id (meta.turn),nullwhen the read failed; - releases the admission with
admissions.releaseFailed(scope, rootKey), whose resolution isfailedReleaseId(rootKey).
The host gets the error and decides. onUncertainTurn says why (cause),
whether the turn's messages are in the session (persisted), and whether the
session is free (released):
persisted: falsemeans the transcript holds nothing of the turn, so sending the same input again under a new key records it once.nullpromises nothing: treat it astrue. Neither says whether the person saw a streamed reply, nor whether a handler's effects happened.released: trueconfirms the session is free, by this release or by the turn's own completion.released: falseconfirms nothing: the mark or the release failed, its acknowledgement was lost, another release (the fence's, an operator's, the expired one) came first, or the claim was still in flight when the view returned. The turn's own work runs the steps once more when it settles.
The old root stays reconciliation_required to read and retry, and is never
answered; its calls keep their states and accounting. The release guarantees
the transcript's order only: a handler that ignores its abort, a store it calls
itself or a remote effect may still complete and overlap the next turn. Keep
handlers inside their run, or make their effects idempotent.
When the release does not happen (a refused mark or release, a process that
died): the admission is released automatically once its deadline is fifteen
minutes past, by maintenance or any caller of admissions.releaseExpired(scope),
with expiredReleaseId(rootKey) (spec: expired-turn-release), or sooner by an
operator's resolve, through the runbook.
Facts recorded out of turn (spec: conversation-events)
What it is for. The person saw or said things no turn produced: a proactive
reminder, a professional's reply during a takeover, a fallback notice, messages
that arrived while a turn was stuck, a reply that was never delivered.
runner.recordEvents({ scope, sessionId, events }) records 1 to 20 of them in
the session, in order. Never write them with SessionStore.append: it bypasses
admission and is not idempotent.
await runner.recordEvents({ scope, sessionId, events: [
{ id: "msg-881", at: "2026-10-04T12:00:00Z", kind: "message", role: "assistant", origin: "human",
blocks: [{ type: "text", text: "Hi, this is Ana from the clinic." }] },
{ id: "msg-882", at: "2026-10-04T12:01:00Z", kind: "undelivered", turn: view.result.turnId },
] });- Events.
messagetakesroleand 1 to 16 text blocks;origin(humanorsystem) only on an assistant message.undeliveredtakes the id of the turn whose reply the person did not see,TurnResult.turnId(for a simple turn,conversationRootKey(...)), and appends a harness note rendered from theundeliveredlabel;{at}in it becomes the reply's time. A turn without a persisted reply is refused (below).atis when the fact happened; the log keeps record order. - Results.
recordedwithappendedandreplayed;busywhile a turn or another batch holds the session, retried later with the same batch; orreconciliation_required(below). - Idempotent by id over the whole log. A present prefix is skipped, so a drain cut after its commit resends safely. An event with the id of a turn input replays when its text equals the input's non-harness text.
- Refusals (spec: fenced-event-append). A batch the log can never accept
throws
EventRefusedErrorwith the offendingeventIdand areason:conflict(a present id with other content, or present after an absent one) orno_reply(anundeliveredwhose turn persisted no reply). Nothing is written and the session is free. It is anExecutionConflictError; an outbox skips and alerts on it, and retries every other error. - Turn inputs. A simple
runTurninput may carrymeta.event: { id, kind: "input" }, persisted as{ id, kind: "input" }. A duplicate id rejects withDuplicateEventErrorbefore any admission, hook or call; a same-key replay is not refused. An explicit root's lazy input cannot carry one. - Rendering.
eventLabels: { human?, system?, undelivered? }(1 to 500 characters, no control characters; bumpconfigRevisionto change them) label product-side messages, which reach the model as the person's side. A proactive assistant message goes as the assistant's own. The rotation summary and memory extraction label the same lines and mark an undelivered reply.
A fenced append (spec: fenced-event-append). The store writes the batch
only while its admission of the session is active and inside its deadline,
holding it until the commit, so no bound is needed: a paused client either
commits before the admission ends or writes nothing. A custom SessionStore
shared across processes must check opts.admission in the append's
transaction.
When an append fails. The runner finishes the admission, which waits for
the append, and reads the log once: present returns recorded; absent
rethrows the store's error with the session free, and the retry records the
batch once. Only if the finish fails too does the call return
reconciliation_required, the admission held to its deadline. A turn there
reads busy with the event's key. After the deadline, the host's tick runs
runner.releaseExpiredEvents({ scope, sessionId }). It releases the
session's expired event admission, active or uncertain, never a turn's; no
reconcileExpired is needed.
A session that opens with the assistant. A proactive message recorded before any turn makes the next request start with an assistant message, and a product-side event right before an input sends two user messages in a row. Alma's adapters do not reorder either. OpenAI accepts both; Anthropic merges consecutive same-role messages, and a host on Anthropic should check an assistant-first session in its own environment before relying on it.
Erasure. Events live in the session and go with it. Erasing a session also erases the only evidence of an id: purge or fence your outbox first, or a late retry records its event again.
The four product-visible differences (main-only progress usage, unpersisted input after a start-hook failure, withheld truncated tool closures, and completed context refusal) are enumerated for explicit owner acceptance in the review packet.
Private feature tests exercise volatile context, reasoning persistence and parent/delegate request-control isolation through this canonical API. Scripted usage remains explicit; missing evidence is never converted into a free call.
Deferred and provider-tool integration tests use the same canonical fixture, including grant isolation, settled search receipts, pause continuation and uncertainty when a search cannot be priced.
Media/wire tests also cover explicit lazy inline input, reference-only simple input, history replay and truncation suppression with retained receipts.
Financial feature tests pin requested versus served tier pricing, root warning attribution, sibling blocks and rejection of the removed per-turn override surface.
Output/text feature tests preserve tool ceilings and redaction, measured recall, Unicode repair for tools, invalid provider-text refusal and owning reply identity.
Recall feature tests distinguish normal content gating from source uncertainty, pre-admission cancellation and delegate sensitivity, with bounded timeout evidence.
Hook-boundary tests preserve request narrowing and shape-only trails, including retained uncertainty after sink failure or mutation of frozen hook input.
Recall uses the loop's single deadline with lifecycle-guarded source access. Cancellation clears the timer even if the source never settles; late source results cannot append or dispatch.
Basic engine feature tests cover schema/scope, profiles, nested grants and actual root/call correlation. Structural delegation refusals are explicit; execution failures retain uncertainty and suppress subsequent host lifecycle effects.
Construction rejects missing or noncallable spend readers. Persistent-cap tests use original governed receipts and counters, including tenant isolation and retained uncertainty when reads or settlement fail.
Lifecycle tests pin listener cleanup, frozen registration and awaited audit failures. A pre-aborted request is never admitted; a poisoned start hook leaves buffered input unpersisted. The final append is one user/assistant batch. Failure views withhold private errors and partial replies while original known receipts remain readable.
Final integration parity covers complete replay snapshots, daily budget-stop replay, no TTL takeover, main-only steps and duration before end hooks, cold summary history, and documented settled-stream consumption. The test ledger maps all remaining legacy cases and their explicitly superseded contracts.
Malformed cap configuration rejects with TypeError from the canonical financial normalizer, replacing legacy AgentConfigError. Invalid path scope rejects with InvalidScopeError before admission. Neither error authorizes execution.
Tool selection remains host-owned at the model-request boundary. The governed
step projection retains toolChoice in execution controls, while step:pre
cannot inject or change it, on parent or delegated steps. No global conversation
selection setting is added: one-call structured extraction uses the existing
createGovernedStepRunner from @alma-harness/single-call without session append,
recall or automatic memory extraction.
The text-only ctx.models.direct gateway rejects a supplied toolChoice rather
than discarding it; invalid child work retains the normal uncertain lifecycle.
