Skip to content
GitHub
Contract Surfaces

Event System

Spectral uses domain events to decouple worlds and platform and coordinate cross-cutting workflows. Events flow from publishers through the LISTEN/NOTIFY + transactional outbox substrate (per ADR-044) to registered handlers, enabling spectral.platform to trigger actions in spectral.worlds (and vice versa) without direct imports. The substrate envelope shape lives in spectral.core.events; producer-owned typed event payloads live in <producer>.contracts.events.* per ADR-065 D2.


Event payloads are frozen Pydantic models living in <producer>.contracts.events.* per ADR-065 D2 (one module per event; the producing context is the single source of truth for the wire shape). The substrate envelope (spectral.core.events.envelope.EventEnvelope) carries event_id, event_type, event_version, occurred_at, source, target, workspace_id, payload: dict[str, Any], idempotency_key, and trace_context per ADR-044 D6. The catalog of existing events lives at Events.

Consumers parse the envelope payload through their own local <EventName>Event model per ADR-065 D4 (consumer ACL pattern); they never import the producer’s typed payload class outside tests/contracts/. See Contract Surfaces for the categorical split between substrate, producer-owned contracts, and framework-layer bridge tools.

The tables below split events by current state. Live events have producer-owned typed payload modules shipping in <context>.contracts.events.* (catalogued at Events) with bilateral contract tests in tests/contracts/. Planned events are part of the alpha-substrate plan; their payload modules + bilateral contract tests land with the epics that produce them. The Planned table captures the wire shape and consumer expectations so downstream design proceeds against a stable contract.

All inter-context payload locations below follow ADR-065 D2 (producer-owned typed payloads in <producer>.contracts.events.*); per-row citations of D2 are omitted for scannability.

