SDKs & Ingestion Architecture

33 min read•docs/SDK-AND-DATA-INGESTION.md•

SDK & Data Ingestion — CX Orbit MVP

This document defines how Simiaroom systems send data to CX Orbit. It supplements the product and journey source documents; it does not replace their event definitions.

1. Decision summary

CX Orbit will expose a versioned event protocol and collector API. Thin SDKs make that protocol convenient to use.

The SDK is not the data platform and must not contain lifecycle, churn, risk, or clinical interpretation logic.

Recommended integration order:

  1. Canonical schemas and collection API
  2. Backend SDK for authoritative business events
  3. CDC/batch reconciliation for existing operational records
  4. Browser SDK for behavioral funnel detail
  5. Additional mobile or language SDKs only when a real integration requires them

2. Provider patterns adopted

The design follows recurring patterns from established event and telemetry providers:

Pattern CX Orbit application
Segment-style tracking plan Define event meaning, producer, owner, properties, and version before implementation
Snowplow-style collector Receive, validate, enrich, quarantine invalid data, and retain replayable raw events
RudderStack-style warehouse ownership Route first-party data into CX Orbit-controlled storage; transform and mask data in flight
Amplitude-style lightweight browser delivery Non-blocking batching, bounded queues, navigation-safe flush, configurable identity storage
OpenTelemetry-style collector boundary Keep SDK exporters thin; centralize batching, filtering, retries, encryption, and routing

For a mental-health product, explicit instrumentation is preferred over broad DOM autocapture.

3. Collection paths

3.1 Browser SDK

The browser SDK records behavior and client-side context:

  • Page and entry views
  • Assessment start, progress, completion intent, and abandonment
  • Therapist list/profile/availability views
  • Booking flow interactions
  • Payment UI interactions
  • Form exposure and submission intent
  • Client-side technical failures that affect the journey

Browser events are not authoritative for payment, booking, session, refund, pause, completion, or case outcomes.

3.2 Backend SDK

The backend SDK records authoritative domain outcomes:

  • Assessment completed
  • Referral created
  • Booking confirmed/cancelled
  • Payment succeeded/failed/refunded
  • Session completed/cancelled/no-show
  • Therapist change completed
  • Pause declared
  • Treatment completed
  • Internal case resolved

Every authoritative event must have exactly one producer of record.

3.3 CDC, API sync, and imports

SDK instrumentation does not replace historical and reconciliation data. Use controlled CDC, APIs, webhooks, or batch imports for:

  • Historical clients, therapists, bookings, sessions, payments, and refunds
  • Package balances and current operational status
  • Porsline/MyOutcomes responses
  • Support tickets and operational follow-ups
  • Corrections or records created outside instrumented application paths

4. Logical collection architecture

flowchart LR
  Web[Browser SDK] --> Collector[CX Orbit Collector API]
  Backend[Backend SDK] --> Collector
  Mobile[Future Mobile SDK] --> Collector
  Hooks[Webhooks] --> Collector
  CDC[CDC / Scheduled Sync] --> Map[Source Mapping]
  Imports[File Imports] --> Map

  Collector --> Auth[Auth + Rate Limits]
  Auth --> Validate[Schema + Privacy Validation]
  Validate -->|valid| Queue[Durable Buffer / Queue]
  Validate -->|invalid| Quarantine[Restricted Quarantine]
  Queue --> Raw[(Immutable Raw Events)]
  Map --> Raw
  Raw --> Canonical[Canonical Mapping + Identity]
  Canonical --> Operational[(PostgreSQL Operational Store)]
  Canonical --> Analytics[(Analytical Store)]
  Operational --> API[CX Orbit API]
  Analytics --> API

SDKs must never connect directly to PostgreSQL, ClickHouse, or another warehouse.

5. Package boundaries

Recommended initial packages:

@cx-orbit/core
  Event envelope, transport interfaces, common context, errors

@cx-orbit/events
  Generated event types and domain methods

@cx-orbit/browser
  Anonymous identity, sessions, attribution, consent, queue, browser transport

@cx-orbit/node
  Server authentication, queue, batch transport, flush/shutdown hooks

cx-orbit-collector
  HTTP ingestion, authentication, validation, privacy controls, routing

cx-orbit-schema
  Tracking plan and versioned JSON Schemas

Do not build SDKs for languages Simiaroom does not currently use. Unsupported services can call the HTTP API directly.

6. Public SDK API

Keep a small provider-compatible core:

cx.track("therapist_profile_viewed", properties);
cx.identify("user_123", traits);
cx.page("therapist_profile", properties);
cx.flush();
cx.reset();
cx.setConsent(consent);

Production domain events should use generated typed methods:

cx.therapistProfileViewed({
  therapistId: "thr_123",
  referralId: "ref_456",
  optionRank: 2,
});

cx.bookingConfirmed({
  bookingId: "bkg_123",
  therapistId: "thr_123",
  scheduledStartUtc: "2026-08-10T14:00:00Z",
  bookingContext: "first_session",
});

The generic track API is allowed for development and approved behavioral events. State-changing P0 events require a typed, reviewed contract.

7. Canonical event envelope v1 (locked)

This section is the collector/SDK wire contract. It matches the lock in MVP-EVENT-SCOPE.md. Field names below are authoritative for storage and validation.

7.1 Required sender fields

