Spectre 0.2.0 adds durable operational loops alongside conversational Runs. They solve different problems:
- a
Spectre.Runadvances one conversational interaction; - a
Spectre.Workperforms one precise, terminating procedure; - a
Spectre.Vigilobserves repeatedly and waits between triggers; - an external controller, such as Directive, can use the same runtime without adding a second state owner.
The Subject-scoped Spectre.Instance is the only local owner of canonical
state. A Runner receives a revision-fenced snapshot, executes exactly one
registered operation attempt, returns data, and terminates. It never commits
canonical state itself.
Define and start a Work
Operations are closed-catalog declarations. They refer to loaded modules and functions; a model cannot supply a new executor at runtime.
defmodule MyApp.Pages do
def fetch(%{page: page}, _context) do
{:ok, %{page: page, body: MyApp.HTTP.fetch_page(page)}}
end
end
defmodule MyApp.Agent do
use Spectre.Agent
operation :fetch_page, {MyApp.Pages, :fetch},
input: :map,
output: :map,
side_effect: :idempotent,
retry: [max_attempts: 3, base_delay_ms: 100, max_delay_ms: 1_000]
end
defmodule MyApp.ReadPages do
use Spectre.Work,
id: :read_pages,
version: 1,
input: :map,
state: :map,
update: :map,
update_fields: [:pages],
budget: [steps: 100, attempts: 150]
uses_operation(:fetch_page)
@impl true
def init(input, _context) do
{:ok, %{queue: input.pages, pages: []}}
end
@impl true
def next(%{queue: [page | _]}, _context) do
run(:fetch_page, %{page: page}, phase: :reading)
end
def next(%{queue: []}, _context), do: complete(:pages_read)
@impl true
def apply_result(%{queue: [_ | rest]} = state, _request, result, _context) do
{:ok, %{state | queue: rest, pages: [result.value | state.pages]}}
end
@impl true
def complete(%{queue: [], pages: pages}, _context) do
complete(Enum.reverse(pages))
end
def complete(_state, _context), do: :continue
endStart it on a supervised Instance:
subject = Spectre.Subject.new({:account, account_id})
{:ok, instance} =
Spectre.instance(MyApp.SpectreSupervisor, MyApp.Agent, subject)
{:ok, ref, view} =
Spectre.start_work(instance, MyApp.ReadPages, %{pages: [1, 2, 3]},
origin: :web,
correlation_id: request_id
)uses_operation/1 imports only that stable operation from the Agent registry.
A Work can instead declare a private operation with operation/3. It cannot
see the rest of the Agent catalog implicitly, and a running Work cannot start
another Work.
Controller contract
Spectre.Work and Spectre.Vigil implement
Spectre.Operation.Controller. Controller callbacks are deterministic state
reducers. Slow I/O, Actions, effects, and inference belong in registered
operations.
The main decisions returned by next/2 are:
{:run, request} execute one registered operation
{:wait, wait} persist a wait boundary without a live Runner
{:blocked, blocker} persist a declared human blocker
{:complete, value} terminate successfully
{:error, reason} terminate with a typed failureapply_result/4 receives only a validated, fenced result. complete/2
decides completion from committed controller state. Definitions declare their
input, state, update schema, closed branches, blockers, waits, triggers,
security policy, publication policy, and budget.
An external controller with kind: :directive may additionally declare
can_start: [:work]. Its successful reducer can then propose Work starts as
part of the Result transition:
def __spectre_loop_definition__ do
Spectre.Operation.Definition.new(
id: :research_mission,
version: 1,
kind: :directive,
input: :map,
state: :map,
can_start: [:work]
)
end
def apply_result(state, _request, result, _context) do
intent =
{:work, MyApp.ReadPages, %{pages: result.value.pages},
[intent_id: :read_pages, id: state.work_id]}
{:ok, %{state | phase: :waiting_for_pages}, start_loops: [intent]}
endintent_id is required and stable. If :id or :correlation_id is omitted,
the runtime derives it deterministically from the parent loop and intent.
Controller-supplied child options are limited to :intent_id, :id,
:correlation_id, :expires_at, :budget, :cognitive, and :metadata.
Origin, provenance, Subject visibility, authorized origins, destinations,
Turn ownership, and causation are inherited or assigned by the Agent.
The parent Result, parent state, child Work, child control state, correlations, and their events are committed at one canonical revision. Child initialization or validation failure rejects the whole transition.
Intents are idempotent: re-proposing an intent whose Work already exists with
the same parent and intent provenance is a committed no-op, reported in the
:loops_started payload with already_started: true. Only an id collision
with different provenance rejects the transition. A Work or
Vigil Definition cannot declare can_start, and direct start_work calls
from a Runner or its isolated executor are rejected. The supported boundary
is a Directive reducer intent; the Runner never owns the capability.
Operation kinds
Spectre.Operation.Spec supports five provider-neutral kinds:
:functioninvokes a registered module/function callback;:actiondispatches through the Agent's registered Action provider;:effectdispatches through a registered extension effect executor;:plannermay select only an entry in its declared catalog;:cognitivevalidates output against a finite domain and can use retry and a deterministic fallback.
Inputs, outputs, domain values, execution envelopes, artifacts, and checkpoint data are validated for both semantics and portability. PIDs, ports, references, and anonymous functions are rejected from canonical values.
An operation that can cross an external side-effect boundary must classify that boundary:
operation :publish, {MyApp.Publisher, :publish},
side_effect: :reconcilable,
reconcile: {MyApp.Publisher, :reconcile},
retry: [max_attempts: 2]The supported classifications are :none, :idempotent, :reconcilable,
and :non_idempotent. A crash does not prove whether a side effect happened.
Spectre retries only when the declaration and retry policy permit it. An
unknown non-idempotent outcome waits for reconciliation instead of being
reported as success or repeated automatically.
Inspect and resolve loops
Views are read-only, privacy-safe projections:
{:ok, view} = Spectre.loop(instance, ref)
{:ok, visible} = Spectre.loops(instance, origin: :web)
{:ok, selected} =
Spectre.resolve_loop(instance,
kind: :work,
definition: :read_pages,
status: [:active, :waiting, :paused]
)When a selector matches more than one loop, resolve_loop/3 returns
{:error, {:ambiguous_operation_loops, candidates}}. It never guesses from
model output. Visibility is checked from Subject, origin, authorized origins,
and the loop's visibility policy.
The view exposes status, phase, progress, attempts, retries, partial results,
published artifacts, budget, last update, pending control, next trigger,
wait_ref, and reconciliation state. artifact_policy can independently set
publish_results, publish_artifacts, publish_progress, and
publish_blocker; all default to true. Redaction changes only the public
projection, never canonical state. wait_ref remains visible because it is a
fencing token rather than blocker or progress content.
Correlate external triggers to one wait
A waiting view exposes the complete reply handle:
wait_ref = view.wait_ref
{:ok, next_view} =
Spectre.trigger_loop(instance, ref, {:human, :approved},
wait_id: wait_ref.id,
generation: wait_ref.generation
)The Definition can require both fields for human, external, event, and reconciliation waits:
security: %{trigger_correlation: :required}security: %{require_trigger_correlation: true} is the compatible boolean
spelling. Missing fields return
{:operation_trigger_correlation_required, missing_fields}; mismatched fields
return the existing stale-wait or stale-generation error before the controller
runs. Correlation fences replay and does not replace Subject/origin
authorization.
During the 0.2.x migration window the default is :legacy. An accepted
partially or wholly uncorrelated trigger records correlation: :legacy in its
committed :triggered event and emits
[:spectre, :instance, :uncorrelated_operation_trigger] telemetry. Supplying
both fields records correlation: :exact. The planned 0.3.0 default is
:required; applications that intentionally retain legacy behavior will
need an explicit opt-out.
Pause, update, resume, renew, and stop
Control commands are correlated, revision-fenced, idempotent transitions:
{:ok, pause_requested} = Spectre.pause_loop(instance, ref)
{:ok, updated} =
Spectre.update_and_resume_loop(instance, ref, %{pages: [4, 5]},
command_id: update_id,
correlation_id: turn_id,
provenance: %{source: :chat}
)
{:ok, resumed} = Spectre.resume_loop(instance, ref)
{:ok, renewed} = Spectre.renew_loop(instance, ref, expires_at)
{:ok, stopped} = Spectre.stop_loop(instance, ref, :cancelled_by_user)A safe pause prevents the scheduler from starting another attempt and reaches the next safe boundary. Immediate interruption additionally requires both caller authorization and a Definition that permits it. Interruption fences the old attempt; it does not assume that an external side effect was undone.
Only fields declared by update_fields can change. apply_update/4 validates
and reduces the update, advances the context revision, records provenance, and
invalidates stale attempts or timers. update_and_resume_loop/4 persists the
pause-update-resume sequence so restart cannot apply the update twice.
A terminal stop cannot be resumed. Pausing or stopping an external Directive controller does not implicitly pause or stop independent Work loops.
Define a Vigil
A Vigil uses the same operation runtime but remains registered while it waits. No Runner needs to stay alive between observations.
defmodule MyApp.WeatherVigil do
use Spectre.Vigil,
id: :weather_vigil,
version: 1,
input: :map,
state: :map,
waits: [:timer],
triggers: [:timer],
budget: [attempts: 1_000]
uses_operation(:read_weather)
@impl true
def init(input, _context), do: {:ok, %{input: input, observe?: true}}
@impl true
def next(%{observe?: true, input: input}, _context), do: observe(:read_weather, input)
def next(%{observe?: false}, context), do: wait_for(:timer.minutes(5), context)
@impl true
def apply_result(state, _request, result, _context) do
significance = if important_change?(result.value), do: :significant, else: :silent
{:ok, %{state | observe?: false}, significance: significance}
end
@impl true
def handle_trigger(state, {:timer, _wait_id}, _context) do
{:ok, %{state | observe?: true}}
end
defp important_change?(weather), do: weather.alert == true
end
{:ok, vigil_ref, _view} =
Spectre.register_vigil(instance, MyApp.WeatherVigil, %{city: "Rome"})Timer messages carry a trigger generation. Updating, pausing, or resuming the Vigil invalidates messages from older generations. Significant and silent observations are separate committed event types; significance does not by itself authorize human delivery.
Canonical checkpoints and recovery
The Instance checkpoint contains versioned Flow, Work, Vigil, external controller, control, correlation, and event sections. Every commit advances a global revision and only the revisions of sections it writes.
Configure a durable adapter implementing
Spectre.Instance.CheckpointStore:
defmodule MyApp.Checkpoints do
@behaviour Spectre.Instance.CheckpointStore
@impl true
def load(instance_ref, opts), do: MyApp.Storage.load(instance_ref.key, opts)
@impl true
def compare_and_swap(instance_ref, json, expected, revision, opts) do
MyApp.Storage.compare_and_swap(instance_ref.key, json, expected, revision, opts)
end
end
{:ok, instance} =
Spectre.instance(MyApp.SpectreSupervisor, MyApp.Agent, subject,
checkpoint_store: {MyApp.Checkpoints, tenant: tenant_id},
checkpoint_mode: :async
)
{:ok, persisted_revision} = Spectre.flush_checkpoint(instance)
status = Spectre.checkpoint_status(instance)The store must return {:error, {:ambiguous, reason}} when it cannot know
whether a CAS write committed. Spectre erects a persistence fence and does not
retry that write automatically. The host must call
Spectre.reconcile_checkpoint/2, which loads and validates the stored
checkpoint before deciding whether the write committed, did not commit, or
conflicts.
Restore validates the entire semantic graph: loop kind and Definition version, loop/control correspondence, event identity and revision, Subject ownership, consent and receipt targets, correlation references, and portable state. Active attempts are recovered with a new Agent epoch, snapshot, attempt id, and fencing token.
Bounded replay and audit windows
The retained windows are part of the 0.2.x contract:
- the canonical transition journal retains the newest 512 transitions;
- canonical change-id replay detection retains the newest 1,024 applied changes;
- each loop control plane retains the newest 128 completed commands;
- the committed operational-event projection retains the newest 512 events.
These are count-based, currently fixed limits. A duplicate canonical change or completed control command is guaranteed to be recognized only while its receipt remains in the corresponding window. Eviction permits the identifier to be treated as new; it does not make an external side effect safe to repeat. External effects still need their own durable idempotency or reconciliation contract. The transition journal and event projection are audit/observation retention, not additional deduplication stores.
Events, Flow routing, and delivery
Operational events are committed records, not notifications. Read or subscribe to them independently:
events = Spectre.operation_events(instance, types: [:completed])
{:ok, _subscription} = Spectre.subscribe_operation_events(instance)
receive do
{:spectre, :operation_event, event} -> inspect(event)
endAn Agent can route selected committed events back through its normal Flow router:
defmodule MyApp.Agent do
use Spectre.Agent
route_operation_events([:completed, :observation_significant])
endThis creates an ordinary internal Run; there is no second event matcher or state owner.
Proactive delivery is a separate authorization step. Store consent, authorize a destination, let Beam or the host perform transport, then record the outcome:
{:ok, consent} = Spectre.put_delivery_consent(instance, consent_attrs)
{:ok, receipt} =
Spectre.authorize_delivery(instance, event.id, destination,
event_types: [:completed],
channels: [:email],
max_deliveries: 3,
window_ms: :timer.hours(1)
)
case MyApp.Transport.deliver(destination, message) do
{:ok, external_receipt} ->
Spectre.record_delivery(instance, receipt.id, :delivered, external_receipt)
{:error, reason} ->
Spectre.record_delivery(instance, receipt.id, :failed, reason)
endConsent, revocation, destination authorization, event type, channel, dedupe, rate limit, quiet hours, and digest policy are checked before transport. Receipt transitions are validated and retained in canonical state.
Runtime invariants
- The Instance is the sole canonical owner.
- A Runner executes one operation attempt and is
:temporaryunder dynamic supervision. - Retry, recovery, merge, control, and completion decisions belong to the Instance and deterministic controller code.
- Results, progress, timers, and control commands are revision- and generation-fenced.
- Waiting loops do not require a live Runner.
- Checkpoints contain data, stable identifiers, and receipts—never process handles or anonymous functions.
- Atom serialization may use
Atom.to_string/1; untrusted strings are never converted into new atoms.
See Agent Instances and Subjects, Architecture, and Migrating to 0.2.0 for the surrounding contracts.