EventTriggerKey Fields
FailureClusterDetectedEvent (platform.failure_cluster.detected)Platform’s FailureClusterService emits when a failure cluster crosses the detection threshold per ADR-057; typed payload at spectral.platform.contracts.events.failure_cluster_detected; two consumer paths — the Operations Agent (intra-platform) upserts platform.rule_candidates_pending with operator-column preservation on every detection; the World Agent (in spectral.worlds) applies a consumer-side promotion-threshold filter (frequency, effect size, actionable) over its event stream and seeds rule-candidate exploration only on the higher barworkspace_id, cluster_id, failure_id, rule_id, severity, failure_count, evidence_bundle, summary, snapshot_hash, suggested_rule_stub, first_observed_at, last_observed_at, observed_at
RuleCandidatePublishedEvent (platform.rule_candidate.published)Operator approves a rule candidate; producer-typed payload at spectral.platform.contracts.events.rule_candidate_published; consumed by the World Agent in spectral.worlds for evolution-loop signalrule_candidate_id, workspace_id, rule_text, authority_version_id, authority_lineage, curation_metadata, published_at, published_by_user_id
T3MemoryWrittenEvent (t3_memory.written)Tier 3 memory write commits inside spectral.platform. Producer-typed payload at spectral.platform.contracts.events.t3_memory_written. Consumed by the World Agent per ADR-064 D3 broadenedmemory_id, embedding_id, workspace_id, recorded_at
EventTriggerKey Fields
ScanCompletedEvent (scan.completed)Scan pipeline finishes (post-verdict phase). Typed payload planned at spectral.platform.contracts.events.scan_completedworkspace_id, scan_id, verdict, composite_score (world_model_score, rubric_score, blended_delta, convergence_delta), mutation_types
ScanConvergenceDeltaEvent (scan.convergence.delta)Verdict phase emits per scan with explicit absence-marker semantics; consumed by World Agent as a world-model-adoption signal. Typed payload planned at spectral.platform.contracts.events.scan_convergence_deltaworkspace_id, scan_id, convergence_delta (with absence marker), observed_at
VerdictIssuedEvent (verdict.issued)Verdict engine finalizes a verdict for a scan. Intra-platform — producer is the verdict engine; consumed by OnVerdictIssuedHandler (Spectral Agent proactive conversation) and the on_scan_completed changeset-lifecycle handler via the platform-internal event substrate per ADR-044. Not subject to ADR-065’s contract surface admission rules; producer-typed payload location is platform-implementation detailworkspace_id, scan_id, verdict, composite_score, evaluation_authority_ref, issued_at
AgentTaskCreatedEventNew agent task created via API. Carries Conversation + AgentTask domain-model identityworkspace_id, task_id, conversation_id, user_id
MemoryObservationPromotedEvent (memory.observation.promoted)Observation at the Tier 3 memory boundary promotes a domain signal toward the World Model. Producer-typed payload planned at spectral.platform.contracts.events.memory_observation_promoted. Consumed by the World Agent per ADR-064 D3 broadened (consumer-side ACL parse + worlds-local replica)workspace_id, world_model_id, world_model_version, observation_type, domain_observation, attribution_envelope
RubricDivergenceEvent (rubric.divergence)Evaluate phase emits per scan, regardless of conformance-sample availability. Typed payload planned at spectral.platform.contracts.events.rubric_divergenceworkspace_id, scan_id, evaluation_framework_id, divergence_delta, observed_at
ApprovalRequiredEvent (approval.required)on_scan_completed handler when verdict triggers it (always when autonomy_mode = manual; kill-switch and bounded_auto fall-through cases land in the second alpha autonomy wave)workspace_id, changeset_id, reason
SupervisorRecommendationIssuedEvent (supervisor.recommendation.issued)Supervisor surfaces a directional recommendation. Typed payload planned at spectral.platform.contracts.events.supervisor_recommendation_issuedworkspace_id, recommendation_id, mode_classification (ACTIVE / PLATEAU / FRONTIER / NO_DATA), priority, budget_hint, narrative, issued_at
WorldModelObservationEnshrinedEvent (worldmodel.observation.enshrined)World Agent emits after operator-approved enshrinement of a RuleCandidateworld_id, rule_id, candidate_id, originating_t3_observation_ref, enshrined_at
WorldModelVersionPublishedEvent (worldmodel.version.published)World Agent emits after operator-triggered world-model-version publication mints a new WorldModel version + EvaluationAuthorityRef; signal-shaped per ADR-030 — platform does not subscribe for state changesworld_id, world_version, evaluation_authority_ref, published_at, operator_id
WorldModelCardPublishedEvent (worlds.world_model_card.published)World Agent emits in the same publication transaction as the WorldModelCard mint; typed payload planned at worlds.contracts.events.world_model_card_published; consumed by spectral.platform projection handler that materializes a SystemCardSnapshot into the platform-local projection table keyed by evaluation_authority_refworld_id, world_version, evaluation_authority_ref, rule_set_snapshot, evolution_history, rule_health_distribution, published_at, released_by
RuleCandidateOutcomeRecordedEvent (rule_candidate_outcome_recorded)World Agent emits after each worlds.rule_candidate_outcomes row insert per ADR-055 D3 Option A; consumed by Operations to materialize the operator-side local replica. Typed payload planned at spectral.worlds.contracts.events.rule_candidate_outcome_recordedevent_id, rule_candidate_id, outcome (accepted / rejected), reason_code, reason, recorded_at

The most connected event. Published by the verdict phase when a scan finishes, it carries enough context for downstream handlers to act without importing scanning internals:

  • Verdict (go, no_go, caution, observe_only) and score metrics
  • Mutation types for autonomy gate checks
  • Framework and sample set IDs for changeset context
  • Scan context snapshot (dict) for pipeline bridge consumption

VerdictIssuedEvent, ApprovalRequiredEvent, and SupervisorRecommendationIssuedEvent drive the Spectral Agent’s proactive customer-facing behavior. The agent is a consumer of these events only; it does not emit them. When a verdict is issued, a changeset awaits approval, or the supervisor surfaces a recommendation, the agent creates or updates a system-initiated Conversation and queues an AgentTask for the LangGraph graph to process. See agent architecture / Event-driven proactive conversations.