Field Required Notes
event_id yes UUID; generate before first send; never rotate on retry
canonical_type yes Dictionary name. Wire alias event_name must equal this value if both are sent; collector persists canonical_type
event_version yes Integer; breaking changes increment version
project_id yes MVP: simiaroom
environment yes production | staging | development
source yes Stable producer ID (e.g. simiaroom_backend). Not a channel enum
source_event_id yes Unique fact ID within source
occurred_at yes ISO-8601 UTC (Z)
identity yes See §7.2
entity yes See §7.3
properties yes Object; may be empty only if the event contract allows it
actor yes { type: user|system|staff|internal, id }
sent_at no SDK send time, UTC
source_version no Producing service version
sdk no { name, version }
journey_ref no e.g. J-04
context no request_id, sender timezone, locale, …

Collector-owned fields such as received_at, authentication result, validation status, ingestion ID, and collector version must not be trusted from the sender.

Public SDK methods may keep camelCase arguments (bookingId); the envelope on the wire is snake_case as shown here. Typed methods still emit this envelope — they do not write storage.

7.2 Identity refs

"identity": {
  "anonymous_id": "string|null",
  "user_id": "string|null",
  "session_id": "string|null"
}

Rules (same as §9, applied to the envelope):

  • identity.session_id is the analytics/web session, not the therapy session
  • Authoritative P0 backend outcomes require identity.user_id
  • Browser events may omit user_id until identify / identity_link_created
  • Flat aliases anonymous_id / user_id / actor_type from older sketches are accepted only if they match the nested objects

7.3 Entity refs

"entity": { "type": "string", "id": "string" }

entity is the primary business object for the event. Related IDs (therapist_id, booking_id, payment_id, session_id, …) go in properties and must not contradict entity.

7.4 Canonical idempotency key

Locked key: project_id + source + source_event_id.

This is source + source_event_id scoped by project_id. Transport-level project_id + event_id remains for SDK retries (§8.2) and does not replace this key.

source_event_id construction:

  1. Prefer the source system's outcome/row ID (verification ID, confirmation event ID, completion record ID)
  2. If none exists and the type fires at most once per entity: {canonical_type}:{entity.id}
  3. Never use a bare entity PK as source_event_id when that entity can emit more than one canonical type (confirm vs cancel)

7.5 Envelope example — `booking_confirmed` (BKG-S03)

{
  "event_id": "0198a1c2-3b4d-7e8f-9a0b-1c2d3e4f5a6b",
  "canonical_type": "booking_confirmed",
  "event_name": "booking_confirmed",
  "event_version": 1,
  "occurred_at": "2026-08-03T10:20:30.123Z",
  "sent_at": "2026-08-03T10:20:31.004Z",
  "project_id": "simiaroom",
  "environment": "production",
  "source": "simiaroom_backend",
  "source_event_id": "bkg_evt_confirm_789",
  "source_version": "3.18.0",
  "sdk": {
    "name": "@cx-orbit/node",
    "version": "0.2.0"
  },
  "actor": {
    "type": "system",
    "id": "booking-service"
  },
  "identity": {
    "anonymous_id": null,
    "user_id": "usr_123",
    "session_id": null
  },
  "entity": {
    "type": "booking",
    "id": "bkg_789"
  },
  "journey_ref": "J-04",
  "context": {
    "request_id": "req_123",
    "timezone": "Europe/Berlin",
    "locale": "fa"
  },
  "properties": {
    "therapist_id": "thr_456",
    "payment_id": "pay_456",
    "slot_id": "slot_1001",
    "session_number": 1,
    "user_timezone": "Europe/Berlin",
    "booking_context": "first_session",
    "scheduled_start_utc": "2026-08-10T14:00:00Z"
  }
}

context.timezone / properties.user_timezone may be a diaspora zone. Storage timestamps stay UTC; Orbit UI still displays Asia/Tehran (§8.6).

7.6 Further P0 examples

`payment_succeeded` (PAY-06)

{
  "event_id": "0198a1c2-aaaa-7e8f-9a0b-1c2d3e4f5a6b",
  "canonical_type": "payment_succeeded",
  "event_name": "payment_succeeded",
  "event_version": 1,
  "occurred_at": "2026-08-03T10:20:28.000Z",
  "sent_at": "2026-08-03T10:20:29.100Z",
  "project_id": "simiaroom",
  "environment": "production",
  "source": "simiaroom_backend",
  "source_event_id": "pay_ver_456",
  "source_version": "3.18.0",
  "sdk": {
    "name": "@cx-orbit/node",
    "version": "0.2.0"
  },
  "actor": {
    "type": "system",
    "id": "payment-verification"
  },
  "identity": {
    "anonymous_id": null,
    "user_id": "usr_123",
    "session_id": null
  },
  "entity": {
    "type": "payment",
    "id": "pay_456"
  },
  "journey_ref": "J-05",
  "context": {
    "request_id": "req_122",
    "timezone": "Asia/Tehran",
    "locale": "fa"
  },
  "properties": {
    "order_id": "ord_789",
    "booking_id": "bkg_789",
    "package_id": "pkg_12",
    "amount": 2500000,
    "currency": "IRR",
    "gateway": "zarinpal",
    "failure_code": null
  }
}

`session_completed` (SES-06)

