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 Types
Section titled “Event Types”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.
Live at alpha
Section titled “Live at alpha”| Event | Trigger | Key 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 bar | workspace_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 signal | rule_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 broadened | memory_id, embedding_id, workspace_id, recorded_at |
Planned
Section titled “Planned”| Event | Trigger | Key Fields |
|---|---|---|
ScanCompletedEvent (scan.completed) | Scan pipeline finishes (post-verdict phase). Typed payload planned at spectral.platform.contracts.events.scan_completed | workspace_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_delta | workspace_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 detail | workspace_id, scan_id, verdict, composite_score, evaluation_authority_ref, issued_at |
AgentTaskCreatedEvent | New agent task created via API. Carries Conversation + AgentTask domain-model identity | workspace_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_divergence | workspace_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_issued | workspace_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 RuleCandidate | world_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 changes | world_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_ref | world_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_recorded | event_id, rule_candidate_id, outcome (accepted / rejected), reason_code, reason, recorded_at |
ScanCompletedEvent
Section titled “ScanCompletedEvent”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
Spectral Agent reactive events
Section titled “Spectral Agent reactive events”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 aRuleCandidatethrough 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 toenshrined) 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 newWorldModelversion +EvaluationAuthorityRef. Signal-shaped per ADR-030 —spectral.platformdoes not subscribe to this event for state changes; the system-card projection pipeline consumes the siblingworlds.world_model_card.publishedevent (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 theWorldModelCardmint; typed payload atspectral.worlds.contracts.events.world_model_card_publishedper ADR-065 D2 (producer-owned typed payload; the catalog at Events). Consumed byspectral.platform’s projection handler (consumer-side ACL parses the wire payload with a localWorldModelCardPublishedEventmodel; projection materializes aSystemCardSnapshotrow in thesystem_card_snapshotstable keyed byevaluation_authority_ref). Thebuild_agent_performance_carduse case reads from this projection atcreate_changesetand embeds the snapshot into theagent_performance_cardrow. No Reader Protocol — see system-card / Card Linkage at Render Time. Idempotent onevaluation_authority_refunder at-least-once delivery.rule_candidate_outcome_recorded— emitted after eachworlds.rule_candidate_outcomesrow insert per ADR-055 D3 Option A. Consumed by the Operations Agent, which materializes its own local replica inplatform.rule_candidate_outcomes_replicarather than reading the Worlds-side outcome rows over a SQL grant. Eventually consistent; idempotent under at-least-once delivery; noworlds_outcomes_readerSQL grant ships per ADR-063.
Events flowing into Operations
Section titled “Events flowing into Operations”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-sideFailureClusterServicewhen a failure cluster crosses the detection threshold per ADR-057. Consumed by Operations to upsertplatform.rule_candidates_pendingwith 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).
Memory-to-Worlds signal events
Section titled “Memory-to-Worlds signal events”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.
Event Flow
Section titled “Event Flow”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.
Publisher / Listener Protocols
Section titled “Publisher / Listener Protocols”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:
@runtime_checkableclass EventPublisher(Protocol): async def publish(self, envelope: EventEnvelope) -> None: ...
@runtime_checkableclass 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).
Registered Handlers
Section titled “Registered Handlers”ScanCompletedEvent handlers
Section titled “ScanCompletedEvent handlers”Two handlers subscribe to ScanCompletedEvent, both in spectral.platform:
| Handler | Context | Responsibility |
|---|---|---|
OnScanCompletedHandler | spectral.platform.application.changeset | Alpha 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. |
OnScanCompletedFeedbackHandler | spectral.platform.application.feedback | Persists 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. |
Spectral Agent event handlers
Section titled “Spectral Agent event handlers”Three handlers in spectral.platform.application.spectral_agent subscribe to the events that
drive the Spectral Agent’s proactive customer-facing behavior:
| Handler | Subscribes to | Responsibility |
|---|---|---|
OnVerdictIssuedHandler | verdict.issued | Creates a proactive conversation rendering the verdict and queues an AgentTask for the scan-analyst specialist to surface next-step recommendations. |
OnApprovalRequiredHandler | approval.required | Creates a proactive conversation prompting the customer to review the proposed changeset, with explainability + agent performance card attached. |
OnSupervisorRecommendationHandler | supervisor.recommendation.issued | Creates or updates a proactive conversation carrying the supervisor’s recommendation narrative. |
Adding a New Event
Section titled “Adding a New Event”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 Finalfrom 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: strWhen a new event lands, the Events catalog is updated by hand at the same time so the catalog stays in step with the contract.
2. Define a consumer-narrow local model
Section titled “2. Define a consumer-narrow local model”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 UUIDfrom pydantic import BaseModel, ConfigDict
class MyNewEvent(BaseModel): model_config = ConfigDict(frozen=True) workspace_id: UUID # detail intentionally dropped if consumer doesn't need itThe consumer never imports the producer’s typed payload class outside tests/contracts/.
3. Create the consumer handler
Section titled “3. Create the consumer handler”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.pyfrom spectral.core.events.envelope import EventEnvelopefrom 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. ...4. Wire the handler at workers startup
Section titled “4. Wire the handler at workers startup”Register the handler in the consuming context’s listener startup (the workers entrypoint per ADR-031):
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,)5. Add a bilateral contract test
Section titled “5. Add a bilateral contract test”Add a bilateral round-trip test under tests/contracts/<event>_test.py (per ADR-065 D6 +
ADR-066). See
testing for the pattern.
6. Publish from the producer
Section titled “6. Publish from the producer”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.pyfrom spectral.core.events.envelope import EventEnvelopefrom spectral.core.events.protocols import EventPublisherfrom 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)Communication between worlds and platform
Section titled “Communication between worlds and platform”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.
Pattern: intra-context notification
Section titled “Pattern: intra-context notification”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.
Synchronous calls — not events
Section titled “Synchronous calls — not events”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.
Why events for notification flow
Section titled “Why events for notification flow”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).
Serialization and Transport
Section titled “Serialization and Transport”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.