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

EventTriggerKey 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 handlerorg_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 materializerorg_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 inputevent_id, world_id, pattern_type, pattern_key, signal_count, representative_decision_ids, cluster_id, observed_at
AgentTaskCreatedEventNew agent task created via API. Carries Conversation + AgentTask domain-model identity per ADR-043 substrateorg_id, domain_id, task_id, conversation_id, user_id

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 the WorldModelCard mint per ADR-082 D2; typed payload at spectral.worlds.contracts.events.world_model_card_published per ADR-065 D2. Consumed by spectral.platform’s System Card projection handler — consumer-side ACL parses the wire payload with a local WorldModelCardPublishedEvent model; 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.

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 in apps/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.


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.

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:

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 DecisionRecordedEvent, both in spectral.platform:

HandlerContextResponsibility
OnDecisionRecordedSystemCardHandlerspectral.platform.application.system_cardUpdates 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.
OnDecisionRecordedSignalHandlerspectral.platform.application.override_patternConsumed 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:

HandlerSubscribes toResponsibility
OnOverridePatternAggregatedHandleroverride_pattern_signal.aggregatedRoutes 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.

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)
domain_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)
domain_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, *, 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)

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.

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.

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.

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.