{
  "event_id": "0198a1c2-bbbb-7e8f-9a0b-1c2d3e4f5a6b",
  "canonical_type": "session_completed",
  "event_name": "session_completed",
  "event_version": 1,
  "occurred_at": "2026-08-10T14:50:00.000Z",
  "sent_at": "2026-08-10T14:50:02.400Z",
  "project_id": "simiaroom",
  "environment": "production",
  "source": "simiaroom_backend",
  "source_event_id": "ses_complete_321",
  "source_version": "3.18.0",
  "sdk": {
    "name": "@cx-orbit/node",
    "version": "0.2.0"
  },
  "actor": {
    "type": "staff",
    "id": "stf_88"
  },
  "identity": {
    "anonymous_id": null,
    "user_id": "usr_123",
    "session_id": null
  },
  "entity": {
    "type": "session",
    "id": "ses_321"
  },
  "journey_ref": "J-06",
  "context": {
    "request_id": "req_900",
    "timezone": "Asia/Tehran",
    "locale": "fa"
  },
  "properties": {
    "booking_id": "bkg_789",
    "therapist_id": "thr_456",
    "session_number": 1,
    "attendance_status": "completed",
    "actual_duration": 50
  }
}

Schema-narrative checks for these examples: required envelope fields present; canonical_type === event_name; identity.user_id set; entity matches the primary ID in properties where overlapping; occurred_at/sent_at are UTC Z; source_event_id is an outcome ID, not a reused entity PK across types.

8. Delivery semantics

8.1 Non-blocking application behavior

Analytics failures must not fail bookings, payments, sessions, or other primary workflows.

Authoritative business transactions complete in the Simiaroom system of record first. Tracking is attempted afterward, preferably using an outbox pattern where feasible.

8.2 At-least-once delivery and idempotency

The pipeline assumes at-least-once delivery. Exactly-once claims are out of scope.

Transport (SDK retry of the same envelope):

  • Generate event_id before the first send
  • Preserve the same ID through all retries
  • Deduplicate raw ingest by project_id + event_id

Canonical (one source fact → one business event):

  • Idempotency key = project_id + source + source_event_id (locked; see §7.4)
  • CDC, import, webhook, and SDK paths for the same source fact must share source + source_event_id

Do not promise global event order. Use occurred_at, sent_at, and collector received_at to reason about late and out-of-order data.

Duplicates

HTTP item codes for these rows are locked in §11.8.

Ingest Canonical result Item code
Same event_id (HTTP retry, batch resubmit of the same envelope) One raw row; one canonical fact. Safe 2xx. DUPLICATE_EVENT_ID
New event_id, same source + source_event_id, compatible payload No second canonical fact. May record diagnostic/raw according to collector policy; counts and state must not double. DUPLICATE_CANONICAL_KEY
Same canonical key, conflicting canonical_type, identity, entity, occurred_at, or required properties Quarantine IDEMPOTENCY_CONFLICT. Do not overwrite the existing canonical fact. IDEMPOTENCY_CONFLICT
Same event_id, different body than the first accept First raw row unchanged; conflict quarantined. EVENT_ID_REUSED

Retries and full or partial batch resubmits must be safe under these rules.

Late events

An event is late when received_at - occurred_at exceeds the threshold, or when a fact with a later occurred_at for the same client/entity was already applied.

Threshold Default
P0 operational (booking/payment/session) 24 hours
Import / CDC backfill 7 days

Late events are still stored. Event time for history and metrics is occurred_at, not arrival time. Derived profiles and funnels recompute from ordered history. Events older than the threshold are flagged for data quality; they are not dropped.

Out-of-order events

Arrival order is not event order.

  • Interpretation: Current Event + Current State + Accumulated History ordered by occurred_at then event_id
  • An earlier fact arriving after a later one is appended to history and triggers derived-state recompute (SYS-01 path)
  • Do not rewrite a previously accepted canonical payload; fix with a compensating event
  • Stage, session numbering, and risk must not depend on ingest order

8.3 Batching

Initial defaults:

Client Flush trigger Queue guidance
Browser 10–20 events or 2–5 seconds Bounded memory queue; optional consent-compatible persistence
Backend 20–100 events or 1–2 seconds Bounded queue; durable outbox recommended for P0 outcomes

Requests should remain below an agreed payload limit, initially 256 KB.

8.4 Retry policy

HTTP status classes, per-item retryable flags, and backoff defaults are locked in §11. Summary:

  • Retry transport failures and transient statuses: 408, 429, 500, 502, 503, 504. Honor Retry-After.
  • Do not retry permanent failures: 400, 401, 403, 404, 413, 422 — fix the request or configuration first.
  • A processed batch (200 / 207 / item-level 422) is not retried as a whole. Inspect results[]; retry only items with retryable: true (schema and idempotency conflicts are never retryable as-is).
  • Preserve event_id across retries. Bounded attempts or elapsed time. Queue overflow and dropped events must be observable.

8.5 Browser navigation

Preferred order:

  1. fetch with keepalive and small batches
  2. Explicit flush on pagehide
  3. sendBeacon only as a fallback because it does not provide reliable response handling

8.6 Timezone policy (locked)

  • Store occurred_at, sent_at, and collector received_at in UTC (Z)
  • Convert sender-local / offset timestamps to UTC at the collector; keep the sender zone in context.timezone when known
  • properties.user_timezone and slot local-time fields are scheduling metadata, not the storage clock
  • Display in CX Orbit UI and default calendar-day metrics: Asia/Tehran (IANA)

9. Identity contract

