Spectre provides a long-lived owner for the ordered state of one logical Agent and one canonical Subject:
Spectre.AgentRef + Spectre.Subject -> one local Spectre.InstanceAn Instance is not addressed by a PID, chat id, sender name, phone number, or
conversation id. Those values can change across restarts and channels. The
logical pair is portable; Spectre.Instance.Registry maps it to the current
local process. Since 0.2.3, the AgentRef key contains only the logical Agent id.
Its compiled module, declared version, and Stack digest are resolver hints and
do not split the Instance when behavior changes.
Start and find an Instance
Add the dynamic supervisor owned by the host application:
children = [
{Spectre.Supervisor, name: MyApp.SpectreSupervisor}
]Then start or find the unique Instance:
subject = Spectre.Subject.new({:account, account.id})
{:ok, instance} =
Spectre.instance(
MyApp.SpectreSupervisor,
MyApp.SupportAgent,
subject
)
{:ok, ^instance} =
Spectre.lookup_instance(MyApp.SupportAgent, subject)Concurrent instance/4 calls for the same pair converge on one PID. A
different Subject gets a different Instance and independent State. Supplying
an Spectre.AgentRef with an explicit logical id allows two intentionally
separate Agent identities to use the same compiled module.
Spectre.summon/1,3 also selects this runtime when :subject is present:
{:ok, instance} =
Spectre.summon(
MyApp.SpectreSupervisor,
MyApp.SupportAgent,
subject: subject
)Calls without :subject retain the legacy conversation-scoped
Spectre.Session behaviour.
Subject state and channel conversations
The persisted %Spectre.State{conversation_id: ...} of an Instance is its
opaque, stable AgentRef + Subject scope. It does not change when the same
Subject reaches that logical Agent through another authenticated channel, and
two logical AgentRefs backed by the same compiled module remain isolated.
Per-message channel origins remain on
%Spectre.Input.Source{conversation_id: ...} and are propagated to runtime
integrations as :origin_conversation_id.
Spectre.Instance.info/1 exposes only hashed conversation keys with channel,
count, and last-Run metadata. It never exposes the raw chat, thread, or session
identifier. This lets one Subject share ordered state across authorized
channels without treating a conversation id as identity proof.
Definition activation and ownership fencing
An Instance can bind its stable identity to one current immutable Definition:
{:ok, activation} =
Spectre.activate(instance, candidate_ref,
expected_generation: 0,
authority_epoch: 8
)
^activation = Spectre.activation(instance)The Candidate must already be published in the Instance's configured
:definition_store. Activation re-reads the Candidate and all publication
artifacts, checks generation CAS and the current owner fence, then commits the
canonical activation section. A durable checkpoint write is synchronous at
this boundary; conflict or ambiguity never falls back to the old or new value
by guesswork.
Every Instance claims a Spectre.Instance.Owner.Lease. The default local
adapter is suitable only when one VM/local Registry is the canonical owner.
Multi-node hosts must configure :owner with a linearizable lease and
monotonic fencing token. Lease loss blocks admission, commits, activation, and
Effect/operation dispatch.
See Stable Identity, Activation, and Definition-Pinned Runs for the publication flow and Migrating to 0.2.3 for legacy key migration.
Adapter authors should run Spectre.Instance.Owner.Conformance with the
:local or :distributed profile that matches the deployment. The
distributed gate proves monotonically increasing fences, supersession,
cross-Ref isolation, and exactly one current lease after concurrent claims;
the local profile intentionally relies on the unique local Registry instead
of claiming cross-node safety.
Offline configured-data erasure
The canonical checkpoint can be erased only while the Instance is offline:
ref = Spectre.Instance.Ref.new(MyApp.SupportAgent, subject)
{:ok, proof} =
Spectre.erase_instance(MyApp.SupportAgent, subject,
checkpoint_store: MyApp.Checkpoints,
owner: MyApp.InstanceOwner,
journal: MyApp.Journal,
receipt_sink: MyApp.Receipts,
confirm: ref.key
)The coordinator deletes configured Journal records and pending receipt payloads before atomically replacing present or absent checkpoint keys with anti-resurrection markers. The Owner must expose a non-preemptive maintenance claim. Existing adapters remain valid for normal Instance operation when the optional deletion callbacks are absent, but configured-data erasure returns an explicit capability error. See Offline Instance erasure and Instance data lifecycle for proof scope and host-owned data.
Private Skill state
The format-tagged schema-3 checkpoint also retains private Skill state as Definition-owned, generational branches. The active branch is selected by the current Activation; older branches remain dormant until an explicit resume, fork, migration, abandonment, or reference-safe retention transition. Activating A → B → A never merges B's state into A.
State reads and writes remain serialized by the same Instance owner. Writes require exact schema, generation, and revision checks plus current Definition authority and owner fencing. See Generational Skill State and Migrating to 0.2.5.
Run ownership and observable boundaries
Each input creates a Run retained by the Instance. The ready queue is FIFO and deduplicated. One Move is selected through an Instance mailbox message, so calls, monitoring, registry failure, and correlated worker results remain ordinary OTP messages rather than recursive reducer calls.
Run creation and its first advance form one bounded two-Move sequence. Input
normalization runs in the Move worker against the latest committed State, not
inside the GenServer callback. Later closed {:continue, run} steps return to
the FIFO tail. A worker crash fails only its owned Run, and a duplicate
caller-provided Run id is rejected before normalization or provider work.
{:ok, turn} = Spectre.turn(instance, "prepare the report")
case turn.observable do
{:reply, output, ref} ->
deliver_once(output, Spectre.Run.Ref.token(ref))
{:awaiting, ref} ->
Spectre.resume(instance, ref, {:execute, ref})
{:needs, _request} ->
Spectre.Turn.resolve_policy(turn, {:accept, :approved})
endThe public Turn contains only the boundary projection. The Instance keeps the
Run and verifies the supplied revision-fenced ref before resuming it.
Spectre.Instance.info/1 and run/2 do not return provider payloads or the
internal continuation, but they are trusted local diagnostics rather than a
safe object to log wholesale: caller-chosen IDs and explicitly published
Operation.View values can still be present. Apply host redaction before
exporting them. Both info and checkpoint-status reads are passive and do not
reset the Instance idle timer; trace_id/1, which derives from info, inherits
that behavior. This is a narrow monitoring exception: direct state, ref/1,
agent/1, configuration, lifecycle, retained-record, and checkpoint-payload
reads count as active use and re-arm the timer.
Effect and inference work run outside the Instance mailbox. Their internal receipts are fenced by Instance generation, Run id and revision, Invocation id, and dispatch id. Inference also carries attempt, control-revision and stream epoch fences. Late, duplicate, foreign, and stale receipts are ignored. The Instance remains responsive while a capability is in flight.
Each Effect and policy Awaitable staged by an Instance carries its owning Run
id. Several Runs can therefore wait independently at policy or Effect
boundaries while sharing the Subject's ordered State. Ordinary input is
matched to an open policy Run by its channel conversation origin. If no origin
is available, the legacy shortcut is safe only when exactly one policy is
open; several possible owners return {:ambiguous_instance_policy, run_ids}.
An Effect boundary is always resumed explicitly with its Invocation ref.
The Instance still applies one state-changing Move at a time. Before a retained Run advances, it is rebased onto the latest committed shared State, including lifecycle entries owned by other Runs. Capability Invocations use a separate state lock: calls arriving while one is in flight stay queued and continue after its terminal state has been committed.
Stateless Runtime calls and conversation-scoped Sessions do not opt into per-Run lifecycle ownership and retain the single pending Effect contract.
Operational loops on the same Instance
In 0.2.0 the same Instance also owns the canonical state of Work, Vigil, and authorized external controllers. Conversational Runs and operational loops remain separate domains, but all committed changes are serialized by this one Subject-scoped owner.
{:ok, work_ref, _view} =
Spectre.start_work(instance, MyApp.ExportReport, %{report_id: report_id})
{:ok, vigil_ref, _view} =
Spectre.register_vigil(instance, MyApp.AccountVigil, %{account_id: account_id})
{:ok, work} = Spectre.loop(instance, work_ref)
{:ok, vigil} = Spectre.loop(instance, vigil_ref)The Instance has a bounded operational Runner pool, configured with
:max_operation_runners. Each Runner receives a revision-fenced snapshot,
executes one registered operation attempt outside the Instance mailbox, sends
one correlated Result, and terminates. A waiting or paused loop retains no
Runner. Retry, recovery, control, and canonical commit decisions remain with
the Instance.
Use loops/2 for visible projections and resolve_loop/3 when a natural
command may identify a loop by kind, Definition, status, or origin. Multiple
matches return an explicit ambiguity error. Pause, update, resume, renew,
trigger, and stop are durable commands; stop is terminal while pause is
reversible.
The complete canonical graph can be persisted through
Spectre.Instance.CheckpointStore. Configure :checkpoint_store on Instance
startup and use flush_checkpoint/2, checkpoint_status/1, and
reconcile_checkpoint/2 at the host boundary. An ambiguous compare-and-swap
write erects a persistence fence and is never retried automatically.
Current format-tagged schema-3 checkpoints retain the Activation, every
retained Run, Definition lifecycle and event records, the complete private
Skill-state branch graph, inference control/progress, and the required-receipt
outbox. The reader accepts tagged versions 2 and 3; retired untagged 0.2.x
schemas are rejected instead of being guessed or silently migrated.
migrate_instance_key/5 migrates a validated checkpoint between the old and
stable Instance keys; it is not a legacy schema decoder. Divergent histories
under old and new keys are rejected. See
Migrating canonical Instance checkpoints to v3.
See Work, Vigil, and the operational runtime for controller, operation, recovery, event, and delivery contracts.
Inference Invocations and streaming
Model selection is now an explicit Spectre.Invocation kind. The Instance
commits selection and dispatch intent before it releases one-shot or streaming
provider work, then accepts one typed terminal worker receipt through the same
owner fences as Effects. This removes provider latency from the Instance
mailbox without creating a second owner for the Run.
Spectre.stream/3 starts an Instance-owned, pull-driven Enumerable. Raw deltas
flow directly from the supervised stream session to one authoritative
consumer; they do not pass through the Instance or enter its checkpoint. The
Instance receives text-free heartbeat snapshots for watchdogs and, when
enabled, commits throttled progress before publishing it to observers.
Cancellation is committed before remote cancel is requested. Steering is a
restart: the old Enumerable ends as superseded and the caller receives a new
handle with a new epoch. A consumer that never attaches becomes the explicit
terminal :consumer_never_attached; Instance death interrupts the session and
best-effort cancels the provider. Recovery either resumes/reconciles through a
declared adapter capability or reaches an explicit interrupted/ambiguous
terminal state. See Streaming inference.
Conversational Move scheduling
The GenServer mailbox never executes input plugs, routing, model calls, memory
callbacks, renderers, or Actions directly. An ordinary Move runs bounded pure
or host callbacks in its worker, so operational calls such as info/1 remain
responsive. When that Move reaches an inference boundary it returns a portable
continuation; the Instance commits it and dispatches the provider separately.
Ready Runs therefore do not hold the Move lock for model latency.
The explicit in-flight Invocation + Receipt path covers staged Effect/Action
execution, including Lens when mounted as an Action, and selected inference.
Post-processing resumes only after a correlated provider receipt and is
rebased against current shared State before its canonical commit.
This conversational one-Move limit is independent from the bounded operational Runner pool described above.
External identities and explicit linking
Channel adapters authenticate a provider principal first, then construct an
opaque Spectre.ExternalIdentity:
identity =
Spectre.ExternalIdentity.new(
provider: :beam,
channel: :telegram,
endpoint: :support_bot,
principal_id: verified_sender_id,
authenticated_at: System.system_time(:millisecond),
proof_ref: authentication_receipt_id
)The raw principal is hashed at construction. Authentication is a channel responsibility; the core does not infer it from populated source fields.
An already verified bootstrap can bind an identity with explicit proof:
{:ok, link} =
Spectre.Subject.Registry.bind(
MyApp.SupportAgent,
subject,
identity,
proof: authentication_receipt
)Afterward, ingress resolves the exact Agent-scoped identity before looking up the Instance:
{:ok, subject, _link} =
Spectre.Subject.Registry.resolve(MyApp.SupportAgent, identity)
{:ok, instance} =
Spectre.instance(MyApp.SpectreSupervisor, MyApp.SupportAgent, subject)Adding another channel uses a one-time destination challenge:
{:ok, intent, challenge} =
Spectre.Subject.Registry.open_link(
MyApp.SupportAgent,
subject,
existing_identity,
destination_identity,
ttl: :timer.minutes(5),
attempts: 3
)
# Deliver `challenge` only through the authenticated destination channel.
{:ok, destination_link} =
Spectre.Subject.Registry.confirm_link(
intent.id,
destination_identity,
received_challenge
)Only the challenge digest is retained. Challenges expire, have bounded
attempts, and are one-time. A policy can require
source_confirmation?: true, in which case confirm_source/3 must also be
called from the already linked identity. Binding an identity already owned by
another Subject returns a conflict; no name, address-book, number-similarity,
message-text, or model-based merge exists.
revoke/2 removes future resolution through a link without deleting the
Subject or Instance State. Successful commits and revocations pass through the
privacy-safe Journal boundary before becoming visible.
The bundled Subject Registry and active-Run registry are local in-memory coordination for this phase. Applications that require cross-node routing or durable identity recovery should persist the exposed value objects and re-establish them through an authenticated host workflow; they must not infer links after restart.