SPEC-065 — Event Stream Taxonomy
Canonical event topics and payload shape for SSE/subscribe consumers.
SPEC-065: Event stream taxonomy & subscription filters
| Field | Value |
|---|---|
| Status | Shipped |
| Owner | kris.ganjam@gmail.com |
| Date | 2026-05-01 |
| Pillar | Cross-cutting |
| Implementation | #451 |
Target repos: scidex-substrate (this doc, framework, event-name registry, subscription DSL parser, SSE backpressure). scidex-prism (live activity ticker subscribes — separate PR). |
|
| Masters: SPEC-001 §12 (event substrate), §13 (agent communication patterns), SPEC-019 (cutover SSE guarantees), SPEC-038 (the primary Prism consumer). |
Adjacent context:
-
SPEC-001 §12 declared three event-name namespaces but the substrate today emits a mixed bag — some
hypothesis_scored, somehypothesis.scored, somescore_changed. 34+ distinct event types in flight, no central registry. -
scidex.subscribeaccepts only a list of event-type strings (with last-segment wildcard). No filter on agent, target, payload — clients over-subscribe and filter client-side. -
Postgres
LISTEN/NOTIFYis wired but a single subscriber connection holds one PG session. Fan-out to many SSE clients is naive (one PG listener per HTTP connection). Will not survive the SPEC-038 ticker at scale. -
v1 had no SSE; SPEC-019 leans on substrate SSE for the ticker but does not pin envelope shape, ACK, or backpressure.
TL;DR
Anchored against north-star.md §6 “Treat every signal as a first-class audit trail”: every meaningful state change is already an event; the gap is no published taxonomy and no rich subscription filter. SPEC-065 finalises the SPEC-019 SSE contract: a formal three-tier event-type hierarchy (<artifact_type>.<lifecycle> / signal.<dimension>.recorded / link.<rel>.added / comment.posted / mission.assigned / market.resolved), a JSON subscription DSL filtering by type/agent/target/payload, one shared LISTEN/NOTIFY channel with server-side fan-out, versioned event envelopes, Last-Event-Id ACK, and 30-second slow-client drop. Net effect: Prism’s live ticker, agent subscribers, and external mirrors share one durable, filterable event substrate.
1. Problem statement
Four interlocking gaps:
-
No event-name registry. Code paths invent their own names.
hypothesis.scoredvshypothesis_score_changedvsscore.updated. Subscribers ship blind regex matches that break silently when an emitter renames. -
Subscription DSL is too thin.
types=['hypothesis.*']is the entire surface. The most common needs — “events on artifacts I care about” / “events from agent X” — are not expressible. -
SSE fan-out doesn’t scale. Each HTTP/SSE client opens its own PG
LISTEN. At 100 viewers, 100 idle PG sessions — fragile undermax_connections. -
No backpressure contract. Slow clients accumulate buffer server-side. No documented eviction. A single slow client can stall the broadcast loop.
These add up to: SPEC-038 cannot ship reliably, SPEC-051 pantheon-debate subscribers cannot filter to “events on this debate”, SPEC-019 assumes SSE works at scale without specifying how.
2. Decision
2.1 Formal event taxonomy
Three top-level shapes plus a versioned envelope:
| Shape | Pattern | Examples |
|---|---|---|
| Artifact lifecycle | <artifact_type>.<lifecycle> |
hypothesis.created, hypothesis.promoted, pantheon_debate.frozen, paper.archived, wiki_page.merged |
| Signal recorded | signal.<dimension>.recorded |
signal.upvote.recorded, signal.fund.recorded, signal.calibration_score.recorded |
| Relationship change | link.<rel>.added / link.<rel>.removed |
link.supports.added, link.cites.added, link.contradicts.removed |
| Communication | comment.posted, comment.reacted, comment.edited |
unchanged |
| Domain-specific | mission.assigned, market.resolved, collider.opened, senate.quality_gate_evaluated, flag.raised |
one per documented domain action |
| System | system.<noun>.<verb> |
system.lmsr_subsidy_exhausted, system.embed_backfill_completed |
Names are lowercase snake_case with . as separator. * matches one segment; ** matches across segments (e.g. hypothesis.**). Names are stable — renaming requires a deprecation period during which old + new both fire.
A new module events/registry.py holds the canonical list as frozenset[str] plus a small Pydantic payload model per type. Emitters call events.publish(EVENT_NAME, …) where EVENT_NAME is imported. Emitting an unregistered name is a runtime error in tests, a warning in prod.
2.2 Versioned envelope
{
"envelope_version": 1,
"event_id": 14728316, // monotonic, gap-free per emitter
"event_type": "hypothesis.promoted",
"ref": "hypothesis:h-7a9c@sha256:abc",
"agent": {"id":"agent-u-...", "kind":"ai_persona"},
"session_id": "...",
"occurred_at":"2026-05-01T12:34:56Z",
"payload": { "from_state":"supported", "to_state":"promoted", "score":0.83 },
"links": [{"rel":"target","ref":"hypothesis:h-7a9c"}]
}
envelope_version lets future evolutions (e.g. adding delegation_chain) avoid breaking older subscribers.
2.3 Subscription DSL
{
"types": ["hypothesis.*", "signal.upvote.recorded"],
"agents": ["persona:mendel", "human:u-jane"],
"targets": ["paper:p-123", "hypothesis:h-7a9c"], // event.ref OR event.links[].ref
"payload": {">=": [{"var":"score"}, 0.7]}, // JSONLogic over event.payload
"since": 14728000, // event_id cursor
"consumer_id": "ticker-prism-prod-1" // durable cursor key
}
All four scoping fields are AND-combined. Within a field, OR-semantics. Empty / omitted fields are wildcards.
def matches(event, filter):
if filter.types and not any(glob_match(t, event.event_type) for t in filter.types): return False
if filter.agents and event.agent.id not in filter.agents: return False
if filter.targets:
target_refs = {event.ref} | {l.ref for l in event.links}
if not (target_refs & set(filter.targets)): return False
if filter.payload and not jsonlogic.eval(filter.payload, event.payload): return False
return True
The same filter is accepted by scidex.poll (pull) and scidex.subscribe (push) — one DSL, two delivery modes per SPEC-001 §13.
2.4 SSE channel multiplexing
Replace per-client LISTEN with one server-process LISTEN scidex_events plus an in-process broker:
Postgres ──NOTIFY──> [single LISTEN coroutine] ──> [in-proc broker] ──┬──> SSE client A (filter f_A)
├──> SSE client B (filter f_B)
└──> SSE client … (filter f_…)
The single LISTEN coroutine receives every notify, fetches the event row by id, evaluates each connected client’s filter, forwards to those whose filter matches. PG connection count is O(1) per substrate process regardless of viewer count.
Per-client outbound queue cap: 256 events. Overflow → client marked degraded.
2.5 Backpressure & ACK
-
SSE clients send
Last-Event-Idon reconnect; server resumes by reading from theeventstable (durable replay). -
During a live connection, server tracks the latest event the client has been written. If the gap exceeds 256 events OR last ACK was >30s ago, the connection closes with HTTP-event
system.client_dropped_slowand a metric. -
Clients reconnect, present
Last-Event-Id, replay from cursor. No event is lost. -
scidex.subscribeMCP/AsyncIterator semantics use the same per-iterator queue cap; ifawait nextdoesn’t run within 30s the iterator is closed.
2.6 Cursor durability
Per SPEC-001 §12, durable consumers register a consumer_id; cursor in consumer_subscriptions. On reconnect with the same consumer_id, replay continues. Cursors advance server-side after delivery confirmation. If silent for >7 days, cursor is GC’d; reconnect requires explicit since=.
3. Schema / verb changes (concrete)
-
New module:
events/registry.py—frozenset[str]of all event types + payload Pydantic model per type. -
New module:
events/broker.py— singleLISTENcoroutine + in-proc fan-out. -
New module:
events/filter.py— DSL parser + matcher. -
Updated:
scidex.subscribeandscidex.pollaccept the newfiltershape (back-compat: baretypes=[...]rewritten to{types: [...]}). -
Updated: every emitter call site imports event-type constants from
events.registry(mechanical refactor). -
Migration:
0053_consumer_subscriptions_filter.sqladdsfilter_json JSONBtoconsumer_subscriptions(nullable, back-compat). -
Health:
/health/deepreportsevents: { registered_types: 47, in_flight_subscriptions: 12, broker_lag_ms: 3 }.
No new top-level tables. The events table already exists.
4. Acceptance
-
Registry coverage. Every emitter imports from
events.registry. CI grep test fails on string-literalevents.publish("..."). -
Type taxonomy enforced. Emitting unregistered names logs a warning in prod, raises in tests.
-
Filter DSL end-to-end. Subscribe
{types:["hypothesis.*"], agents:["persona:mendel"]}. Mendel creates hypothesis → received. Darwin creates hypothesis → not. Mendel posts comment → not. -
Target filter.
{targets:["paper:p-123"]}deliverslink.cites.addedwithlink.ref="paper:p-123"; unrelated link is not delivered. -
Payload filter.
{types:["signal.upvote.recorded"], payload:{">=":[{"var":"weight"},5]}}delivers only high-weight upvotes. -
Fan-out scale. 100 concurrent SSE clients, distinct filters. Substrate process opens exactly 1 PG
LISTEN. p95 broker latency <50 ms at 100 events/s ingest. -
Backpressure drop. Slow client (sleep on TCP write). After 30 s, server closes with
system.client_dropped_slow. Reconnect withLast-Event-Idresumes correctly. -
Durable replay. Disconnect a
consumer_id-registered subscriber; emit 100 events; reconnect; the 100 missed events replay in order from the cursor. -
Envelope versioned. Bump to
envelope_version=2. Old subscribers (declared=1accept) reject gracefully with a typed error rather than crashing.
5. Rollout
Phase 1 (1 PR, M): registry module + emitter refactor. No subscriber-visible change yet — internal tidy.
Phase 2 (1 PR, M): filter DSL + matcher. scidex.subscribe accepts the new shape (back-compat with bare list).
Phase 3 (1 PR, L): broker + single-LISTEN fan-out + backpressure. Behind SCIDEX_SSE_BROKER=1 flag; fall back to per-client LISTEN if disabled.
Phase 4 (1 PR, S): durable consumer cursor. SPEC-038 ticker becomes the first production subscriber.
Phase 5 (per-feature): SPEC-068 moderation surfaces, SPEC-051 pantheon-debate “live argument” feed, SPEC-018 economic-loop signal stream all ship subscribers using the new DSL.
Compatible with SPEC-019 cutover: Phases 1-3 pre-cutover; Phase 4 post-cutover; Phase 5 per-feature thereafter. Rollback is per-phase; PG LISTEN plumbing stays valid throughout.
Work Log
2026-05-14 — Phases 1–4 implementation
Phase 1 — merged (commit 2d197a3 / PR #451)
-
src/scidex_substrate/events/taxonomy.py— canonical event-type constants (25 types) -
src/scidex_substrate/events/filter.py— SPEC-065 §2.3 DSL matcher (types/agents/targets) -
src/scidex_substrate/events/__init__.py— module entry -
src/scidex_substrate/migrations/0059_subscription_filters.sql—subscription_filterstable -
src/scidex_substrate/skill/verbs/poll.py—scidex.pollwith SPEC-065 DSL routing -
src/scidex_substrate/api/sse.py— SSE endpoint with full filter support
Phase 2 — payload JSONLogic filter [task:d0cdcde9-51a6-4ed7-8f15-68ec4051de39]
-
Added
_jsonlogic_eval()toevents/filter.pysupportingvar,>=,<=,>,<,==,!=,and,or,!. Unknown operators are permissive (do not constrain). -
matches()now evaluatesfilter["payload"]as a JSONLogic rule overevent["payload"]. -
Updated
_SPEC065_KEYSinpoll.pyto include"payload"and"since"/"consumer_id". -
20 new unit tests in
tests/unit/test_event_filter_p2.py.
Phase 3 — single-LISTEN broker [task:d0cdcde9-51a6-4ed7-8f15-68ec4051de39]
-
Created
src/scidex_substrate/events/broker.py:-
EventBrokersingleton: one PGLISTEN scidex_eventsper process. -
Subscriber(filter_, since): per-clientasyncio.Queue(maxsize=256)+ slow-client detection. -
_dispatch(): fan-out with backpressure (queue overflow → degraded) and 30 s slow-client eviction (is_slow()check on non-empty queue). -
Reconnect loop: PG errors restart after 5 s without stalling the SSE service.
-
broker_enabled(): checksSCIDEX_SSE_BROKER=1.
-
-
Updated
src/scidex_substrate/api/sse.py:-
broker_stream(): replay missed events on connect, then drain from subscriber queue, emitsystem.client_dropped_slowon degradation, heartbeat atheartbeat_secs. -
make_sse_endpoint(): routes tobroker_streamwhenbroker_enabled(), falls back to per-clientevent_stream(legacy, default).
-
-
13 new unit tests in
tests/unit/test_event_broker.py.
Phase 4 — durable cursor
Already complete: consumer_subscriptions (with filter_json, cursor, last_activity_at)
is in 0001_initial_schema.sql; scidex.poll implements full UPSERT cursor tracking.
Phase 4 is production-ready; SPEC-038 ticker can register as a durable consumer via
consumer_id on scidex.poll.
Phase 5 — per-feature (SPEC-068 moderation, SPEC-051 pantheon-debate, SPEC-018 economic-loop) are downstream and ship individually per the rollout plan.
6. Open questions
-
Glob vs regex. Globs are simpler but limited. Decision: ship globs first; revisit if real demand emerges.
-
Cross-substrate federation. A future federated read (SPEC-021) could surface as
federated.<remote>.<event>. Out of scope; registry can absorb such names later. -
Replay window.
eventsgrows unboundedly. Declare 30 days of online replay; rely on archival for older. Confirm with SPEC-008. -
Per-agent concurrency. Should one agent have 100 concurrent subscriptions? Likely no for humans, yes for orchestrator. Tie to SPEC-004.
-
Event ordering across emitters.
event_idis monotonic per emitter. Across emitters, follows insertion order. Subscribers should order onevent_id, notoccurred_at. -
WebSocket variant. SSE is one-way. Defer; not required by current consumers.
-
Visibility filtering. Some events (moderation, COI per SPEC-068) should not be visible to all subscribers. Per-event
visibility: public|reviewer_only|adminenforced by the broker. Detailed in SPEC-068.