The SDK may maintain anonymous_id, user_id, and session_id, subject to consent.

Browser consent, first-party ID storage, attribution-window alignment, and send-minimization are locked in BROWSER-CONSENT-POLICY.md (T1.14). This section remains the identity-link contract (ID-01 / T2.3). @cx-orbit/browser (T2.13, P1) must not contradict that policy. Backend @cx-orbit/node is not consent-gated.

Anonymous visit: anonymous_id=anon_A, user_id=null
Valid login:     identify(user_123)
Known activity:  anonymous_id=anon_A, user_id=user_123
Logout:          clear user_id and rotate session identity as required

Rules:

  • Emit an explicit identity-link event; do not rewrite immutable raw events
  • Same-device authenticated linkage may be deterministic
  • Verified OTP/email/phone linkage may be deterministic within the approved window
  • IP-only evidence never merges identities
  • Shared devices must not merge separate accounts
  • Cross-domain identifiers must be short-lived, integrity-protected, and approved by security

10. Schema governance

Every event contract must define:

name: booking_confirmed
canonical_type: booking_confirmed
version: 1
owner: booking-team
description: A booking became operationally confirmed.
authoritative_source: backend
privacy_class: operational
required:
  - booking_id
  - user_id
  - therapist_id
  - scheduled_start_utc
properties:
  booking_context:
    type: string
    enum: [first_session, follow_up, return]

name and canonical_type are the same identifier. Envelope validation also requires event_id, source, source_event_id, occurred_at, identity, entity, and properties as specified in §7.

Contract lifecycle:

Draft → Approved → Implemented → Staging validated
→ Production monitored → Deprecated

Breaking meaning, type, or required-field changes create a new event version. Multiple supported versions may coexist during rollout.

11. Collector ingest contract (locked T1.13)

This section is the HTTP wire contract for cx-orbit-collector. T2.2 implements it; T2.2a SDKs consume it; T2.2c relies on the dedup rules. It must not contradict the T0.4 envelope lock (§7–§8.2) or ADR 0002.

Normative test plan: collector-ingest-contract-tests.md (executable in T2.2).

Invariant: no silent drop. Every event in a processed batch appears in results[] with an explicit outcome. Invalid items are reported and, when enough identity exists, quarantined. Valid items are accepted or deduplicated — never discarded without a result row.

11.1 Endpoint and request

Item Value
Method / path POST /v1/events:batch
Auth Authorization: Bearer <write_key> (server key for P0 backend events; restricted browser key for @cx-orbit/browser P1 types)
Content-Type application/json
Body { "events": [ /* envelope v1 objects, §7 */ ] }
Batch size 1–100 events
Payload limit 256 KB (request body)
Collector-owned request_id, received_at, auth result, validation, quarantine IDs — never trusted from the sender

Each HTTP request receives a new collector request_id (prefix ing_, then ULID or UUID). Retries of the same envelopes get a new request_id; dedup is by event_id / canonical key, not request_id. Sender context.request_id is correlational only.

CDC, import, and webhook paths use the same validation, quarantine, and dedup rules via the collector (ADR 0002 internal ingest path). They do not write storage directly.

11.2 HTTP status vs batch `status`

Two layers:

  1. Request-level — the collector did not process items (auth, parse, size, availability). Body is an error object (status: "error"). No results[].
  2. Batch-processed — every item was classified. Body is a batch result with status: "ok" | "partial" | "rejected" and a results[] entry per input index.
HTTP status When Client retry of this request?
200 ok Success. All items accepted, accepted_with_warnings, or duplicate No
207 partial Partial. Mix of success outcomes and rejected / quarantined No (per-item only; see §11.6)
422 rejected Reject (items). Batch parsed; zero items accepted or duplicated No
400 error Malformed JSON, wrong Content-Type, missing/invalid events array, empty batch, >100 events No
401 error Missing or invalid write key No
403 error Authenticated key not permitted for this project, environment, or event class No
404 error Unknown path No
408 error Request timed out before durable accept Yes
413 error Body exceeds 256 KB No (split the batch)
429 error Rate limited Yes — honor Retry-After
500 error Unexpected error; client must assume unknown accept Yes (idempotent retry)
502 / 503 / 504 error Unavailable / gateway / timeout Yes

207 is still 2xx (fetch ok === true). SDKs must read status and results[], not assume HTTP 200-only.

If the collector durably accepted events and then fails to respond, the client retries. Transport and canonical dedup make that retry safe (§11.8).

11.3 Success / partial / reject response shapes

Shared batch-processed fields:

Field Type Meaning
status string ok | partial | rejected
request_id string Collector ingest ID (ing_…)
received_at string UTC Z when the collector accepted the HTTP request
accepted int Newly accepted items, including accepted_with_warnings
duplicate int Transport or compatible canonical duplicates (success for the client)
rejected int Items not accepted and not counted as duplicate (includes quarantined)
quarantined int Subset of rejected that wrote an event_quarantine row
results array Exactly one entry per input event, same order as events[]

accepted + duplicate + rejected equals events.length. quarantined ≤ rejected.

Success — HTTP 200

