Data Model — CX Orbit MVP
Status: T1.2 implement lock 2026-08-12 (in-repo review; named eng+data sign-off residual — §13).
Physical DDL: SCHEMA.md / packages/db. This doc is the conceptual contract. SQL names match this file so Test Agent can diff.
Field class (evented / import / manual): LIVE-VS-PERIODIC-FIELDS.md (T1.9).
Porsline form catalog / join keys: PORSLINE-FORMS.md (T1.5).
Role × screen × privacy class: PRIVACY-ACCESS-MATRIX.md (T1.11).
Browser consent / ID storage / send-minimization: BROWSER-CONSENT-POLICY.md (T1.14; SDK is T2.13).
Generic naming in parentheses supports future Pedida reuse. Every P0 decision field has a locked name and type. Capacity: this is not a CRM — see §12.
0. Generic aliases (customer / provider / interaction)
Simiaroom product nouns are the SQL and serving names. Generic aliases exist for Pedida reuse and TypeScript imports. They are the same tables, not extra entities.
| Simiaroom (SQL / API) | Generic | Physical table | TypeScript alias (packages/db) |
|---|---|---|---|
| Client | Customer | client |
customer → client |
| Therapist | Provider | therapist |
provider → therapist |
| Session | Recurring interaction | session |
interaction → session |
| Booking | Interaction intent | booking |
(none — not interaction) |
| Payment | Transaction | payment |
— |
Rules:
- Docs, APIs, and fixtures use Simiaroom names (
client_id,therapist_id,session_id). - Code may import
customer/provider/interaction; they must not create parallel tables. interactionalways means session (recurring delivered work), never booking, payment, or a support ticket.- Identity is
anonymous_id↔user_idon events +identity_link. There is no genericcontact/accounttable.
1. Core entities
Key fields below are the locked P0 set. Serving metadata project_id, created_at, updated_at apply to every current-state / operational table unless noted. Enums are closed.
| Entity | Generic | Table | Key fields (locked) |
|---|---|---|---|
| Client | Customer | client |
client_id (PK), current_stage, current_therapist_id, derived profile §4 (session_count, last_session_at, days_since_last_session, next_session_booked, established_cadence_days, current_pause_flag, completion_flag, therapist_change_count, last_meaningful_event_at, risk_level, risk_reasons[], risk_rule_version) |
| Therapist | Provider | therapist |
therapist_id (PK), specialty_tags[] (omit if unknown; T3.5 depth is P1) |
| Session | Recurring interaction | session |
session_id (PK), client_id, therapist_id, booking_id, payment_id, session_number, status, scheduled_start_at, completed_at, attendance, plus §5 analytics facts |
| Booking | Interaction intent | booking |
booking_id (PK), client_id, therapist_id, payment_id, slot_id, status, scheduled_start_at, cancel_reschedule (json) |
| Payment | Transaction | payment |
payment_id (PK), client_id, booking_id, session_id, amount, currency, status, package, discount (json), refund (json) |
| Assessment | Onboarding artifact | assessment |
assessment_id (PK), client_id, method, progress, completed_at |
| FeedbackResponse | Survey response | feedback_response |
feedback_id (PK), client_id, session_id, therapist_id, form_type, form_version, scores (json, restricted), free_text (restricted), imported_category, submitted_at |
| Insight | Analysis output (P1) | insight |
§7.2 — T3.7 shipped (/insights, in-memory store) |
| Action | Operational follow-up | action |
§7.1 — T3.8 shipped |
| Event | Canonical event log | canonical_event |
canonical_event_id, raw_event_id, project_id, event_id, source, source_event_id, canonical_type, event_version, occurred_at, sent_at, received_at, actor_type / actor_id, identity, entity_type / entity_id, user_id, anonymous_id, properties, payload |
| IdentityLink | Identity resolution | identity_link |
identity_link_id, anonymous_id, user_id, method, confidence, window_start, window_end, linked_at, source_event_id, canonical_event_id |
| RawEvent | Immutable collection record | raw_event |
envelope + received_at, source, event_id, source_event_id, validation_result, write_key_id (never the secret), collector_version — §10 |
| EventQuarantine | Invalid/restricted collection record | event_quarantine |
quarantine_id, validation code / message, sanitized_payload, retry_eligible, replay_status — §10 |
| RiskSnapshot | Explainable derived decision | risk_snapshot |
risk_snapshot_id, client_id, level, reasons[], rule_version, source_event_id, computed_at |
Not core entities (deliberate): support ticket, CRM contact/company, opportunity, full case-management thread. Ops extras land as import_row on feed ops_support_ticket (T2.8) or as Orbit action rows (T3.8).
Closed enums used by P0 serving:
| Field | Values |
|---|---|
client.current_stage |
unknown | identified | assessed | matched | booked | paid | in_treatment | paused | completed | churned (JOURNEY-STAGE-MACHINE.md) |
client.risk_level / risk_snapshot.level |
healthy | needs_attention | high_risk | unknown |
session.status |
completed | no_show | cancelled |
session.continuation_status |
continued | paused | stopped | unknown |
action.source |
client_risk | manual | insight (T3.10 one-click from /insights) |
action.priority |
high | medium | low |
action.status |
open | in_progress | done | cancelled |
action.result |
continued | did_not_continue | no_response | other |
insight.severity |
high | medium | low (T3.7 constraint; column stays text) |
insight.status |
open | reviewing | accepted | dismissed | closed (default open) |
import_row.status |
pending | accepted | quarantined | failed |
2. Relationship sketch
erDiagram
CLIENT ||--o{ SESSION : has
THERAPIST ||--o{ SESSION : delivers
CLIENT ||--o{ BOOKING : makes
CLIENT ||--o{ PAYMENT : pays
CLIENT ||--o{ ASSESSMENT : completes
CLIENT ||--o{ FEEDBACK_RESPONSE : submits
CLIENT ||--o{ ACTION : targeted_by
CLIENT ||--o{ RISK_SNAPSHOT : scored_as
CLIENT ||--o{ IDENTITY_LINK : resolves
CLIENT ||--o{ CANONICAL_EVENT : emits
SESSION ||--o| FEEDBACK_RESPONSE : triggers
INSIGHT ||--o{ ACTION : spawns
RAW_EVENT ||--o| CANONICAL_EVENT : maps_to
IMPORT_BATCH ||--o{ IMPORT_ROW : contains
Timeline (T2.10) is a read of canonical_event (+ identity overlay), not a separate table. Import rows never become a ticket entity.
3. Journey stages (MVP)
Canonical machine (funnel display + client.current_stage): JOURNEY-STAGE-MACHINE.md (T1.4). Aligns with T3.2 /journey and T2.9 profile replay.
Linear spine (conversion table, first-reach — not current-stage buckets):
identified → assessed → matched → booked → paid → in_treatment
Non-linear branches (not conversion denominators): paused, returned_from_pause (resume, not a stage id), completed, churned.
Initial serving value: unknown (not a funnel column). Product refs J-01…J-12 collapse onto this machine; they are not extra funnel stages. Gap analysis (exists/partial/missing): JOURNEY-GAP-ANALYSIS.md (T1.1).
4. Derived client profile (must recompute)
T2.9 SYS-01 writes the serving row on client. T2.10 Client 360 and T3.4 risk_v1 read these fields (plus event history for signals). Rebuildable from canonical_event.
| Field | Column | Meaning | Used by |
|---|---|---|---|
current_stage |
current_stage |
Latest meaningful journey stage | T2.10, T3.2, T3.4 eligibility |
current_therapist_id |
current_therapist_id |
Active therapist | T2.10 therapist panel; T3.4 SIG-THERAPIST-CHG context |
session_count |
session_count |
Count of completed sessions only (T2.4) | T2.10, T3.3, T3.4 (≥1 to score) |
last_session_at |
last_session_at |
Last session_completed time |
T2.10 last-session; gap math |
days_since_last_session |
days_since_last_session |
UTC calendar days from last_session_at to as_of |
T2.10 gap; T3.4 SIG-GAP-* |
next_session_booked |
next_session_booked |
Open booking_confirmed with slot ≥ as_of |
T2.10 flags; T3.4 SIG-NO-BOOKING / gap copy |
established_cadence_days |
established_cadence_days |
Typical gap in UTC days (declared cadence or observed median) | T2.10 gap; T3.4 SIG-GAP-CADENCE / LONG / FALLBACK |
current_pause_flag |
current_pause_flag |
Declared pause active | T2.10 flags; T3.4 suppressions |
completion_flag |
completion_flag |
treatment_completed |
T2.10 flags; T3.4 hard suppress (not churn) |
therapist_change_count |
therapist_change_count |
Rematch / replacement pressure | T3.4 SIG-THERAPIST-CHG; rematch must not reset session_number |
last_meaningful_event_at |
last_meaningful_event_at |
Last SYS-01 trigger event | Inactivity / return |
risk_level |
risk_level |
healthy / needs_attention / high_risk / unknown (unscored) |
T2.10, T3.4 list |
risk_reasons[] |
risk_reasons |
Human-readable strings (required; empty only if healthy / unknown) | T2.10, T3.4 |
risk_rule_version |
risk_rule_version |
risk_v1 (T2.9 stub stub-v0 is replaced) |
T2.10, T3.4 |
Rules, thresholds, and suppressions: RISK-RULES-V1.md. Engine: packages/profile evaluateRisk.
Risk / continuum inputs that are not client columns (locked locations, not unspecified):
| Input | Where it lives | Notes |
|---|---|---|
expected_return_date |
Pause-event properties; T2.4 / T3.4 evaluate at as_of |
Fallback 14 UTC days if missing is a rule, not an import or a new column |
pause_declared_at |
Pause-event occurred_at |
Evaluation-only |
suggested_next_step, at_risk_list, queue_kind, active_signals[] |
RiskEvaluation (T3.4) |
Not persisted on client; latest explainability audit is risk_snapshot |
Identity refs (user_id / anonymous_id) |
Event envelope + identity_link |
Not duplicated as CRM contact fields on client |
Segment attrs (channel, issue_type) |
Event properties (T3.2 optional filters) | Null → drop out of that filter; not client columns |
Global flags that suppress naive churn:
- Declared pause should suppress At-Risk/Churn unless the pause is expired or a non-gap signal remains
- Completion must not be counted as churn
4.1 Client 360 timeline serving (T2.10)
Not an entity. Chronological read of canonical events for one client_id, sorted by occurred_at then source_event_id.
| Serving field | Source |
|---|---|
occurred_at |
canonical_event.occurred_at |
canonical_type |
canonical_event.canonical_type |
source_event_id |
canonical_event.source_event_id |
entity_type / entity_id |
canonical_event entity |
user_id |
envelope / identity overlay |
unified |
T2.3 identity: row is on the resolved user continuum (true) or un-unified history (false) |
Payments / feedback / actions on 360 are joins to payment, feedback_response, and action. Empty arrays are valid (empty.payments / empty.feedback / empty.actions). Missing feedback must not block timeline or at-risk.
5. Session analytics facts
T2.4 packages/session-continuum projects these onto session (rebuildable). Store per completed / cancelled / no-show spine row:
| Field | Rule |
|---|---|
session_number |
Per-client completed continuum 1..N by UTC completion time. Null on cancel / no-show. Rematch does not reset. Producer properties.session_number is DQ-only (source_session_number on the job fact; not a T2.1 session column). |
gap_from_previous_days |
UTC calendar days from the previous completed session. Cancels / no-shows do not break the chain. |
therapist_changed_since_previous |
True when therapist differs from previous completed session. |
feedback_linked |
True when a feedback_response joins this session. |
continuation_status |
continued / paused / stopped / unknown from pause / stop / complete / next booking. T3.3 churn uses completion_flag on the client, not stopped alone. |
status |
completed / no_show / cancelled |
attendance |
From SES-06 / SES-08 / SES-S01. Browser session_joined is non-authoritative (P1). |
CON-S01 unplanned_gap_detected is an Orbit-derived event (not a session column): cadence or pause-return breach, suppressed by active pause and by treatment_completed. Idempotency unplanned_gap:{user_id}:{gap_period_start}.
6. Feedback schema (MVP)
Form list, triggers, and join keys: PORSLINE-FORMS.md (T1.5). form_type values: baseline, session_feedback, session_feedback_deep, therapist_change_reason, cancel_reason, stop_reason, gap_reason, payment_fail_reason.
Numeric (example dimensions):
- Session satisfaction
- Comfort with therapist
- Feeling understood
- Relationship quality
- Booking / payment / support quality (where asked)
- Intent to continue
Text:
- Free-text comment
- Imported category (rule/keyword/LLM-light): matching, fit, payment, scheduling, price, support, understanding, pathway clarity
P0: scores may feed SIG-FDB-* when present; missing feedback does not fire signals and does not block Client 360.
7. Action + insight schemas
7.1 Action (P0 — T3.8)
Light operational follow-up. Human-authored; not rebuildable from the event log. API: apps/web /api/actions + /api/at-risk/assign|close. Physical table action.
| Field | Column | Values / notes |
|---|---|---|
action_id |
action_id |
PK |
project_id |
project_id |
Tenant |
title |
title |
Short label. At-risk assign defaults to T3.4 suggested_next_step |
source |
source |
client_risk | manual | insight. T3.8 P0 create is client_risk / manual. T3.10 one-click from /insights sets insight |
linked_client_id |
linked_client_id |
Required on P0 create / at-risk assign. Optional on source=insight (pattern-level row; column nullable) |
linked_insight_id |
linked_insight_id |
Required when source=insight (T3.10). One open action per insight; a second click reuses it. Do not invent a second action type |
owner |
owner |
Internal staff id. Null → status open; assign sets in_progress |
priority |
priority |
high | medium | low (default medium) |
status |
status |
open | in_progress | done | cancelled |
result |
result |
Required to close: continued | did_not_continue | no_response | other. Null while open |
reason_code_id |
in-memory T5.9 | Required to close. FK to SET-06 taxonomy (resolution domain). Retired ids stay readable; new close cannot select retired. Physical Postgres column is the same residual class as T3.8 in-memory actions |
reason_code / reason_label / reason_domain |
in-memory T5.9 | Snapshot at close so history survives retire / label edit |
reason_note |
in-memory T5.9 | Optional free text. Cannot replace the taxonomy code |
created_at / updated_at / completed_at |
same | completed_at set on done / cancelled |
At-risk loop: one open follow-up per client (open or in_progress); assign reuses it; close requires an open row. Same row appears on Client 360 actions[]. Close also requires a resolution-domain reason code from the SET-06 library (/settings/reason-codes). Pause / churn / change / cancellation codes are categorized in the same library; they are not substitutes for the T3.8 four outcomes. Retire is soft-archive (no delete). This is P2 SET-06 — it does not claim the P0 decision loop (T3.4+T3.8).
Canonical INT-01 / INT-03 / INT-09 remain evented (Orbit → collector). T3.8 does not yet emit them; that is a residual, not a missing field.
Field ingest class: LIVE-VS-PERIODIC-FIELDS.md (T1.9).
7.2 Insight (P1 — T3.7 shipped)
Semi-manual analysis output. API/UI: apps/web /insights + /api/insights. Physical table insight exists. T3.10 one-click sets action.source=insight + linked_insight_id. Not a P0 decision-loop entity. Do not treat empty insights as a Client 360 blocker. Serving store is in-memory (same residual class as T3.8 actions).
| Field | Column | Values / notes |
|---|---|---|
insight_id |
insight_id |
PK |
project_id |
project_id |
Tenant |
title |
title |
Required (or filled from a §12 template) |
severity |
severity |
Required. Constrained to high | medium | low. No extra columns |
segment |
segment |
Optional cohort / stage / therapist slice |
evidence |
evidence |
JSON facts (template id, description, source, facts, impact hint, causal strength). No separate description column |
suggested_action |
suggested_action |
Optional copy. Prefills the T3.10 action title on one-click create |
status |
status |
Default open. Workflow: open → reviewing → accepted | dismissed → closed. Reopen: closed → open |
created_at / updated_at |
same | Manual |
Templates (METRICS-AND-RISK §12): drop-off spike, churn peak, therapist change-request, recurring negative theme (T3.6 counts as evidence), unplanned gaps.
Out of P0 / later: standalone Actions page, Postgres persist of insight/action rows.
8. Import batch audit entities
Keep raw import batches auditable. T2.8 job (packages/import, apps/import-job) fills these shapes for the P0 supplemental feed ops_support_ticket. Postgres INSERT of audit rows is deferred; persist shapes already match T2.1 columns.
8.1 `import_batch`
| Field | Column | Notes |
|---|---|---|
batch_id |
batch_id |
PK |
project_id |
project_id |
Tenant |
source |
source |
Feed id, e.g. ops_support_ticket |
file_name |
file_name |
Optional |
imported_at |
imported_at |
Batch clock |
row_count |
row_count |
Rows seen |
error_count |
error_count |
Quarantined + failed |
created_at |
created_at |
Audit |
Report-only (not table columns): batch_status (succeeded | partial | failed), on_error (continue | fail), collector-plan counts. Replay is a new batch, not an in-place edit.
8.2 `import_row`
| Field | Column | Notes |
|---|---|---|
import_row_id |
import_row_id |
PK |
batch_id |
batch_id |
FK → import_batch |
raw_json |
raw_json |
Original CSV row |
mapped_entity_refs |
mapped_entity_refs |
Optional { user_id, ticket_id, … } |
status |
status |
pending | accepted | quarantined | failed |
error_reason |
error_reason |
Required when quarantined / failed (e.g. MISSING_REQUIRED_FIELD:user_id) |
created_at |
created_at |
Audit |
Failed rows are quarantined with error_reason. If a row maps to an event (resolved ticket with case fields → internal_case_resolved), the job POSTs the collector — it does not write canonical_event. Operator contract: ops/CSV-IMPORT.md.
Open / in-progress tickets stay audit-only. They are not a ticket entity and not an action unless staff create one in Orbit.
9. MVP storage principles
- Append-only event log + mutable derived profile
- Two idempotency keys (T0.4): transport
project_id + event_id(SDK retry); canonicalproject_id + source + source_event_id(business fact). First accepted envelope wins; do not overwrite — correct with a compensating event - Never overwrite historical session/payment facts silently — correct via compensating events
- Version risk rules; store version on each risk snapshot and on
client.risk_rule_version - Preserve
occurred_at, SDKsent_at, and collectorreceived_atfor late-event analysis - Separate raw, canonical, derived, and serving models; derived state is rebuildable from history
- PostgreSQL owns operational state, actions, permissions, and configuration
- Use an analytical store for event-heavy funnels/cohorts; adopt ClickHouse only from measured volume/latency needs
- Invalid events go to restricted quarantine and never silently enter canonical metrics. Pilot visibility and go-live rules:
DATA-QUALITY-BAR.md(T1.10). - Direct identifiers and sensitive ORS/SRS/free-text data require restricted storage and minimal analytical exposure. Role × screen grants:
PRIVACY-ACCESS-MATRIX.md(T1.11).restrictedandfinancialare deny for support except operational payment status; enforcement is T5.6. - Collector is the only event write ingress; SDKs never write storage. Import jobs and Actions APIs must not INSERT
canonical_event
10. Event delivery support tables
event_ingestion_request—request_id,project_id,source,received_at,accepted_count/duplicate_count/rejected_count/quarantined_count,write_key_id,collector_versionraw_event— immutable accepted envelope and collector metadata; unique(project_id, event_id)event_quarantine— sanitized invalid payload, error codes, retry eligibility, replay audit; field catalog and replay path inSDK-AND-DATA-INGESTION.md§11.7event_deduplication— implemented asevent_dedup_transport(project_id + event_id) +event_dedup_canonical(project_id + source + source_event_id)source_reconciliation_run—source,window_start/window_end,expected_count/observed_count/mismatch_count,mismatches,ran_atrisk_snapshot— append-only explainable risk decisions by rule version (T3.4). New row on recompute; historical rows stay
If the authoritative Simiaroom backend cannot guarantee reliable post-commit publication, use the transactional outbox in the source system (ops/OUTBOX-RELAY.md, T2.14) rather than making the business transaction depend on the CX Orbit collector.
Physical PostgreSQL tables, T0.4 unique keys, and the rebuild boundary (what can be recomputed from raw_event) are in SCHEMA.md (T2.1).
11. Rebuild boundary (T2.1)
- Not rebuildable:
raw_event,event_ingestion_request,event_quarantine, humanaction/insight, import audit, reconciliation run history. - Rebuildable from accepted raw:
canonical_event, dedup keys, current-stateclient/session/booking/payment/assessment/therapist,identity_link(T2.3 job), serving aggregates. - Risk snapshots: append-only audit; a new snapshot can be recomputed, historical rows are not rewritten.
- Write ingress: collector only for event tables; SDKs never write storage.
Full table: SCHEMA.md.
12. Capacity — what this model is not
P0 decision loop needs timeline, sessions, risk, and actions. Do not grow CRM objects:
| Out of model | Where the need goes instead |
|---|---|
| Ticket / case thread / SLA calendar | import_row (ops_support_ticket) and/or light action |
| Contact, company, lead, opportunity | client + identity_link only |
| Staff user directory / Auth.js sessions | T5.5; not a T1.2 entity |
| Clinical notes, ORS/SRS warehouses | Restricted feedback_response / event properties; not extra tables |
| Insight description, tags, subscribers | insight.evidence jsonb; T3.7 must not add CRM columns for P0 |
Duplicate generic tables (customer, provider, interaction as SQL) |
TypeScript aliases only (§0) |
13. In-repo review (T1.2)
This is the eng+data review evidence, not a fabricated stakeholder sign-off.
| Item | Record |
|---|---|
| Date | 2026-08-12 |
| Kind | Implement-agent attestation against shipped P0 + T2.1 schema |
| Named eng lead | Residual — no named sign-off in repo (same class as T1.3). Do not invent a name |
| Named data lead | Residual — same |
Cross-checked in this pass (pointers, not a Test Agent result):
| P0 slice | Pointer | Model coverage |
|---|---|---|
| T2.10 timeline / 360 | apps/web/src/lib/client360/types.ts, docs/workflows/reports/T2.10-approval.md |
§4 + §4.1 serving fields |
| T2.4 sessions | packages/session-continuum/src/types.ts, src/constants.ts |
§5 session_number / gaps / rematch / CON-S01 |
| T3.4 risk | packages/profile/src/types.ts, RISK-RULES-V1.md, risk_snapshot |
§4 + §1 RiskSnapshot |
| T3.8 actions | apps/web/src/lib/actions/types.ts, SCHEMA action enums |
§7.1 |
| T5.9 reason codes | apps/web/src/lib/reason-codes/types.ts, /settings/reason-codes |
§7.1 reason_code_* (in-memory) |
| T2.8 import | packages/import/src/persist.ts, src/types.ts |
§8 |
| T2.1 physical | packages/db/src/schema/{entities,operational,canonical,ingest,enums}.ts |
§1 tables + aliases customer / provider / interaction |
| T0.4 keys | ADR 0002 / T0.4 approval residual | §9 principle 2 + §10 event_dedup_* |
Approve Agent may still require named humans. This pack must not be treated as that sign-off.