Published in openvibe-contracts v0.84.0 (docs/adr/ADR-042-events-fabric.md), rendered as is.

ADR-042: The events fabric — carrier classes, per-key ordering, cursors and tiers

Status: Accepted 2026-09-29 (plan track T7, "Events complete"; the owner's plan names NATS JetStream as the cell-local carrier). Amends ADR-004: its "Kafka/NATS now: rejected" is superseded for the STREAM class only.

Context and current evidence

Decision

  1. Two layers, one vocabulary each. The delivery policy stays the only semantic vocabulary. The carrier class is an internal, derived label, never a contract enum and never in the envelope:

| Policy class | Carrier class | Carriers (in preference order) | | --- | --- | --- | | realtime_ephemeral | TOPIC (fan-out, no ack, no retention) | valkey-v1 pub/sub, pg-v1 | | interactive, interactive_durable, domain, critical, background, bulk, webhook | QUEUE (competing consumers, ack, at least once) | valkey-v1 streams, pg-v1 | | any class with durability: required and an ordering.key | STREAM (retained, ordered per key, offsets) | nats-v1 (JetStream), pg-v1 |

A publisher's declared intent may raise a class and never lower it. The resolved carrier class, carrier and the planner's reasons are answered by the delivery, replay and GET /api/v1/placement APIs.

  1. PostgreSQL stays the record. Every event is written to events and every delivery to deliveries before any carrier sees it. A carrier is a transport: when one is unhealthy or ineligible the planner drops it with a reason and pg-v1 carries the delivery. No carrier outage loses an event.
  2. The lease first. deliveries gains ordering_key, carrier, lease_owner and lease_until; a worker claims with FOR UPDATE SKIP LOCKED, at most one delivery in flight per (subscription, ordering key). This ships before any second carrier, because it is what makes a second worker safe.
  3. Per-key ordering. The key is policy.ordering.key resolved against the envelope, defaulting to the subject (subject.type + subject.id), else the event type. Never the seq. A key's backlog stays on one carrier until it drains (placement is pinned per key backlog, not per time window), so a rebalance cannot reorder a key.
  4. The planner. Dispatch calls openvibe-sdk/placement plan() with requirements { kind: 'events.deliver', mobility: 'stateful-partition' } plus the class's latency, durability and ordering, against one resource-offer@1 per carrier adapter (capabilities events:gateway, events:durable, events:ordered), priced from a revisioned rate-card@1 set, with health from the worker's own EWMA and breaker (Media's signals.js pattern). Decisions are cached per (class, key) with hysteresis and recorded in delivery_placements, so an explanation can be answered from history.
  5. JetStream carries STREAM only. One nats-server -js per cell on the Events host (openvibe-nats.service, file storage under /var/lib/nats, loopback and private network only, one stream per carrier class). It is never the record; when it is down STREAM deliveries fail open to pg-v1.
  6. Cursors replace the global seq. publish-result gains cursor and read-result gains cursor and next_cursor (opaque: a hot-store position plus a retention epoch); the SSE id becomes the cursor. Consumers' positions live in consumer_checkpoints with epoch and carrier columns. seq is still returned for exactly one release after the cursor ships; it and X-OpenVibe-Seq are then deleted, with the "global order" promise.
  7. Three tiers. hot = events (pruned at retention.hot); replay = events_archive in the same database (no delivery rows, compressed payloads, pruned at retention.replay); archive = monthly NDJSON objects in object storage, restored only by an operator job into events_archive. Pulls and scans span hot and replay with one cursor and answer the existing gap shape outside both. The archive tier is never on an online path.
  8. One inbox. lib/client.js re-exports the SDK's createPgInbox and createPgOutbox; its SQLite copies and better-sqlite3 go.
  9. The origin. Events serves its API and product on openvibe.events in one deploy; events.openvibe.network answers 308 only while its access log still shows callers, then it and its certificate are deleted.

Alternatives considered

Migration consequences

Rollback

Acceptance tests