{
  "status": "ok",
  "request_id": "ing_01j8q2k4n6p8r0s2t4v6w8x0y2",
  "received_at": "2026-08-12T16:54:00.123Z",
  "accepted": 2,
  "duplicate": 1,
  "rejected": 0,
  "quarantined": 0,
  "results": [
    {
      "index": 0,
      "event_id": "0198a1c2-3b4d-7e8f-9a0b-1c2d3e4f5a6b",
      "outcome": "accepted",
      "code": "ACCEPTED",
      "retryable": false,
      "quarantine_id": null,
      "path": null,
      "message": null
    },
    {
      "index": 1,
      "event_id": "0198a1c2-3b4d-7e8f-9a0b-1c2d3e4f5a6b",
      "outcome": "duplicate",
      "code": "DUPLICATE_EVENT_ID",
      "retryable": false,
      "quarantine_id": null,
      "path": null,
      "message": "Already ingested; canonical fact unchanged"
    },
    {
      "index": 2,
      "event_id": "0198a1c2-cccc-7e8f-9a0b-1c2d3e4f5a6b",
      "outcome": "accepted_with_warnings",
      "code": "LATE_EVENT",
      "retryable": false,
      "quarantine_id": null,
      "path": null,
      "message": "received_at - occurred_at exceeds P0 late threshold; stored"
    }
  ]
}

Duplicates are success. The client must not retry them and must not treat them as loss.

Partial — HTTP 207

{
  "status": "partial",
  "request_id": "ing_01j8q2k4n6p8r0s2t4v6w8x0y3",
  "received_at": "2026-08-12T16:54:01.000Z",
  "accepted": 18,
  "duplicate": 0,
  "rejected": 2,
  "quarantined": 2,
  "results": [
    {
      "index": 0,
      "event_id": "evt_ok_1",
      "outcome": "accepted",
      "code": "ACCEPTED",
      "retryable": false,
      "quarantine_id": null,
      "path": null,
      "message": null
    },
    {
      "index": 8,
      "event_id": "evt_9",
      "outcome": "quarantined",
      "code": "SCHEMA_VALIDATION_FAILED",
      "retryable": false,
      "quarantine_id": "q_01j8q2k4n6p8r0s2t4v6w8x0z1",
      "path": "properties.therapist_id",
      "message": "required property missing"
    }
  ]
}

The example omits middle results rows; a real response includes all indexes 0 … n-1.

Reject (all items) — HTTP 422, batch processed

{
  "status": "rejected",
  "request_id": "ing_01j8q2k4n6p8r0s2t4v6w8x0y4",
  "received_at": "2026-08-12T16:54:02.000Z",
  "accepted": 0,
  "duplicate": 0,
  "rejected": 2,
  "quarantined": 2,
  "results": [
    {
      "index": 0,
      "event_id": "evt_a",
      "outcome": "quarantined",
      "code": "MISSING_REQUIRED_FIELD",
      "retryable": false,
      "quarantine_id": "q_01j8q2k4n6p8r0s2t4v6w8x0z2",
      "path": "source_event_id",
      "message": "source_event_id is required"
    },
    {
      "index": 1,
      "event_id": null,
      "outcome": "quarantined",
      "code": "MISSING_REQUIRED_FIELD",
      "retryable": false,
      "quarantine_id": "q_01j8q2k4n6p8r0s2t4v6w8x0z3",
      "path": "event_id",
      "message": "event_id is required"
    }
  ]
}

event_id in results[] may be null when the item omitted it. index is still required so nothing is silent.

Reject (request-level) — HTTP 4xx/5xx, not processed

{
  "status": "error",
  "request_id": "ing_01j8q2k4n6p8r0s2t4v6w8x0y5",
  "received_at": "2026-08-12T16:54:03.000Z",
  "code": "AUTH_INVALID",
  "retryable": false,
  "message": "Missing or invalid write key"
}

Rate-limit example (HTTP 429, header Retry-After: 12):

{
  "status": "error",
  "request_id": "ing_01j8q2k4n6p8r0s2t4v6w8x0y6",
  "received_at": "2026-08-12T16:54:04.000Z",
  "code": "RATE_LIMITED",
  "retryable": true,
  "message": "Rate limit exceeded",
  "retry_after_seconds": 12
}

Unavailable example (HTTP 503, Retry-After optional):

{
  "status": "error",
  "request_id": "ing_01j8q2k4n6p8r0s2t4v6w8x0y7",
  "received_at": "2026-08-12T16:54:05.000Z",
  "code": "COLLECTOR_UNAVAILABLE",
  "retryable": true,
  "message": "Collector unavailable"
}

11.4 Per-item `results[]`

Field Required Notes
index yes 0-based position in the request events array
event_id yes Sender event_id, or null if missing
outcome yes See table below
code yes From §11.5
retryable yes If true, client may resubmit this item (same event_id)
quarantine_id yes Set when outcome is quarantined; otherwise null
path no JSON path of the first failing field (properties.therapist_id)
message no Human-readable; no PII, no raw payload echo
outcome Counts toward Durable effect
accepted accepted New raw row; will canonicalize
accepted_with_warnings accepted New raw row; warning recorded (late, alias normalized, optional property stripped)
duplicate duplicate No second canonical fact; first raw row unchanged
quarantined rejected and quarantined event_quarantine row; not canonical
rejected rejected only Not accepted; no quarantine row (item was not an object / could not be sanitized). Still listed in results[]

Prefer quarantined over bare rejected whenever a sanitized payload can be stored.

11.5 Transient vs permanent codes

retryable on the response is authoritative. This catalog is the default mapping T2.2 must implement.

Permanent — do not retry as-is (`retryable: false`)

