@shardworks/animator-apparatus
v0.1.310
Published
The Animator — session launch and telemetry recording apparatus
Readme
@shardworks/animator-apparatus
The Animator brings animas to life. It is the guild's session apparatus — the single entry point for making an anima do work. Two API levels serve different callers:
summon()— the high-level "make an anima do a thing" call. Passes the role to The Loom for identity composition, then launches a session with the work prompt. This is what the summon relay, the CLI, and most callers use.animate()— the low-level call for callers that compose their ownAnimaWeave(e.g. The Parlour for multi-turn conversations). Rejects at the top with a synthesizedSessionResult { status: 'rate-limited', … }when the rate-limit back-off machine is paused; no SessionDoc is written for the rejected call.getSessionCosts()— bulk per-session cost/token lookup for read-side consumers. First read-side helper onAnimatorApi; used by Spider's rig-view aggregator to compose rig-level totals and per-engine breakdowns without reaching into thesessionsbook directly.getStatus()— returns the rate-limit back-off state document verbatim (see § Rate-Limit Back-Off). The canonical dispatchability predicate is exported asisDispatchable(doc)from this package's index; TypeScript callers should import that helper rather than re-composing it. Non-TS consumers (e.g. the Oculus banner) read the server-computeddispatchableboolean enriched onto theanimator-statustool //api/animator/statusroute response.
Both methods return an AnimateHandle synchronously — a { chunks, result } pair. The result promise resolves when the session completes. The chunks async iterable yields output as the session runs when streaming: true is set; otherwise it completes immediately with no items.
Depends on @shardworks/stacks-apparatus for persistence (session records and full transcripts). Uses @shardworks/loom-apparatus for context composition (resolved at call time by summon(), not a startup dependency). The session provider (e.g. @shardworks/claude-code-apparatus) is discovered at runtime via guild config.
Installation
{
"dependencies": {
"@shardworks/animator-apparatus": "workspace:*"
}
}API
The Animator exposes its API via guild().apparatus<AnimatorApi>('animator'):
import { guild } from '@shardworks/nexus-core';
import type { AnimatorApi } from '@shardworks/animator-apparatus';
const animator = guild().apparatus<AnimatorApi>('animator');summon(request): AnimateHandle
Summon an anima — compose context via The Loom and launch a session. This is the primary entry point for dispatching work. Returns synchronously.
const { result } = animator.summon({
prompt: 'Build the frobnicator module with tests',
role: 'artificer', // passed to The Loom for composition
cwd: '/path/to/workdir',
metadata: { // optional, merged with auto-generated metadata
writId: 'wrt-8a4c9e2',
},
});
const session = await result;
console.log(session.status); // 'completed' | 'failed' | 'timeout' | 'cancelled'
console.log(session.costUsd); // 0.42
console.log(session.output); // final assistant message text
console.log(session.metadata?.trigger); // 'summon' (auto-populated)
console.log(session.metadata?.role); // 'artificer' (auto-populated from request)With streaming:
const { chunks, result } = animator.summon({
prompt: 'Build the frobnicator module with tests',
role: 'artificer',
cwd: '/path/to/workdir',
streaming: true,
});
for await (const chunk of chunks) {
if (chunk.type === 'text') process.stdout.write(chunk.text);
}
const session = await result;The Loom owns system prompt composition — given the role, it produces the system prompt from the anima's identity layers (role instructions, curriculum, temperament, charter). The work prompt bypasses The Loom and goes directly to the session provider. At MVP, the Loom does not yet compose a system prompt (returns undefined); the session runs with the work prompt only. As the Loom gains composition logic, summon() callers get richer sessions without changing their code.
Requires The Loom apparatus to be installed. Throws with a clear error if not available.
animate(request): AnimateHandle
Launch a session with a pre-composed context. Use this when you've already built an AnimaWeave yourself (e.g. The Parlour assembling inter-turn context for a multi-turn conversation). Returns synchronously.
const { result } = animator.animate({
context: animaWeave, // from The Loom or self-composed
prompt: 'Do the thing', // work prompt, sent directly to provider
cwd: '/path/to/workdir',
conversationId: 'conv-xyz', // optional, for multi-turn resume
metadata: { // optional, recorded as-is
trigger: 'consult',
animaName: 'coco',
},
});
const session = await result;With streaming:
const { chunks, result } = animator.animate({
context: animaWeave,
prompt: 'Build the feature',
cwd: '/path/to/workdir',
streaming: true,
});
for await (const chunk of chunks) {
if (chunk.type === 'text') process.stdout.write(chunk.text);
}
const session = await result;If the session provider doesn't support streaming, chunks completes immediately with no items and result resolves normally via the non-streaming path — regardless of the streaming flag.
cancel(sessionId, options?): Promise<SessionDoc>
Cancel a running session. Patches the SessionDoc to 'cancelled' with endedAt, durationMs, and an optional reason. If the provider supports cancellation and cancelHandle is available, delegates to the provider's cancel() method to kill the process.
const doc = await animator.cancel(sessionId, { reason: 'Cost overrun' });
console.log(doc.status); // 'cancelled'
console.log(doc.error); // 'Cost overrun'Idempotent: calling cancel() on a session that is already in a terminal state (completed, failed, timeout, cancelled) returns the existing SessionDoc without modification. Throws if the session ID does not exist.
Cross-process: the cancelHandle field on SessionDoc stores a tagged cancel handle for cross-process cancellation (e.g. { kind: 'local-pgid', pgid: number } for local process groups). This allows any process with Stacks access to cancel a session launched by another process.
getSessionCosts(sessionIds): Promise<Map<string, SessionCost>>
Bulk per-session cost/token lookup. Resolves cost and token-usage snapshots for the given session ids in a single round-trip against the sessions book. Intended for UI-facing aggregators that compose rig-level totals or per-engine breakdowns.
const costs = await animator.getSessionCosts(['ses-a', 'ses-b', 'ses-missing']);
costs.get('ses-a'); // { costUsd: 0.15, inputTokens: 1000, outputTokens: 200 }
costs.get('ses-missing'); // undefined — ids not present in the book are omittedMissing ids: session ids not present in the sessions book are omitted from the returned Map. Callers decide whether that means "zero contribution" (Spider's rig-view does) or something else.
Empty input: returns an empty Map without touching The Stacks.
Shape: deliberately minimal — costUsd (zero when the session exists but has not reported cost), plus optional inputTokens / outputTokens when the provider reported token usage. Consumers that need other SessionDoc fields should look them up separately.
Types
interface SummonRequest {
prompt: string; // The work prompt (sent to provider directly)
role?: string; // Role name (passed to The Loom for composition)
cwd: string; // Working directory for the session
conversationId?: string; // Optional, for multi-turn resume
metadata?: Record<string, unknown>; // Merged with { trigger: 'summon', role }
environment?: Record<string, string>; // Per-request env overrides (merged with weave)
streaming?: boolean; // Enable streaming output (default false)
}
interface AnimateRequest {
context: AnimaWeave; // Pre-composed identity context
prompt?: string; // Work prompt (sent to provider as initialPrompt)
cwd: string;
conversationId?: string;
metadata?: Record<string, unknown>;
environment?: Record<string, string>; // Per-request env overrides (merged with weave)
streaming?: boolean; // Enable streaming output (default false)
}
interface AnimateHandle {
chunks: AsyncIterable<SessionChunk>; // Empty when not streaming
result: Promise<SessionResult>;
}
interface SessionResult {
id: string; // Generated by The Animator (ses-{hex})
status: 'completed' | 'failed' | 'timeout' | 'cancelled' | 'rate-limited';
startedAt: string; // ISO-8601
endedAt: string; // ISO-8601
durationMs: number;
provider: string; // e.g. 'claude-code'
exitCode: number;
error?: string;
conversationId?: string;
providerSessionId?: string;
tokenUsage?: TokenUsage;
costUsd?: number;
metadata?: Record<string, unknown>;
output?: string; // Final assistant message text
}
type SessionChunk =
| { type: 'text'; text: string }
| { type: 'tool_use'; tool: string }
| { type: 'tool_result'; tool: string };
interface SessionCost {
costUsd: number; // Zero when the session exists but has not reported cost
inputTokens?: number; // From the session's tokenUsage, if reported
outputTokens?: number; // From the session's tokenUsage, if reported
}Configuration
The Animator reads its config from guild.json["animator"]:
{
"animator": {
"sessionProvider": "claude-code",
"rateLimit": {
"backoff": {
"initialMs": 900000,
"maxMs": 3600000,
"factor": 2
}
}
}
}| Field | Type | Default | Description |
|---|---|---|---|
| sessionProvider | string | 'claude-code' | Plugin id of the apparatus that implements AnimatorSessionProvider. Looked up via guild().apparatus(). |
| rateLimit.backoff.initialMs | number | 900_000 (15 min) | Initial pause window when the first rate-limit hit arrives. |
| rateLimit.backoff.maxMs | number | 3_600_000 (1 h) | Upper bound for the pause window after exponential back-off. |
| rateLimit.backoff.factor | number | 2 | Multiplier applied per successive failed resume attempt. |
The rateLimit.backoff block is validated fail-loud at startup (a patron override of the Animator's default silent-default convention, scoped to this block only). Malformed values throw; a missing block uses the defaults. The rateLimit umbrella is future-proofing: today it only holds backoff, but further rate-limit tuning (detection thresholds, per-source overrides) can land nested under it without flattening the top-level namespace.
Session Provider Interface
Session providers are apparatus plugins whose provides object implements AnimatorSessionProvider:
interface AnimatorSessionProvider {
name: string;
launch(config: SessionProviderConfig): {
chunks: AsyncIterable<SessionChunk>;
result: Promise<SessionProviderResult>;
processInfo?: Promise<Record<string, unknown>>; // e.g. { kind: 'local-pgid', pgid: number }
};
cancel?(cancelMetadata: Record<string, unknown>): Promise<void>;
}
interface SessionProviderConfig {
systemPrompt?: string; // From AnimaWeave (Loom output)
initialPrompt?: string; // From AnimateRequest.prompt (work prompt)
model: string;
conversationId?: string;
cwd: string;
environment?: Record<string, string>; // Merged env vars (weave + request overrides)
}
interface SessionProviderResult {
status: 'completed' | 'failed' | 'timeout' | 'cancelled' | 'rate-limited';
exitCode: number;
error?: string;
providerSessionId?: string;
tokenUsage?: TokenUsage;
costUsd?: number;
transcript?: TranscriptMessage[]; // Full NDJSON message array
output?: string; // Final assistant message text
/**
* Structured termination tag. Providers attach this when the terminal
* status reflects a specific detected condition (today: rate-limit).
* The Animator forwards it onto the SessionDoc / SessionResult so
* downstream consumers don't have to pattern-match freeform error text.
*/
terminationTag?: { kind: 'rate-limit'; source: 'ndjson-result'; detail?: string };
}The Animator imports these types; provider packages import them from @shardworks/animator-apparatus and implement them.
Oculus Page
The Animator contributes an Animator page to the Oculus dashboard (id: 'animator') for viewing and managing sessions.
Session List
- Displays sessions with status badge, role, writ title, cost (with token breakdown tooltip on hover), duration, and start time.
- Filters: status dropdown, date range (from/to).
- Auto-refreshes every 12 seconds.
- Running sessions show a Cancel button.
Session Detail
Click a session row to view full metadata, a real-time session log (SSE streaming for running sessions), and the full transcript.
Custom Routes
Three API routes under /api/animator/:
| Route | Description |
|---|---|
| GET /api/animator/sessions | Enriched session list with role, writTitle (resolved from Clerk), and tokenUsage. Supports status, from, to, limit query params. |
| GET /api/animator/session-transcript | Returns { messages, sessionStatus } for a session. |
| GET /api/animator/session-stream | SSE stream — emits chunk, transcript, and done events. Handles completed sessions, running sessions with/without broadcaster. |
GET /api/animator/status is not in this list: the animator-status tool (see the tools table above) is auto-registered by Oculus at that path and returns the raw AnimatorStatusDoc — no custom handler needed. The Spider Oculus pause banner reads from the auto-registered route without modification.
Support Kit
The Animator contributes two books, inspection/dispatch tools, an Oculus page, and custom routes:
Books
| Book | Indexes | Description |
|---|---|---|
| sessions | startedAt, status, conversationId, provider | Session records — one per animate() call. Includes output (final assistant text). |
| transcripts | sessionId | Full NDJSON transcripts — one per session. Drives web UIs, operational logs, debugging. |
| state | — | Shared operational state. Two well-known documents: guild-heartbeat (written by the heartbeat timer) and dispatch-status (owned by the rate-limit back-off machine). See § Rate-Limit Back-Off. |
Tools
| Tool | Permission | Description |
|---|---|---|
| session-list | read | List recent sessions with optional filters (status, provider, conversationId, limit) |
| session-show | read | Show full detail for a single session by id |
| summon | animate | Summon an anima from the CLI — compose context and launch a session |
| session-cancel | animate | Cancel a running session by id, with optional reason |
| session-running | write | Record initial "running" state for a detached session |
| session-record | write | Record a terminal session result for a detached session |
| session-heartbeat | write | Refresh session liveness timestamp (called periodically by babysitters) |
| animator-status | read | Show the Animator's current rate-limit pause state as JSON (the CLI auto-printer pretty-prints it) |
The summon and session-cancel tools are patron-only (callableBy: 'patron'). The session-running, session-record, and session-heartbeat tools are infrastructure-facing (callableBy: 'anima') — called by session babysitters over the Tool HTTP API to report detached session lifecycle events. See docs/architecture/detached-sessions.md.
Response-enrichment convention
Tools may compute display-only derived fields whose source data is in the API's verbatim return; the API itself stays verbatim. The convention is: keep the public AnimatorApi shape free of presentation-layer fields, and let the tool — sitting at the request-time boundary — decorate the response with anything that requires now() or otherwise depends on call time.
The canonical example is the animator-status tool. Its underlying API call is getStatus(), which returns the persisted AnimatorStatusDoc verbatim — the doc never carries a dispatchable field. The tool's response, on the other hand, includes a server-computed dispatchable: boolean derived from the canonical isDispatchable(doc) predicate at request time. Non-TypeScript callers (the Oculus pause banner) read the enriched field; TypeScript callers compose against isDispatchable(doc) directly. The persisted shape stays narrow, the wire shape stays self-contained, and the predicate has exactly one source of truth.
Startup Routines
Before the operational stages below run, start() performs three boot-time preconditions on the apparatus literal: validateBackoffConfig (fail-loud check on the persisted back-off configuration — bad config refuses to boot rather than silently using defaults), setBackoffMachine (registers the back-off machine with the session-record handler so terminal writes can hand the rate-limit signal to the back-off state machine), and setEmitter (registers the lifecycle-event emitter with the same handler so terminal writes also fire animator.session.ended and animator.session.record-failed through one call site). These are wiring, not operational stages; they finish before the numbered list begins.
On startup the Animator runs the following sequence. The start() method is async; it awaits the initial pause-state read up front so peek() reflects persisted state by the time start() returns. DLQ drain and reconciliation run in an async IIFE afterwards (fire-and-forget from Arbor's perspective):
Eager pause-state read — Awaits a single
BackoffMachine.read()at the very top ofstart(). The previous fire-and-forget version allowed the firstanimate()call to race with the initial load andpeek()a default running shape; the awaited read closes that window while keepinganimate()synchronous.DLQ Drain — Scans
.nexus/dlq/for JSON files containing session-record payloads that babysitters couldn't deliver (guild was down). Each file is processed through the session-record handler and deleted on success. The directory is created if it doesn't exist.Eager Pause-Window Reconciliation — Immediately after DLQ drain (and before orphan recovery / timers), the back-off machine's
reconcileOnBoot()runs: if the persisted dispatch-status doc isstate: 'paused'ANDpausedUntil <= now, it flips tostate: 'running', backoffLevel: 0, preservingbackoffLastHitAtandlastTriggeringSessionfor audit. This closes the drift window between persisted and observed state that used to stall post-expiry dispatch on first boot.Downtime Credit — Reads the previous
guild-heartbeatdocument from thestatebook to compute how long the guild was down. This credit is applied to the initial reconciliation pass so sessions that were healthy before the guild went down aren't falsely marked as stale.Heartbeat-based Reconciliation — Scans sessions in
pendingandrunningstates. Whennow - lastActivityAt - downtimeCredit > 90s, the session is transitioned tofailed. Sessions withoutlastActivityAt(legacy) are backfilled and skipped for one pass. Runs once at startup (with downtime credit) and then periodically every 30s (without credit) via an unref'd timer with a single-flight guard.Guild Self-Heartbeat — Writes
guildAliveAtto thestatebook every 30s via an unref'd timer. This timestamp is used to compute the downtime credit on the next startup.
Rate-Limit Back-Off
When the claude-code provider (or any future provider that sets a terminationTag) reports a rate-limited session terminal, the Animator opens a pause window that blocks further dispatch across every caller — Spider, Parlour, and CLI paths alike. The state lives as a single well-known document inside the shared state book at 'dispatch-status' (sibling of the 'guild-heartbeat' doc):
interface AnimatorStatusDoc {
id: 'dispatch-status';
state: 'running' | 'paused';
pausedSince?: string;
pausedUntil?: string;
pauseReason?: 'rate-limit';
backoffLevel: number;
backoffLastHitAt?: string;
lastTriggeringSession?: string;
}State transitions:
- Rate-limited terminal while running → opens a fresh pause with
pausedUntil = now + initialMs,backoffLevel: 0. - Rate-limited terminal while paused (no resume dispatch yet) → coalesces; level and bounds unchanged.
- Rate-limited terminal after a resume attempt dispatched → increments
backoffLevel, multiplies the window byfactor, caps atmaxMs. - Any non-rate-limit terminal → resets
backoffLevelto0and flips state torunning.
animate() pre-checks the cached status at the top of the function. When paused and pausedUntil > now, it returns a handle whose result resolves to a synthesized SessionResult { status: 'rate-limited', terminationTag, … } and no SessionDoc is written. In-flight sessions are not proactively cancelled.
Daemon restarts leave the persisted doc untouched; the first dispatch after pausedUntil elapses naturally flips the state back to running (the "natural probe" semantic).
Dispatchability is composed once. The canonical predicate — state === 'running' OR pausedUntil <= now — is implemented as isDispatchable(doc, nowMs?) and re-exported from the package index. TypeScript consumers (Spider's crawl gate, the animator-paused block-type, the animate() pre-check) all import that single helper. Non-TypeScript callers (e.g. the Oculus pause banner) read the server-computed dispatchable: boolean field that the animator-status tool / auto-registered /api/animator/status route enriches onto the response. The persisted doc shape never carries dispatchable — it is a presentation-layer field computed from now() at request time.
SessionDoc — passive termination diagnostic
When a session terminates with exactly status: 'failed' (non-zero exit, no structured termination tag, no cancel override), the provider attaches a passive terminationDiagnostic field to the session record:
interface TerminationDiagnostic {
/** Process exit code that produced the `'failed'` status. */
exitCode: number
/** Tail of the subprocess stderr (<= 200 chars) if any was captured. */
stderrExcerpt?: string
}
interface SessionDoc {
// ... other fields
terminationDiagnostic?: TerminationDiagnostic
}The diagnostic is informational only — the Animator's back-off state machine does not consume it. It exists so operators can audit the signal that did NOT satisfy the narrowed rate-limit detector. timeout, cancelled, rate-limited, and completed sessions never carry this field.
Exports
The main export provides the apparatus factory, API types, and provider interface types:
import {
createAnimator,
type AnimatorApi,
type AnimateHandle,
type AnimateRequest,
type SummonRequest,
type SessionResult,
type SessionChunk,
type TokenUsage,
type SessionCost,
type AnimatorSessionProvider,
type SessionProviderConfig,
type SessionProviderResult,
type SessionDoc,
type TranscriptDoc,
type TranscriptMessage,
type AnimatorConfig,
type AnimatorRateLimitBackoffConfig,
type AnimatorStatusDoc,
type AnimatorPauseReason,
type SessionTerminationTag,
type TerminationDiagnostic,
} from '@shardworks/animator-apparatus';The default export is a pre-created apparatus plugin instance:
import animator from '@shardworks/animator-apparatus';
// animator is { apparatus: { requires: ['stacks'], recommends: ['loom', 'oculus', 'clockworks'], provides: AnimatorApi, ... } }Framework events
The Animator declares three framework-owned events via its
supportKit.events kit contribution. The Clockworks merges these names
into its authoritative event set at startup, marking them
plugin-declared (so unprivileged emit channels — the anima signal
tool, the operator nsg signal CLI — reject them). Names follow the
catalog (docs/reference/event-catalog.md):
animator.session.started— written from every running-state transition site (in-process attached, detachedsession-runningready report).animator.session.ended— written from every terminal site (in-process attached on completion/failure/timeout/rate-limited, detachedsession-record, in-processcancel(), orphan recovery). Payload carriesexitCode,durationMs,costUsd, and (when present)error.animator.session.record-failed— fired from the catch path of session-doc / transcript writes that themselves failed (the only path the CDC observer on the sessions book cannot see). Thephasefield follows the catalog's three-phase taxonomy:'insert'for the initial running row,'update-row'for terminal-state SessionDoc overwrites (recordSession failure, cancel, orphan recovery, detached terminal), and'write-record'for transcript writes.
The rate-limit pre-check rejection path does NOT emit — no SessionDoc was authoritatively written.
The Clockworks is in recommends, not requires: the helpers resolve
it lazily and silently no-op when it isn't installed, mirroring the
summon() → LoomApi resolution pattern.
Anima-lifecycle events are deferred until the future Roster apparatus
lands — there is no aspirant → active state machine to observe today,
so the Animator does not emit any anima.* names.
Internal: SessionDoc transition reducer
Every in-package SessionDoc writer funnels through a single pure-function
reducer in src/session-reducer.ts. The reducer encodes the merge
invariants once — there is no bespoke per-writer merge code anywhere
else in the package.
The reducer accepts an existing: SessionDoc | null | undefined and a
SessionTransition (a discriminated union over a kind field) and
returns the next SessionDoc to write. Variants:
| kind | Used by | Notes |
|---|---|---|
| pending-pre-write | claude-code launchDetached() (deferred migration) | Authorization anchor; seeds lastActivityAt. |
| attach-running | recordRunning (in-process attached) | Canonical first-time running write. |
| detached-ready | session-running tool | Reducer detects the running → running refresh internally. |
| heartbeat-touch | session-heartbeat tool, orphan-recovery legacy backfill | Updates only lastActivityAt. |
| terminal | recordSession, handleSessionRecord | Carries 'completed' | 'failed' | 'timeout' | 'rate-limited' as a sub-field. |
| cancel | AnimatorApi.cancel() | Flips to 'cancelled'; provider cancel() runs at the call site after the put. |
| orphan-failed | startup.ts orphan recovery | Does NOT refresh lastActivityAt — the host is presumed dead. |
Invariants encoded once in the reducer:
- Preserve from existing:
startedAt,provider,authorizedTools. - Deep-merge:
metadata,cancelHandle. - Refresh
lastActivityAtonly from per-variant payload. Variants whose payload carries the field write it; others leave it untouched. - No-op on terminal-state regression. Any transition against a
terminal-state existing row returns
existingunchanged.
The reducer is a pure synchronous function: no I/O, no clock dependency,
no emission. Lifecycle event emission stays at the call sites — they
compare pre-reducer existing?.status against the post-reducer doc's
status to decide whether to emit animator.session.started /
animator.session.ended / animator.session.record-failed.
The reducer module also owns the TERMINAL_STATUSES set; the four
duplicate locals previously scattered across the package are gone, and
rate-limit-backoff.ts derives its NON_RATE_LIMIT_TERMINAL_STATUSES
from this consolidated set rather than maintaining a hand-listed
inverse.
Reducer audit: tools that stay standalone
The reducer covers every SessionDoc writer. Read-only and display-only surfaces are deliberately not folded through it because they never produce a transition:
session-show— pure read by id; the only invariant it adds abovesessions.get(id)is translating row-not-found into a thrown error. Folding this through the reducer would require inventing a no-op variant whose behaviour isreturn existing, which is whatgetalready does.session-list— pure read with filters; same rationale.animator-status— reads the dispatch-status doc (not a SessionDoc) and decorates the response with the request-timedispatchablepredicate; no SessionDoc transition is ever produced.
Design decisions index
The animator source carries a long tail of (Dn) traceability tags
that anchor source comments and test names to specific design
decisions made during the apparatus's lifecycle planning. This index
is the canonical mapping; bare (Dn) references in code are
intentional shorthand for the rules below. Source-level comments
generally inline a one-line rule name in addition to the tag (e.g.
// Co-location rule (D3): …); this table is the authoritative
source of truth when the names disagree.
| Tag | Rule name | One-line summary |
|---|---|---|
| D2 | Event-spec discipline | Event entries must declare a non-empty description; schema is omitted while no consumer needs payload validation. |
| D3 | Emit co-location rule | The lifecycle emitter and its emit-gate live in the same module (session-emission.ts) so the unprivileged-emit rejection is one read. |
| D5 | Heartbeat read+reduce+put | session-heartbeat writes through the reducer (heartbeat-touch variant) rather than sessions.patch, so every SessionDoc writer follows one merge contract. |
| D6 | Skip-when-unset rule | getSessionCosts() and similar bulk reads omit ids whose row is missing from the sessions book rather than synthesising a placeholder. |
| D7 | Non-rate-limit-terminal reset gate | A non-rate-limit terminal resets backoffLevel only when the session was dispatched after the current pause opened (in-flight stragglers don't count). |
| D8 | Coalesce-vs-increment rule | Rate-limit hits arriving while already paused coalesce; only a hit that arrives after a resume probe has dispatched bumps the back-off level. |
| D9 | Internal-refresh-detection / already-running refresh path | The reducer detects running → running refreshes internally and preserves metadata, startedAt, and lastActivityAt rather than asking call sites to branch. |
| D10 | Patron-override fail-loud config | Animator boot validates the rate-limit back-off block fail-loud — partial overrides are allowed, but malformed values refuse to start rather than silently using defaults. |
| D11 | Verbatim getStatus | AnimatorApi.getStatus() returns the persisted AnimatorStatusDoc verbatim — no composed dispatchable field on the API. The tool/HTTP boundary decorates the response at request time. |
| D12 | Pre-check rejection (synthesized rate-limit) | animate() rejects at the top with a synthesized SessionResult { status: 'rate-limited', … } when the back-off machine is paused; no SessionDoc is written for the rejected call. |
| D13 | Read-existing-first uniformity / best-effort emit | All terminal-write call sites read existing → reduce → put (rather than patching), and emit lifecycle events best-effort after the put succeeds. The animate() pre-check (Step 0) sits at the top of animate() before id generation or any SessionDoc write. |
| D16 | Transcript-write-on-duplicate-terminal early return | handleSessionRecord()'s "session already terminal" early return stays at the call site (not folded into the reducer) because the transcript-write side-effect needs the call site to know it's on a no-op path; the reducer's terminal-immutability rule is belt-and-suspenders for direct callers. |
| D17 | Legacy-row backfill rule | Orphan-recovery's legacy lastActivityAt backfill writes via the reducer's heartbeat-touch variant rather than sessions.patch, so the legacy row joins the modern merge contract. |
| D22 | Eager boot reconciliation of pause-window expiry | start() awaits a single BackoffMachine.read() up front so the first post-start animate() peek reflects the persisted state — a paused doc whose window has elapsed flips to running before the first dispatch. |
| D24 | Canonical dispatchability predicate / first-dispatch-flips-state | isDispatchable(doc) is the single source of truth for "may dispatch now" — combines state === 'running' with the pausedUntil <= now window check. Daemon restarts leave persisted state untouched; the first dispatch with pausedUntil <= now naturally flips the state to running via the back-off machine. |
Source-level pointers: rate-limit-backoff.ts's file header lists the
back-off rules (D7, D8, D11, D12, D24) it owns; session-emission.ts
documents D3 inline; session-record-handler.ts explains D16 inline;
startup.ts explains D17 inline. The reducer module
(session-reducer.ts) is the load-bearing site for D5, D9, D13, and
the read-existing-first uniformity rule. Tests names follow the
convention <rule name>: <behaviour> (Dn).
