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:
- Canonical schemas and collection API
- Backend SDK for authoritative business events
- CDC/batch reconciliation for existing operational records
- Browser SDK for behavioral funnel detail
- 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_idis the analytics/web session, not the therapy session- Authoritative P0 backend outcomes require
identity.user_id - Browser events may omit
user_iduntil identify /identity_link_created - Flat aliases
anonymous_id/user_id/actor_typefrom 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:
- Prefer the source system's outcome/row ID (verification ID, confirmation event ID, completion record ID)
- If none exists and the type fires at most once per entity:
{canonical_type}:{entity.id} - Never use a bare entity PK as
source_event_idwhen 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_idbefore 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 Historyordered byoccurred_atthenevent_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. HonorRetry-After. - Do not retry permanent failures:
400,401,403,404,413,422— fix the request or configuration first. - A processed batch (
200/207/ item-level422) is not retried as a whole. Inspectresults[]; retry only items withretryable: true(schema and idempotency conflicts are never retryable as-is). - Preserve
event_idacross retries. Bounded attempts or elapsed time. Queue overflow and dropped events must be observable.
8.5 Browser navigation
Preferred order:
fetchwithkeepaliveand small batches- Explicit flush on
pagehide sendBeacononly as a fallback because it does not provide reliable response handling
8.6 Timezone policy (locked)
- Store
occurred_at,sent_at, and collectorreceived_atin UTC (Z) - Convert sender-local / offset timestamps to UTC at the collector; keep the sender zone in
context.timezonewhen known properties.user_timezoneand 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:
- Request-level — the collector did not process items (auth, parse, size, availability). Body is an error object (
status: "error"). Noresults[]. - Batch-processed — every item was classified. Body is a batch result with
status: "ok" | "partial" | "rejected"and aresults[]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"andretryable: true
Do not retry the HTTP request when:
- HTTP
400,401,403,404,413,422 - HTTP
200or207(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_idandsource_event_idwhen those were valid, so transport/canonical dedup stays stable. - If
event_idwas 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 newsource_event_id(T0.4).EVENT_ID_REUSED: do not replay the conflicting body under the sameevent_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_identity.type,entity.idoccurred_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_idandrequest_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
- Segment data governance
- RudderStack Event Stream
- Snowplow tracker delivery
- Amplitude Browser SDK
- OpenTelemetry Collector
- OpenTelemetry protocol delivery semantics
- ClickHouse real-time analytics architecture
- Collector ingest contract tests (T1.13 / T2.2)
- ADR 0002 — collector, SDK, outbox
- Browser consent & minimization (T1.14)
- Data quality bar (T1.10)