Code Typical HTTP Layer Meaning
AUTH_MISSING 401 request No Authorization header
AUTH_INVALID 401 request Write key unknown or malformed Bearer
AUTH_FORBIDDEN 403 request Key valid but not allowed for this project/env
EVENT_CLASS_NOT_PERMITTED 403 request or item Browser/restricted key used for a P0 backend event class
BATCH_SCHEMA_INVALID 400 request Body is not { events: [...] } with 1–100 items
PAYLOAD_TOO_LARGE 413 request Body > 256 KB
UNSUPPORTED_MEDIA_TYPE 400 request Content-Type not JSON
MISSING_REQUIRED_FIELD 207 / 422 item Envelope field missing (event_id, canonical_type or event_name, source, source_event_id, occurred_at, identity, entity, properties, plus event_version, project_id, environment)
SCHEMA_VALIDATION_FAILED 207 / 422 item Envelope or per-type schema failed
PRIVACY_VALIDATION_FAILED 207 / 422 item Disallowed field or privacy class violation
UNKNOWN_EVENT_TYPE 207 / 422 item canonical_type not in the tracking plan
UNSUPPORTED_EVENT_VERSION 207 / 422 item event_version not accepted
IDENTITY_REQUIRED 207 / 422 item P0 backend outcome missing identity.user_id (or identity rule in §7.2 / §9)
PROJECT_MISMATCH 207 / 422 item project_id does not match the authenticated project
ENVIRONMENT_MISMATCH 207 / 422 item environment does not match the key’s environment
IDEMPOTENCY_CONFLICT 207 / 422 item Same canonical key, conflicting material payload (§11.8)
EVENT_ID_REUSED 207 / 422 item Same event_id with a different body than the first accept
CANONICAL_TYPE_MISMATCH 207 / 422 item Both canonical_type and event_name sent and they differ

Schema-invalid, privacy-invalid, and conflict items are not automatically retried. Fix the producer or payload, then replay from quarantine (§11.7) if eligible.

Transient — retry (`retryable: true`)

Code Typical HTTP Layer Meaning
RATE_LIMITED 429 request Honor Retry-After / retry_after_seconds
REQUEST_TIMEOUT 408 request Timed out before durable accept
COLLECTOR_UNAVAILABLE 503 request Shed load / not ready
BAD_GATEWAY 502 request Proxy/upstream
GATEWAY_TIMEOUT 504 request Upstream timeout
INTERNAL_ERROR 500 request Unknown whether accept succeeded; retry is safe because of dedup

Item-level transient codes are not used in MVP: once the collector is processing a batch, each item is classified synchronously into a permanent outcome. If the process fails mid-batch, the client sees a request-level 5xx and retries the whole batch; dedup prevents double-counting.

Success codes (`retryable: false` — already done)

Code outcome Meaning
ACCEPTED accepted New raw row
LATE_EVENT accepted_with_warnings Stored; flagged vs late threshold (§8.2)
ALIAS_NORMALIZED accepted_with_warnings event_name copied to canonical_type (or flat identity aliases matched)
DUPLICATE_EVENT_ID duplicate Transport hit: project_id + event_id already ingested
DUPLICATE_CANONICAL_KEY duplicate New event_id, same project_id + source + source_event_id, compatible payload

11.6 Client retry guidance

Applies to @cx-orbit/node, outbox relays, and raw HTTP callers. Browser SDK (P1) uses the same classes with a shorter budget (§8.5).

Retry the HTTP request when:

  • TCP/TLS/DNS failure, or no response
  • HTTP 408, 429, 500, 502, 503, 504
  • status: "error" and retryable: true

Do not retry the HTTP request when:

  • HTTP 400, 401, 403, 404, 413, 422
  • HTTP 200 or 207 (batch was processed)

On 207 / item 422: drop accepted, accepted_with_warnings, and duplicate items from the outbox/queue. Do not resubmit them. Resubmit only items with retryable: true (none for schema/conflict in MVP). Permanent item failures go to the Simiaroom dead-letter / alert path (ADR 0002 §5.1), not an infinite collector retry.

Full or partial batch resubmit is safe under §11.8 even if the client resends already-accepted items (for example after a 500). Prefer filtering to reduce load; correctness does not depend on filtering.

Backoff (backend / outbox defaults):

Parameter Default
Initial delay 1 second
Multiplier 2
Jitter Full jitter in [0, delay]
Cap per attempt 60 seconds
Honor Retry-After when present (takes precedence for that wait)
Stop 10 attempts or 15 minutes elapsed, whichever first (P0 outbox may keep the row pending beyond this and retry on a slower schedule; in-memory SDK queues must stop and metric-drop)

Preserve event_id generated before the first send. Never rotate IDs on retry.

Overflow: if the in-memory queue is full, increment a dropped-event metric. P0 authoritative outcomes must use the transactional outbox so they are not in that in-memory path (ADR 0002 L1).

Analytics failures must not fail booking, payment, or session workflows (§8.1).

11.7 Quarantine fields and replay

Invalid or conflicting items write event_quarantine (see also DATA-MODEL.md §10). Retention default: 90 days (T0.7). Restricted access; sanitized payload only.

Required quarantine fields

