ADR 0002: Ingress Boundary

10 min read•docs/adr/0002-collector-sdk-outbox.md•

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):

  1. Stage 1: L2 sync HTTP + collector durable buffer (T2.2, T2.2a, T2.2b).
  2. Stage 2: L3 CDC reconciliation for sessions, bookings, payments (T2.2c).
  3. 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 same event_id through backoff.
  • Canonical idempotency key: project_id + source + source_event_id — one canonical business fact per source outcome; this is the DoD key source + source_event_id scoped by project_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 transport event_id).
  • No exactly-once promise.
  • Ordering: not guaranteed globally; use occurred_at, sent_at, received_at for 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)

  1. Confirm Simiaroom primary backend language with eng (T0.5).
  2. 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.
  3. CDC technology choice (logical replication vs polling) after DB access inventory (T0.5).
  4. Collector hosting details pending ADR 0001 (T0.2).