openvibers/openvibe.contracts Commits

ADR-042, the events fabric (plan T7): the delivery-policy class stays the one vocabulary and TOPIC/QUEUE/STREAM is a derived carrier class; PostgreSQL stays the record and carriers fail open to pg-v1; a delivery lease (FOR UPDATE SKIP LOCKED, one in flight per subscription and key) ships before any second worker; per-key ordering pinned per backlog; the SDK planner places and explains deliveries; JetStream for STREAM only; opaque cursors replace the global seq after one overlap release; hot/replay/archive tiers; the SQLite inbox goes; the origin moves to openvibe.events. Amends ADR-004's NATS rejection for STREAM.

OP OpenVibers committed 29 Sept 2026 · f33a857aa0 · 3 files, +105 −1


Changes

ddiff --git a/docs/adr/ADR-004-durable-events.md b/docs/adr/ADR-004-durable-events.md
iindex 6a71308e..a6c34cfa 100644
---- a/docs/adr/ADR-004-durable-events.md
++++ b/docs/adr/ADR-004-durable-events.md
@@@ -28,7 +28,7 @@ Cross-service side effects are best-effort POSTs and HMAC webhooks; a consumer t
## Alternatives considered
--- Kafka/NATS now: rejected, operational weight far beyond current volume.
++- Kafka/NATS now: rejected, operational weight far beyond current volume. (Superseded for the STREAM carrier class by ADR-042, 2026-09-29: JetStream carries retained, per-key ordered streams; PostgreSQL stays the record.)
- Webhooks only: rejected, no durability or replay.
## Migration consequences
ddiff --git a/docs/adr/ADR-042-events-fabric.md b/docs/adr/ADR-042-events-fabric.md
nnew file mode 100644
iindex 00000000..3c5c9d73
---- /dev/null
++++ b/docs/adr/ADR-042-events-fabric.md
@@@ -0,0 +1,103 @@
++# 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
++
++- OpenVibe.Events stores every event in PostgreSQL (`events`), then one polling worker delivers to subscriptions
++ (`server/worker.js`). Two workers would double-send: a delivery is claimed with an in-process `busy` set, not a
++ database lease.
++- How an event type should be carried is already a contract: `events.delivery-policy@1` gives a class
++ (`realtime_ephemeral`, `interactive`, `interactive_durable`, `domain`, `critical`, `background`, `bulk`, `webhook`),
++ durability, delivery semantics, an ordering scope and key, latency targets, retention (hot, replay, archive) and a
++ routing objective. Nothing reads it yet.
++- `openvibe-sdk/placement` (`plan()`, `resource-offer@1`, `rate-card@1`) already chooses among offers by hard
++ constraints, then cost and latency, with hysteresis and an explanation. Media's fabric uses the same shape.
++- The global `seq` is the wire position today: `publish-result.seq`, `read-result.after_seq/next_after_seq/latest_seq`,
++ the SSE `id` and `X-OpenVibe-Seq`. Seven repositories persist it.
++- Valkey (ADR-035) already carries queues and pub/sub for nine services through `openvibe-sdk/queue` and `/pubsub`.
++
++## 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.
++2. **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.
++3. **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.
++4. **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.
++5. **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.
++6. **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`.
++7. **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.
++8. **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.
++9. **One inbox.** `lib/client.js` re-exports the SDK's `createPgInbox` and `createPgOutbox`; its SQLite copies and
++ `better-sqlite3` go.
++10. **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
++
++- **A TOPIC/QUEUE/STREAM contract enum:** rejected. It would be a second vocabulary beside the delivery-policy class
++ and a contract bump for every consumer, for a label only Events uses.
++- **JetStream for every class:** rejected. TOPIC and QUEUE traffic is served by Valkey and PostgreSQL, which already run;
++ a daemon is justified only where retained, ordered, offset-addressed logs are the product (STREAM).
++- **Kafka:** rejected for the same operational-weight reason ADR-004 gave; JetStream's single binary and file storage
++ fit one cell.
++- **Keeping the global seq:** rejected. A single sequence is a single writer; cursors carry the retention epoch that
++ a tiered, multi-carrier store needs.
++
++## Migration consequences
++
++- Migration `0002` (expand): `delivery_policies`, the four `deliveries` columns, `events_archive`,
++ `delivery_placements`, and `consumer_checkpoints.epoch/carrier`. Contract migration after the seq release.
++- Contracts: `publish-result` and `read-result` gain their cursor fields (additive, a minor release).
++- The SDK events client and the seven seq readers move to cursors in the compatibility release.
++- Host: the `openvibe-nats.service` unit, its storage and firewall rules; backups need nothing (NATS is not the record).
++
++## Rollback
++
++- Carriers: mark an adapter ineligible (configuration) and every delivery falls back to `pg-v1` at once.
++- The lease and the tiers are additive schema; the previous release ignores the new columns.
++- Cursors: during the compatibility release both `seq` and `cursor` are returned, so a consumer rolls back freely;
++ after `seq` is deleted, rolling a consumer back past its cursor support is not possible.
++- The origin: the old host keeps its 308 until its log is quiet; re-pointing is a DNS and nginx change.
++
++## Acceptance tests
++
++- Every policy class maps to exactly one carrier class; an unknown class is refused; a publisher cannot lower one.
++- Two workers, one due delivery: exactly one send. Per-key order holds across two workers and a crash (the consumer
++ dedupes the redelivery); two keys interleave freely.
++- A carrier marked unhealthy is excluded with a reason and `pg-v1` delivers; nothing is lost.
++- `GET /api/v1/placement` shape: non-empty reasons, eligibility, cost and latency per candidate; hysteresis holds a
++ placement inside `minGain`; a stored decision explains a past delivery.
++- Cursor round trip: publish, pull from the cursor, checkpoint, SSE resume; a cursor outside hot and replay gets `gap`.
++- Tiers: a hot-pruned event is readable through replay; archive write and operator restore round-trip; a cursor across
++ the tier boundary reports no false gap.
++- The inbox is awaited, duplicate-safe under concurrency and rolls back with the caller's transaction.
ddiff --git a/docs/adr/README.md b/docs/adr/README.md
iindex d878e9bc..447d2b72 100644
---- a/docs/adr/README.md
++++ b/docs/adr/README.md
@@@ -39,3 +39,4 @@ Platform-wide decisions from the OpenVibe development roadmap (section 10). Each
| [ADR-033](ADR-033-account-export-and-deletion.md) | Account export and deletion: services push their export parts to Network; deletion after a 30-day grace, each service erases its rows and confirms; money rows kept pseudonymised | Accepted 2026-09-27; implementation in progress |
| [ADR-034](ADR-034-platform-north-star.md) | Platform north star, an open affordable cloud: boundaries now (projects, `ovrn:` resource names, scoped grants, generated contracts, metering), infrastructure when measured; control/data plane and cells; regions, edge nodes, load balancing and a geo API; pulled, gated deploys; four extension levels under one manifest | Proposed 2026-09-28, for the owner's review |
| [ADR-035](ADR-035-postgresql-and-valkey.md) | PostgreSQL 18 as every service's system of record (database + roles per service, PgBouncer transaction pooling, pgBackRest PITR to B2) and Valkey as the shared non-authoritative store; the SDK data layer; services migrate one at a time, smallest first; supersedes ADR-007's trigger rule; amendment: PostgreSQL dialect only (PGlite in tests), a verified SQLite importer, every service async and migrated | Accepted 2026-09-28 (owner decision), amended the same day |
++| [ADR-042](ADR-042-events-fabric.md) | The events fabric: the delivery-policy class is the one vocabulary and TOPIC/QUEUE/STREAM a derived carrier class; PostgreSQL stays the record, carriers are transports that fail open to `pg-v1`; a delivery lease before any second worker; per-key ordering; the SDK planner places deliveries and explains them; JetStream for STREAM only; opaque cursors replace the global seq after one overlap release; hot/replay/archive tiers; amends ADR-004's NATS rejection | Accepted 2026-09-29 (plan T7) |