Field Type Notes
quarantine_id string Collector-generated (q_…)
request_id string Ingest request that produced this row
project_id string Authenticated project
environment string production | staging | development
received_at string UTC Z
code string Primary code from §11.5
retry_eligible boolean false for schema, privacy, missing fields, conflicts
replay_status string pending | replayed | discarded
sanitized_payload object Envelope with secrets/PII redacted (write keys never stored; tokens/emails/phone hashed or stripped per privacy class)
source string | null From payload if present
event_id string | null From payload if present
canonical_type string | null From payload if present
source_event_id string | null From payload if present
sdk_name / sdk_version string | null From sdk if present
path string | null First failing JSON path
message string No PII
collector_version string Build/version of the collector
write_key_id string Key identifier, not the secret

retry_eligible on the row matches retryable on the HTTP result. Schema-invalid and IDEMPOTENCY_CONFLICT rows are retry_eligible: false.

Replay path

Replay is an Orbit ops / data-eng action after the producer or schema is fixed. SDKs do not call replay. Automatic retry of quarantined schema errors is forbidden.

1. Inspect quarantine (redacted inspector / DQ dashboard — T2.11)
2. Fix producer, schema, or confirm the row should be discarded
3. Replay: submit the corrected envelope through the collector ingest path
   (same auth, validation, privacy, dedup as a live SDK POST)
4. On accept or compatible duplicate: set replay_status = replayed;
   store replayed_at and replay_request_id. Do not delete the quarantine row.
5. On another validation failure: leave pending; append a new quarantine row
   (do not overwrite the original)
6. Discard: replay_status = discarded with actor + reason (audit)

Replay rules:

  • Replayed envelopes should keep the original event_id and source_event_id when those were valid, so transport/canonical dedup stays stable.
  • If event_id was missing, generate one once at replay; record it on the quarantine row.
  • IDEMPOTENCY_CONFLICT: do not replay as-is. Investigate. Never overwrite the existing canonical fact. Correction is a compensating event with a new source_event_id (T0.4).
  • EVENT_ID_REUSED: do not replay the conflicting body under the same event_id.
  • Replay is not a backdoor around validation.

11.8 Dedup behavior on resubmit

Locked keys (T0.4):

Layer Key Purpose
Transport project_id + event_id SDK/outbox retries of the same envelope
Canonical project_id + source + source_event_id One business fact per source outcome

CDC, import, webhook, and SDK copies of the same source fact must share source + source_event_id (each path may mint a new transport event_id).

Material fields for “compatible” vs “conflict” (canonical key already matches):

  • canonical_type (after alias normalization)
  • identity.user_id, identity.anonymous_id
  • entity.type, entity.id
  • occurred_at (normalized UTC)
  • Required properties from the event contract (T1.12)

First accepted envelope wins. Extra optional properties on a later compatible submit are ignored. Do not rewrite the stored canonical payload.

Resubmit HTTP (typical) Item outcome / code Storage
Same event_id, same or compatible body 200 ok duplicate / DUPLICATE_EVENT_ID One raw row; one canonical fact
Same event_id, different body 207 or 422 quarantined / EVENT_ID_REUSED First raw row unchanged; conflict quarantined
New event_id, same canonical key, compatible body 200 ok duplicate / DUPLICATE_CANONICAL_KEY No second canonical fact; diagnostic/raw copy allowed
New event_id, same canonical key, conflicting material fields 207 or 422 quarantined / IDEMPOTENCY_CONFLICT Canonical fact unchanged
Partial batch replay (already-accepted items + new valid items) 200 or 207 duplicates + accepted New items ingested; old items not double-counted
Full batch replay after 5xx 200 ok mix of duplicate / accepted Safe

Counts, funnels, and operational state must not double on any row in this table.

11.9 Contract tests (implement in T2.2)

The catalog below is normative. Fixtures, assertions, and file layout: collector-ingest-contract-tests.md.

ID Case
CT-01 Valid batch → HTTP 200, status=ok, results.length == events.length
CT-02 Mixed valid + schema-invalid → HTTP 207, status=partial; valid accepted; invalid quarantined, not silently dropped
CT-03 All items invalid → HTTP 422, status=rejected, accepted=0, every index in results
CT-04 Missing/invalid auth → HTTP 401, AUTH_*, no ingest
CT-05 Malformed JSON / empty events / >100 events → HTTP 400, BATCH_SCHEMA_INVALID
CT-06 Body over 256 KB → HTTP 413, PAYLOAD_TOO_LARGE
CT-07 Missing required envelope field → item quarantined, MISSING_REQUIRED_FIELD, retryable=false
CT-08 Resubmit same event_id → DUPLICATE_EVENT_ID; one raw row
CT-09 New event_id, same canonical key, compatible payload → DUPLICATE_CANONICAL_KEY; no second canonical fact
CT-10 Same canonical key, conflicting payload → IDEMPOTENCY_CONFLICT quarantined; canonical unchanged
CT-11 Partial batch resubmit (accepted + new) → no double-count of the accepted item
CT-12 Same event_id, different body → EVENT_ID_REUSED; first raw unchanged
CT-13 429 includes Retry-After and retryable=true
CT-14 503 COLLECTOR_UNAVAILABLE, retryable=true
CT-15 Schema failure retryable=false (client must not auto-retry)
CT-16 Quarantine row has required fields (§11.7); payload sanitized
CT-17 Replay after schema fix → accepted or compatible duplicate; original replay_status=replayed
CT-18 Replay of IDEMPOTENCY_CONFLICT as-is is refused
CT-19 Late but valid P0 event → accepted_with_warnings / LATE_EVENT; stored, not dropped
CT-20 Every input index present in results (no silent drop), including items without event_id