World Model emissions (worlds → consumers)

Section titled “World Model emissions (worlds → consumers)”

Four events flow from spectral.worlds to consumers in spectral.platform, the operational control plane, and the Operations Agent. Dashboard health reads happen via the worlds.application.world_model_health.ReadWorldModelHealth Tier 1 use case handler (ADR-070) consumed by apps/api/operator/* directly per validator rule 7 — there is no worldmodel.health.updated event. Subscribers to scan-level signals (scan.convergence.delta, rubric.divergence) remain available for non-dashboard consumers that need finer granularity.

  • worldmodel.observation.enshrined — emitted after an operator approves a RuleCandidate through the enshrinement-gate call (see Evolution Loop / Transport shape). The originating Spectral Agent T3 record is flagged via the consumer-side handler. This is call-flow-driven notification — the state change (rule transitions to enshrined) happens synchronously inside the call, and the event is the downstream broadcast for cards, dashboards, and audit consumers.
  • worldmodel.version.published — emitted after operator-triggered world-model-version publication mints a new WorldModel version + EvaluationAuthorityRef. Signal-shaped per ADR-030spectral.platform does not subscribe to this event for state changes; the system-card projection pipeline consumes the sibling worlds.world_model_card.published event (below) instead. Publication is operator-triggered, not enshrinement-triggered — see Evolution Loop / Versioning and Release.
  • worlds.world_model_card.published — emitted in the same operator-triggered publication transaction as the WorldModelCard mint; typed payload at spectral.worlds.contracts.events.world_model_card_published per ADR-065 D2 (producer-owned typed payload; the catalog at Events). Consumed by spectral.platform’s projection handler (consumer-side ACL parses the wire payload with a local WorldModelCardPublishedEvent model; projection materializes a SystemCardSnapshot row in the system_card_snapshots table keyed by evaluation_authority_ref). The build_agent_performance_card use case reads from this projection at create_changeset and embeds the snapshot into the agent_performance_card row. No Reader Protocol — see system-card / Card Linkage at Render Time. Idempotent on evaluation_authority_ref under at-least-once delivery.
  • rule_candidate_outcome_recorded — emitted after each worlds.rule_candidate_outcomes row insert per ADR-055 D3 Option A. Consumed by the Operations Agent, which materializes its own local replica in platform.rule_candidate_outcomes_replica rather than reading the Worlds-side outcome rows over a SQL grant. Eventually consistent; idempotent under at-least-once delivery; no worlds_outcomes_reader SQL grant ships per ADR-063.

Two events flow into the Operations Agent’s consumer handlers and drive the operator-facing work surface in apps/operations. Both ride the same outbox substrate as every event in the system; both consumers are idempotent under at-least-once delivery.

  • rule_candidate_outcome_recorded (worlds → operations) — see World Model emissions above.
  • platform.failure_cluster.detected (intra-platform; platform → operations) — emitted by the platform-side FailureClusterService when a failure cluster crosses the detection threshold per ADR-057. Consumed by Operations to upsert platform.rule_candidates_pending with operator-column preservation — the operator-managed columns (status, assigned_to, notes) survive snapshot updates from re-emitted signals per ADR-057 D3. This is the input to the cluster-triage tool family on the Operations Agent’s tool surface (see agent tool invocation / Operator-surface tool patterns).

Published when a Tier 3 workspace memory observation crosses the domain observation boundary and is routed to the World Model system. The two wire events — memory.observation.promoted and t3_memory.written — are the primary integration surface between spectral.platform and spectral.worlds.

Delivery semantics: At-least-once with idempotent handlers. The observation signal path is statistically robust — a missed observation is unfortunate but not catastrophic; a duplicate is handled by the conformity gate at the enshrinement boundary.

Ordering: Best-effort. The conformity gate and human sign-off at enshrinement are the integrity controls, not event order.

Schema ownership: The typed payload lives in spectral.platform.contracts.events.* per ADR-065 D2 (spectral.platform is the producer; spectral.worlds is the consumer).

Dead-letter handling: Failed events route to the substrate-default status='failed' filter on core.outbox (per ADR-044 — no separate DLQ table). Producer-side recovery; spectral.worlds never reaches back into spectral.platform to recover a failed event.

Back-pressure: spectral.worlds signals capacity constraints to the event infrastructure, not to spectral.platform directly. The boundary between contexts stays clean.


Producer context core.outbox (txn) LISTEN/NOTIFY relay Consumer context
│ │ │ │
│ publish(envelope) │ │ │
│ inside business txn │ │ │
│─────────────────────────>│ │ │
│ (txn commits → trigger) │ pg_notify(channel) │ │
│ │─────────────────────────>│ │
│ │ │ listener wakes, │
│ │ │ SELECT FOR UPDATE │
│ │ │ SKIP LOCKED │
│ │ │─────────────────────────>│ handler.handle(envelope)
│ │ │ │ idempotency check
│ │ │ status='delivered' │ (core.event_handled)
│ │ │<─────────────────────────│

The substrate is LISTEN/NOTIFY + transactional outbox per ADR-044. Per-context listener processes hold direct-Postgres connections; the producer’s core.outbox write commits inside its business transaction (transactional-outbox guarantee); the relay delivers asynchronously to consumers; consumers dedup via core.event_handled (per ADR-044 D10) and classify failures via RetryPolicy (per ADR-044 D7 + the RetryPolicy.classify / RetryPolicy.delay_for methods on the kernel value type per ADR-068). Substrate-swap forward triggers (NATS JetStream / Redis Streams / Kafka) per ADR-044 D15 are documented for future scale.

The publisher and listener Protocols live in spectral.core.events.protocols (substrate contract; per ADR-044 D7). Per-context implementations live in each context’s infrastructure layer:

spectral.core.events.protocols
@runtime_checkable
class EventPublisher(Protocol):
async def publish(self, envelope: EventEnvelope) -> None: ...
@runtime_checkable
class EventListener(Protocol):
async def listen(
self,
*,
channel: str,
handler_name: str,
handler: EventHandler,
) -> None: ...
EventHandler = Callable[[EventEnvelope], Awaitable[None]]

Producer side: the producer calls publisher.publish(envelope) inside the same transaction as the business state change that the event signals. Publishers MUST write the outbox row inside the business txn — the transactional-outbox invariant is what makes the substrate at-least-once delivery work.

Consumer side: the listener wakes on pg_notify, claims rows via SELECT FOR UPDATE SKIP LOCKED per ADR-044 D2, and dispatches each envelope to the registered handler. Handlers are idempotent; the listener checks core.event_handled against (handler_name, idempotency_key) before executing the handler body. Error isolation: each handler invocation is individually classified via RetryPolicy.classify(exc); transient failures retry per RetryPolicy.delay_for; terminal failures transition the row to status='failed' (the in-place DLQ per ADR-017 producer-owned recovery).


Two handlers subscribe to ScanCompletedEvent, both in spectral.platform:

HandlerContextResponsibility
OnScanCompletedHandlerspectral.platform.application.changesetAlpha first wave: creates a changeset from scan results (skipped when autonomy_mode = observe_only), assembles Explainability via assemble_explainability, attaches an AgentPerformanceCard and stamps evaluation_authority_ref, persists interaction-tier (T1) observations via the spectral_agent_memory gateway (always, regardless of mode), and emits approval.required when mode = manual. The alpha second wave extends this handler with bounded_auto auto-acceptance, kill-switch enforcement, and gate evaluation.
OnScanCompletedFeedbackHandlerspectral.platform.application.feedbackPersists customer feedback + production outcome signals as workspace-scoped FeedbackSignal records (records-vs-memory dual-face per the records-vs-memory framing — ADR-058 D14 captures the workshop principle), then routes signals to T1 / T2 Spectral Agent memory via the spectral_agent_memory gateway and to the worlds evolution loop via T3 routing per ADR-056.

Three handlers in spectral.platform.application.spectral_agent subscribe to the events that drive the Spectral Agent’s proactive customer-facing behavior:

HandlerSubscribes toResponsibility
OnVerdictIssuedHandlerverdict.issuedCreates a proactive conversation rendering the verdict and queues an AgentTask for the scan-analyst specialist to surface next-step recommendations.
OnApprovalRequiredHandlerapproval.requiredCreates a proactive conversation prompting the customer to review the proposed changeset, with explainability + agent performance card attached.
OnSupervisorRecommendationHandlersupervisor.recommendation.issuedCreates or updates a proactive conversation carrying the supervisor’s recommendation narrative.

1. Define the producer-owned typed payload

Section titled “1. Define the producer-owned typed payload”

Add a new module under the producing context’s contracts/events/ per ADR-065 D2:

# src/spectral/<producer>/contracts/events/my_new_event.py
"""Brief description. Published by <producer>; consumed by <consumer>."""
from typing import Final
from uuid import UUID
from pydantic import BaseModel, ConfigDict
PRODUCER_MY_NEW_EVENT_TYPE: Final[str] = "<producer>.my_new_event"
PRODUCER_MY_NEW_EVENT_VERSION: Final[int] = 1
class MyNewEventPayload(BaseModel):
model_config = ConfigDict(frozen=True)
workspace_id: UUID
detail: str

When a new event lands, the Events catalog is updated by hand at the same time so the catalog stays in step with the contract.

In the consuming context’s flow, declare a local <EventName>Event model that has only the fields the consumer actually needs (per ADR-065 D4 — consumer ACL pattern):

# src/spectral/<consumer>/<flow>/my_new_event.py
"""Local interpretation of <producer>.my_new_event. Drops fields the consumer doesn't need."""
from uuid import UUID
from pydantic import BaseModel, ConfigDict
class MyNewEvent(BaseModel):
model_config = ConfigDict(frozen=True)
workspace_id: UUID
# detail intentionally dropped if consumer doesn't need it

The consumer never imports the producer’s typed payload class outside tests/contracts/.

Create a handler implementing the EventHandler shape from spectral.core.events.protocols. Place it in the consuming context’s infrastructure layer:

# src/spectral/<consumer>/infrastructure/events/handlers.py
from spectral.core.events.envelope import EventEnvelope
from spectral.<consumer>.<flow>.my_new_event import MyNewEvent
async def on_my_new_event(envelope: EventEnvelope) -> None:
event = MyNewEvent.model_validate(envelope.payload)
# React to the event — project into local state, etc.
...

Register the handler in the consuming context’s listener startup (the workers entrypoint per ADR-031):

apps/workers/src/spectral_workers/composition.py
from spectral.<consumer>.infrastructure.events.handlers import on_my_new_event
await listener.listen(
channel="outbox_default", # or per-generation channel per ADR-044 D8
handler_name="<consumer>.my_new_event_handler", # scope-qualified per ADR-044 D10
handler=on_my_new_event,
)

Add a bilateral round-trip test under tests/contracts/<event>_test.py (per ADR-065 D6 + ADR-066). See testing for the pattern.

In the producing context’s application code, build the typed payload, wrap it in an EventEnvelope, and call publisher.publish(envelope) inside the same transaction as the business state change that the event signals (transactional-outbox invariant per ADR-044 D1):

# src/spectral/<producer>/application/<flow>/handlers.py
from spectral.core.events.envelope import EventEnvelope
from spectral.core.events.protocols import EventPublisher
from spectral.<producer>.contracts.events.my_new_event import (
PRODUCER_MY_NEW_EVENT_TYPE,
PRODUCER_MY_NEW_EVENT_VERSION,
MyNewEventPayload,
)
async def emit_my_new_event(publisher: EventPublisher, *, workspace_id: UUID, detail: str) -> None:
payload = MyNewEventPayload(workspace_id=workspace_id, detail=detail)
envelope = EventEnvelope(
event_type=PRODUCER_MY_NEW_EVENT_TYPE,
event_version=PRODUCER_MY_NEW_EVENT_VERSION,
payload=payload.model_dump(mode="json"),
# ...substrate fields per ADR-044 D6: event_id, occurred_at, source, target,
# workspace_id, idempotency_key, trace_context
)
await publisher.publish(envelope)

The event substrate is the notification-flow mechanism per the Contract Surfaces page. Library code in worlds cannot import library code in platform, and vice versa (validator rule 2); notification flow between them uses the producer-owned typed-payload model (per ADR-065 D2) emitted onto the LISTEN/NOTIFY + outbox substrate (per ADR-044), and consumers parse via their own local <EventName>Event model (per ADR-065 D4).

Pattern: platform publishes, worlds reacts

Section titled “Pattern: platform publishes, worlds reacts”

platform.rule_candidate.published (in spectral.platform.contracts.events.rule_candidate_published) flows from platform’s curation pipeline to worlds for rule-corpus integration. Worlds emits a converse worlds.rule_candidate_outcome_recorded event; platform materializes a platform-local replica per ADR-064 (Option A definitive).

Pattern: worlds publishes, platform reacts

Section titled “Pattern: worlds publishes, platform reacts”

worlds.world_model_card.published (in spectral.worlds.contracts.events.world_model_card_published) flows from worlds’s operator-triggered publication transaction to platform’s system-card projection handler; platform parses with its own local WorldModelCardPublishedEvent ACL model and materializes a SystemCardSnapshot row in system_card_snapshots keyed on evaluation_authority_ref. The build_agent_performance_card use case reads from this projection at create_changeset time.

Not every event crosses contexts. Producer + consumer can both live in the same context; the typed payload still lives at <context>.contracts.events.* per ADR-065 D2 and the consumer parses with its own local ACL model. Example: platform.failure_cluster.detected (in spectral.platform.contracts.events.failure_cluster_detected) flows from FailureClusterService to the Operations Agent’s cluster-triage handler — both in spectral.platform. The substrate mechanism is identical; the <context>.contracts.events.* placement reflects ownership, not consumption.

Pattern: Eventual-consistency reads via local replica

Section titled “Pattern: Eventual-consistency reads via local replica”

When a consumer needs to read facts already recorded elsewhere — e.g., the platform-side view of accepted/rejected rule candidates owned by worlds — notification flow with a richer payload + a consumer-local replica is the canonical pattern. See ADR-055 D3 + ADR-064.

When a caller dispatches a request and needs the result back synchronously (e.g., Ops Agent → World Agent), the path is a callee-owned OHS Protocol in <callee>.contracts.protocols.* per ADR-065 D3, with the bridge tool composed in apps/* per ADR-065 D5. See agent-tool-invocation for the worked WorldAgentRunner example.

Events enforce the architecture boundary structurally: the producing context’s library code never imports the consuming context’s library code. Events also provide natural fan-out (one event, multiple independent reactions), error isolation (one handler failing does not block others), and auditability (the substrate’s outbox + observability surface carries identity and timestamps).


Producer payloads are frozen Pydantic models with built-in model_dump(mode="json") / model_validate() serialisation. The transport is the LISTEN/NOTIFY + transactional outbox substrate per ADR-044 D1 (core.outbox table; core.outbox_notify() trigger; per-context listener processes hold direct-Postgres connections). Substrate-swap forward triggers (NATS JetStream / Redis Streams / Kafka) per ADR-044 D15 are documented for future scale; the wire contract (envelope shape + producer-owned typed payloads) is substrate-agnostic by design.