ADR 0002 — Collector, SDK Language, Producer-of-Record, and Durability Strategy
| Field | Value |
|---|---|
| Status | Proposed (T0.6) |
| Date | 2026-08-12 |
| Deciders | CX Orbit technical lead (pending Simiaroom eng sign-off) |
| Related | SDK-AND-DATA-INGESTION.md, MVP-EVENT-SCOPE.md, TECHNICAL-ARCHITECTURE.md, SOURCE-OWNERSHIP.md |
Context
CX Orbit needs a durable, auditable path for Simiaroom to prove business outcomes (booking, payment, session, churn) without coupling primary workflows to analytics availability. The ingestion contract in SDK-AND-DATA-INGESTION.md already defines collector-centric boundaries, at-least-once delivery, and quarantine semantics. T0.6 locks implementation choices so Wave B can ship the collector (T2.2), backend SDK (T2.2a), and first P0 instrumentation (T2.2b).
Constraints:
- Collector is the sole event write ingress — Simiaroom SDKs and integrations never connect to PostgreSQL, ClickHouse, or Orbit raw storage.
- Backend proves outcomes — browser events describe behavior and intent only.
- Three-person team — prefer one primary backend SDK language and a layered durability model operable without Kafka/self-managed streaming for MVP.
- Simiaroom stack is not yet inventoried in-repo (T0.5) — language choice documents an explicit assumption pending confirmation.
Decision
1. Collector placement and boundary
Deploy cx-orbit-collector as a dedicated CX Orbit service (HTTP ingestion API + internal ingest workers). It is the only entry point for externally produced events.
| Ingress | Path | Writes storage? |
|---|---|---|
@cx-orbit/node (Simiaroom backend) |
HTTPS → Collector API | No (SDK is transport-only) |
@cx-orbit/browser (future P1) |
HTTPS → Collector API | No |
| Webhooks (payment gateway, forms) | HTTPS → Collector API | No |
| CDC / scheduled sync (Simiaroom product DB) | Orbit-owned ingest worker reads source DB/API → maps → writes via internal collector ingest path | No (Simiaroom does not write Orbit storage; Orbit worker is not an SDK) |
| CSV / batch imports | Orbit-owned import job → same internal ingest path | No |
The collector subsystem owns: authentication, rate limits, schema/privacy validation, batch responses, durable accept buffer, quarantine for invalid payloads, and routing to immutable raw storage. Downstream canonical mapping, identity, and operational stores are Orbit-internal only.
flowchart LR
subgraph simiaroom [Simiaroom — never writes Orbit storage]
BE[Backend services]
Outbox[(Transactional outbox)]
ProdDB[(Product DB)]
BE --> Outbox
Outbox --> NodeSDK["@cx-orbit/node"]
end
subgraph orbit [CX Orbit — sole write ingress]
Collector[Collector API]
Worker[CDC / import ingest worker]
Validate[Auth + schema + privacy]
Buffer[(Durable accept buffer)]
Quarantine[(Quarantine)]
Raw[(Immutable raw events)]
NodeSDK --> Collector
Worker --> Validate
Collector --> Validate
Validate -->|valid| Buffer --> Raw
Validate -->|invalid| Quarantine
ProdDB -.->|read-only CDC| Worker
end
Hosting: co-locate collector with CX Orbit on the platform chosen in ADR 0001 (T0.2). Expose staging and production collector endpoints with separate credentials per environment.
2. Primary backend SDK language (MVP)
Primary language: Node.js / TypeScript — package @cx-orbit/node.
| Assumption | Rationale |
|---|---|
| Simiaroom's main product backend is Node.js or TypeScript (monolith or primary API service) | Typical modern therapy-platform stack; aligns with SDK-AND-DATA-INGESTION.md package layout (@cx-orbit/core, @cx-orbit/events, @cx-orbit/node) and typed generated event methods |
| Browser/client app uses TypeScript | @cx-orbit/browser shares @cx-orbit/core and generated types (P1 — after Wave B backend slice) |
MVP scope: ship one first-class backend SDK (@cx-orbit/node). Services in other languages call the versioned collector HTTP API directly with the same envelope and idempotency keys; no second SDK until a confirmed integration requires it (per ingestion doc §15 Stage 4).
Validation required: T0.5 source inventory must confirm or correct this assumption. If Simiaroom's authoritative backend is PHP/Laravel or Python instead, keep @cx-orbit/node for Orbit-side tooling but prioritize HTTP contract + instrumentation guide for the confirmed primary stack; defer a native SDK to a follow-on task.
Alternatives considered
| Option | Reject reason |
|---|---|
| PHP/Laravel SDK first | Common in regional stacks, but unconfirmed; doubles MVP SDK surface; HTTP API sufficient for non-Node services until proven need |
| Python SDK first | No evidence Simiaroom backend is Python; same MVP cost as multi-SDK |
| Multi-language SDKs in Wave B | Violates capacity guardrail; splits fixture/CI and reconciliation ownership |
3. Producer-of-record
Every authoritative P0 outcome has exactly one producer of record. Duplicate observations from other sources are diagnostic only and must not create duplicate business facts.
Full matrix: SOURCE-OWNERSHIP.md (booking, payment, session, churn/retention, and initial 8–12 event slice).
Rules preserved:
- Browser / GA → behavior and intent only.
- Booking, payment, session, refund outcomes → Simiaroom backend or CDC-reconciled source table.
- Internal case/action outcomes → CX Orbit operational store emits after committed state change.
4. Durability strategy — layered outbox + sync HTTP + CDC reconciliation
Use all three patterns in layers, not as mutually exclusive choices:
| Layer | Mechanism | When | Purpose |
|---|---|---|---|
| L1 — Transactional outbox (Simiaroom) | cx_outbox_events (or equivalent) in same DB transaction as business commit; relay process invokes @cx-orbit/node |
P0 authoritative outcomes at instrumented write paths | Guarantees event intent survives process crash before collector ACK; tracking never blocks business commit |
| L2 — Sync HTTP via SDK | @cx-orbit/node POST batch to Collector API after commit (or from outbox relay) |
All SDK-produced events | Real-time ingestion; bounded in-memory queue + exponential backoff for transient failures |
| L3 — CDC / scheduled reconciliation | Orbit ingest worker polls/streams Simiaroom product DB (bookings, sessions, payments) on schedule | Historical backfill, missed outbox rows, legacy paths, drift detection | Completeness target ≥ 99% against source records; not a substitute for L1 on new writes |
MVP sequencing (matches TECHNICAL-TASKS.md Wave B):
- Stage 1: L2 sync HTTP + collector durable buffer (T2.2, T2.2a, T2.2b).
- Stage 2: L3 CDC reconciliation for sessions, bookings, payments (T2.2c).
- Stage 1–2 overlap: L1 transactional outbox in Simiaroom when instrumenting each P0 write path (T2.14 — implement where business DB supports same-transaction insert).
Do not introduce self-managed Kafka or a separate message broker for MVP. Collector accept buffer + PostgreSQL (T2.1) is sufficient until T0.7/T3.12 measured volume proves otherwise.
Alternatives considered
| Option | Reject reason |
|---|---|
| Sync HTTP only (no outbox) | Loses events on crash between DB commit and successful POST; fails collector-outage durability DoD |
| CDC only (no SDK instrumentation) | High lag, weak occurred_at fidelity, poor action-loop freshness; contradicts “backend proves outcomes” for new writes |
| Outbox only (no HTTP) | Requires broker or polling infrastructure in Simiaroom; HTTP batch to collector is simpler for 3-person team |
| Kafka between Simiaroom and Orbit | Operational overhead disproportionate to MVP volume; deferred unless measured need (ingestion doc §14) |
5. Failure modes and ownership
5.1 Simiaroom-side (producer + SDK + outbox)
| Failure | Owner | Behavior |
|---|---|---|
| Business transaction fails | Simiaroom domain service | No outbox row; no event |
| Business commit succeeds; outbox insert fails | Simiaroom domain service | Critical — treat as application error; alert; fix before relying on analytics for that path |
| Outbox relay / SDK: collector returns 408, 429, 502, 503, 504 | Simiaroom (@cx-orbit/node + outbox relay) |
Exponential backoff + jitter; honor Retry-After; preserve stable event_id |
| Outbox relay / SDK: collector returns 400, 401, 403, 422 | Simiaroom | Do not retry — fix payload/config; move to dead-letter quarantine table in Simiaroom with alert |
| Outbox queue depth / age exceeds threshold | Simiaroom ops | Page on-call; events remain in outbox until delivered or manually quarantined |
| SDK in-memory queue overflow (non-outbox path) | Simiaroom | Drop oldest or reject new with metric; outbox required for P0 outcomes so this path is non-authoritative only |
| Process crash after commit, before outbox relay | Simiaroom outbox | Row remains pending; relay retries on restart |
| Collector prolonged outage | Simiaroom outbox | Events accumulate; relay resumes when collector healthy; L3 CDC detects sustained gaps |
5.2 CX Orbit collector and ingest
| Failure | Owner | Behavior |
|---|---|---|
| Auth failure (401/403) | Collector | Reject request; Simiaroom must not retry without credential fix |
| Schema / privacy validation failure | Collector | Reject per event; quarantine sanitized payload; retry_eligible=false for schema errors |
| Transient internal error after accept | Collector / ingest worker | Retry from durable accept buffer; transport dedup by project_id + event_id; canonical dedup by project_id + source + source_event_id |
Duplicate transport delivery (same event_id) |
Collector / canonical pipeline | Dedup at accept layer; count as duplicate metric, not new fact |
Duplicate source fact (new event_id, same source + source_event_id) |
Collector / canonical pipeline | No second canonical fact (ingestion §8.2); may retain diagnostic/raw copy; CDC/SDK/import paths for the same fact must share source + source_event_id |
| Collector total outage | CX Orbit platform | 503 to clients; Simiaroom outbox holds; alert on Orbit SLO breach |
| CDC worker lag or source DB unreachable | CX Orbit ingest | Pause sync; alert; reconciliation report shows gap; no silent skip |
5.3 Reconciliation and quarantine (cross-boundary)
| Concern | Owner |
|---|---|
| Invalid P0 events visible in DQ dashboard | CX Orbit (T2.11) |
| Source vs Orbit count mismatch | CX Orbit reconciliation job (T2.2c); Simiaroom fixes missing instrumentation |
| Dead-letter outbox rows (permanent reject) | Simiaroom eng + CX Orbit data eng joint triage |
| Quarantined schema-invalid events | CX Orbit data eng; fix schema or producer; manual replay only after fix |
5.4 Non-blocking guarantee
Analytics failures never fail booking, payment, or session workflows. Business commit completes first; event emission is post-commit (outbox or async SDK call).
6. Delivery semantics summary
Aligned with the locked envelope contract (SDK-AND-DATA-INGESTION.md §7.4 / §8.2; T0.4):
- At-least-once end-to-end; dedup at collector/canonical layer.
- Transport dedup key:
project_id + event_id— SDK/outbox retries preserve the sameevent_idthrough backoff. - Canonical idempotency key:
project_id + source + source_event_id— one canonical business fact per source outcome; this is the DoD keysource + source_event_idscoped byproject_id. - Cross-path rule: CDC, import, webhook, and SDK copies of the same source fact must share
source+source_event_id(each path may generate a new transportevent_id). - No exactly-once promise.
- Ordering: not guaranteed globally; use
occurred_at,sent_at,received_atfor late-event handling.
Consequences
Positive
- Clear separation: Simiaroom owns business truth and outbox durability; Orbit owns validation, storage, quarantine, and reconciliation.
- Single backend SDK reduces Wave B scope and CI/fixture cost.
- Layered L1/L2/L3 meets completeness targets without Kafka.
- Collector API remains stable if storage moves from PostgreSQL to ClickHouse (T3.12).
Negative / tradeoffs
- Simiaroom must implement outbox table + relay (T2.14) for highest-reliability P0 paths.
- CDC adds read access to product DB (dependency for T0.5).
- Node/TypeScript assumption may require revision after T0.5 — HTTP contract limits rework cost.
Follow-on tasks unblocked
| Task | Dependency satisfied |
|---|---|
| T2.2 | Collector boundary, auth model, validation outcomes |
| T2.2a | @cx-orbit/node as primary SDK |
| T2.2b | Producer-of-record matrix |
| T2.14 | Outbox pattern decision |
| T2.2c | CDC as reconciliation layer |
| T0.5 | Ownership matrix template (refine access rows) |
| T1.3 | Source → event mapping uses ownership matrix |
Open items (not blockers for T0.6 test)
- Confirm Simiaroom primary backend language with eng (T0.5).
- Exact outbox table schema and relay:
packages/outbox/sql/cx_outbox_events.postgres.sql+ops/OUTBOX-RELAY.md(T2.14). Simiaroom product-repo same-TX wiring remains a follow-on. - CDC technology choice (logical replication vs polling) after DB access inventory (T0.5).
- Collector hosting details pending ADR 0001 (T0.2).