T2.2 must also cover authenticated ingest, rate-limit enforcement, and metrics counters (accepted, rejected, quarantined, latency) per that task’s DoD. CT-13 is the contract shape for rate limits; numeric thresholds are a T2.2 implementation choice.

11.10 Skeleton vs T2.2

apps/collector returns §11.3 success and request-level error shapes (status, request_id, counts, results on 200; code / retryable on 401/400). It does not yet persist, validate per-type schemas, quarantine, or dedup. Persistence, CT-02–CT-20, and metrics land in T2.1 / T2.2 / T2.2c.

12. Security and privacy defaults

Browser-side consent gate, anonymous ID storage, and the denylist of fields/classes the browser must not send: BROWSER-CONSENT-POLICY.md (T1.14). This section remains collector/SDK security defaults. T2.13 implements the browser half; P0 server-key ingest is not consent-gated.

  • Separate restricted browser write keys from private server credentials
  • Restrict browser keys by project, origin, environment, rate, and permitted event classes
  • Never put server credentials in browser or mobile packages
  • Do not collect therapy notes, recordings, or session content
  • Do not send raw sensitive assessment or feedback answers in behavioral events
  • Use field allowlists rather than property denylists for P0 events
  • Provide setConsent, reset, and configurable memory/cookie/local-storage behavior
  • Remove or anonymize IP data unless explicitly required and approved
  • Mask or tokenize direct identifiers before general analytical storage
  • Keep sensitive ORS/SRS and free-text feedback in restricted storage and expose aggregated/minimal views
  • Audit sensitive-profile access

13. Observability and developer experience

Operator docs (T4.11): how to instrument and validate — INTEGRATION-GUIDE.md. On-call rejects / dupes / lag / outage — ops/COLLECTOR-RUNBOOK.md. This section stays the SLO and tooling contract; those guides do not replace §7 / §11.

Required MVP tools:

  • Staging project and keys
  • Live event inspector with sensitive values redacted
  • Search by event_id and request_id
  • Schema fixture validator in CI
  • Accepted/rejected/quarantined counters
  • Ingestion latency and source freshness
  • Queue depth, retry count, and dropped-event metrics
  • SDK debug logging disabled by default in production
  • Reconciliation reports against authoritative source tables

Suggested service objectives for P0 backend events — locked for pilot in DATA-QUALITY-BAR.md (T1.10). T2.11 /dq measures every row except collector availability.

Measure Pilot target T2.11 field
Collector availability ≥ 99.5% Ops SLO only (not a /dq widget)
Valid event acceptance latency p95 under 5 seconds (v0 uses max) lag ← metrics.latency_ms.max
Duplicate rate after canonicalization ≤ 0.1% duplicates ← report.duplicate_rate_pct
P0 event reconciliation completeness ≥ 99% against source records missingness ← report.completeness_pct
Invalid P0 events visible within 15 minutes rejects ← metrics.rejected / quarantined
Event lag (received_at − occurred_at) < 24 h for evented P0 lag ← metrics.event_lag_ms.max

Go-live FAIL/UNKNOWN/PASS: DATA-QUALITY-BAR §6. Do not loosen these numbers without a T1.10 reopen. Re-measure volume in T4.1 / T4.12 (volume_v2).

14. Storage decision: PostgreSQL and ClickHouse

Use PostgreSQL for operational current state:

  • Internal users and permissions
  • Cases, actions, owners, statuses, and SLAs
  • Rule/configuration versions
  • Current canonical entity state required by transactional APIs

ClickHouse is a suitable analytical event store for high-volume event history, funnels, cohorts, retention, and interactive dashboards. It is not the system of record for booking, payment, or case workflows.

MVP decision rule:

  • Start with partitioned PostgreSQL events and reporting tables when volume/concurrency are modest
  • Add ClickHouse when event volume, retention, or interactive query latency provides a measured benefit
  • If ClickHouse is selected early, prefer a managed deployment and avoid introducing self-managed Kafka without a demonstrated need
  • Keep collector and schema contracts storage-neutral so the database choice can change without reinstrumenting Simiaroom

15. MVP rollout

Stage 1 — Protocol and vertical slice

  • Approve event envelope v1 — spec locked (T0.4) in this document §7–§8.6 and MVP-EVENT-SCOPE.md
  • Approve collector HTTP contract — spec locked (T1.13) in this document §11 and collector-ingest-contract-tests.md
  • Implement collector authentication and validation
  • Implement 8–12 authoritative P0 backend events
  • Store raw + canonical events
  • Produce one verified journey/retention report

Stage 2 — Reconciliation and operations

  • Add CDC/batch reconciliation for sessions, bookings, and payments
  • Add invalid-event quarantine and data-quality reporting
  • Add identity-link events
  • Add client timeline and at-risk workflow inputs

Stage 3 — Behavioral detail

  • Introduce browser SDK
  • Add consent and anonymous session handling — policy locked in BROWSER-CONSENT-POLICY.md (T1.14); code is T2.13
  • Instrument selected P1 funnel events
  • Add navigation-safe delivery and developer inspector

Stage 4 — Scale only when measured

  • Evaluate ClickHouse from real event volume and query latency
  • Add durable streaming infrastructure only if buffering and throughput requirements justify it
  • Add additional SDK languages only for confirmed integrations

16. References