Agent Instances and Subjects

Copy Markdown View Source

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.Instance

An 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.

Private Skill state

Canonical schema 4 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})
end

The public Turn contains only the boundary projection. The Instance keeps the Run and verifies the supplied revision-fenced ref before resuming it. Use Spectre.Instance.info/1 and run/2 for privacy-safe operational projections; they do not return provider payloads or the internal continuation.

Effect work runs outside the Instance mailbox. Its internal receipt is fenced by Instance generation, Run id and revision, Invocation id, and dispatch id. Late, duplicate, foreign, and stale receipts are ignored. The Instance remains responsive while the 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 schema-4 checkpoints retain the Activation, every retained Run, Definition lifecycle and event records, and the complete private Skill-state branch graph. Readers still accept schemas 1 through 3 and supply missing sections during restore. When a legacy Instance key is found, the adapter's migrate_instance_key/5 callback must atomically expose the migrated bytes under the stable Ref. Divergent histories under old and new keys are rejected.

See Work, Vigil, and the operational runtime for controller, operation, recovery, event, and delivery contracts.

Conversational Move scheduling

The GenServer mailbox never executes input plugs, routing, model calls, memory callbacks, renderers, or Actions directly. An ordinary Move runs those bounded callbacks in its worker, so operational calls such as info/1 remain responsive. However, Prism inference and other ordinary provider calls still execute synchronously within that one active Move worker. Ready Runs wait for that Move to finish or hit its configured provider timeout.

The explicit in-flight Invocation + Receipt path in this release covers staged Effect/Action execution, including Lens when it is mounted as an Action. A generic provider Invocation requires a serializable mid-turn continuation and typed provider receipt; it is intentionally not simulated by renaming the whole Move or running stale-State workers concurrently.

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.