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, domain_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 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.
| Event | Trigger | Key Fields |
|---|---|---|
WorldModelCardPublishedEvent (worlds.world_model_card.published) | World Agent emits in the same publication transaction as the WorldModelCard mint; typed payload at worlds.contracts.events.world_model_card_published. Consumed by spectral.platform’s System Card projection handler | org_id, domain_id, world_model_version, authority_summary, provenance_summary, configuration_snapshot, action_registry_summary, restatement_summary, published_at, released_by |
DecisionRecordedEvent (decision.recorded) | Platform decision-server emits after each /decide invocation completes (audit-chain row persisted). Consumed by the System Card aggregation pipeline and by the override-pattern signal materializer | org_id, domain_id, action, decision_id, status, world_model_version, recorded_at |
OverridePatternSignalAggregatedEvent (override_pattern_signal.aggregated) | Platform-side signal aggregator emits when an override-pattern cluster crosses its per-world threshold per Evolution Loop — Platform substrate. The payload is world-scoped: a world model’s customer-operator feedback signals correlate by pattern into a single cluster, so per-decision provenance rides inside representative_decision_ids (each sample carrying its org_id, domain_id, decision_id). Consumed by the World Agent as a rule-candidate proposal input | event_id, world_id, pattern_type, pattern_key, signal_count, representative_decision_ids, cluster_id, observed_at |
AgentTaskCreatedEvent | New agent task created via API. Carries Conversation + AgentTask domain-model identity per ADR-043 substrate | org_id, domain_id, task_id, conversation_id, user_id |
Decision-time events
Section titled “Decision-time events”DecisionRecordedEvent is the highest-volume event in the system — one per /decide
invocation. It does not carry the full decision metadata (that lives in the audit-chain
row); the event payload is the slim handle downstream consumers need to refresh their
projections. The System Card aggregation pipeline reads the audit-chain row by decision_id;
the override-pattern signal materializer reads it only when the decision was flagged.
OverridePatternSignalAggregatedEvent rides the substrate as the world-signal path.
World Model emissions (worlds → consumers)
Section titled “World Model emissions (worlds → consumers)”One event flows from spectral.worlds to consumers in spectral.platform:
worlds.world_model_card.published— emitted in the same operator-triggered publication transaction as theWorldModelCardmint per ADR-082 D2; typed payload atspectral.worlds.contracts.events.world_model_card_publishedper ADR-065 D2. Consumed byspectral.platform’s System Card projection handler — consumer-side ACL parses the wire payload with a localWorldModelCardPublishedEventmodel; projection materializes a snapshot row keyed by(org_id, domain_id, world_model_version). The System Card pipeline reads from this projection when rendering customer-facing snapshots per system-card / Card Linkage. No Reader Protocol — see ADR-063. Idempotent on(org_id, domain_id, world_model_version)under at-least-once delivery.
Events flowing into Operations
Section titled “Events flowing into Operations”The override_pattern_signal.aggregated event drives the operator-facing work surface in
apps/operations. It rides the same outbox substrate as every event in the system; its
consumer is idempotent under at-least-once delivery.
override_pattern_signal.aggregated(intra-platform; platform → operations) — emitted by the platform-side override-pattern signal aggregator when a cluster crosses the operator-triage threshold per Evolution Loop — Platform substrate: override-pattern aggregation. Consumed by the platform-side operator-triage projection to upsert into the operator triage queue with operator-column preservation — the operator-managed columns (status,assigned_to,notes) survive snapshot updates from re-emitted signals. The operator triage queue feeds the triage surface inapps/operations.
Override-pattern signal events (platform → worlds)
Section titled “Override-pattern signal events (platform → worlds)”Published when an aggregated override-pattern cluster crosses the operator-triage threshold and
is routed to the World Model system as a rule-candidate proposal input. The wire event —
override_pattern_signal.aggregated — is the primary integration surface between
spectral.platform and spectral.worlds.
Delivery semantics: At-least-once with idempotent handlers. The signal path is statistically robust — a missed aggregation 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.
The context-agnostic outbox listener (spectral.core.events.infrastructure) holds a direct-Postgres connection and dispatches to per-context handlers; 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). The context-agnostic concretion of these Protocols — the outbox listener that drains core.outbox — lives at spectral.core.events.infrastructure (per ADR-099); per-context event handlers register onto it at the workers composition seam:
@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”DecisionRecordedEvent handlers
Section titled “DecisionRecordedEvent handlers”Two handlers subscribe to DecisionRecordedEvent, both in spectral.platform:
| Handler | Context | Responsibility |
|---|---|---|
OnDecisionRecordedSystemCardHandler | spectral.platform.application.system_card | Updates the deployment-scoped System Card snapshot per (org_id, domain_id) per ADR-082 D3: increments four-state status totals, updates latency percentiles, advances version-history markers when world_model_version differs from the prior snapshot. |
OnDecisionRecordedSignalHandler | spectral.platform.application.override_pattern | Consumed only when the decision was flagged via review-request or noteworthy per Customer Dashboard — Interaction primitives. Routes the signal into the override-pattern aggregation pipeline; emits override_pattern_signal.aggregated when the cluster crosses the operator-triage threshold. |
Override-pattern signal handler (worlds-side)
Section titled “Override-pattern signal handler (worlds-side)”One handler in spectral.worlds.application.evolution_loop subscribes to
override_pattern_signal.aggregated:
| Handler | Subscribes to | Responsibility |
|---|---|---|
OnOverridePatternAggregatedHandler | override_pattern_signal.aggregated | Routes the aggregated cluster into the World Agent’s rule-candidate proposal flow per Evolution Loop — Platform substrate: override-pattern aggregation. The World Agent surfaces the cluster as a candidate proposal for operator review. |
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) domain_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) domain_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, *, domain_id: UUID, detail: str) -> None: payload = MyNewEventPayload(domain_id=domain_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, # domain_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 (the validator’s inter-context-import rule); 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”override_pattern_signal.aggregated (in spectral.platform.contracts.events.override_pattern_signal_aggregated)
flows from platform’s signal aggregator to the World Agent as a rule-candidate proposal input.
Worlds parses the wire payload with its own local ACL model and materializes a worlds-local
projection per ADR-064.
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 snapshot row keyed on (org_id, domain_id, world_model_version). The System
Card pipeline reads from this projection when rendering customer-facing snapshots.
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: decision.recorded (in
spectral.platform.contracts.events.decision_recorded) flows from the decision-server to the
System Card aggregation 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, 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.