Data Model Specification

18 min read•docs/DATA-MODEL.md•

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.
  • interaction always means session (recurring delivered work), never booking, payment, or a support ticket.
  • Identity is anonymous_id ↔ user_id on events + identity_link. There is no generic contact / account table.

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

  1. Append-only event log + mutable derived profile
  2. Two idempotency keys (T0.4): transport project_id + event_id (SDK retry); canonical project_id + source + source_event_id (business fact). First accepted envelope wins; do not overwrite — correct with a compensating event
  3. Never overwrite historical session/payment facts silently — correct via compensating events
  4. Version risk rules; store version on each risk snapshot and on client.risk_rule_version
  5. Preserve occurred_at, SDK sent_at, and collector received_at for late-event analysis
  6. Separate raw, canonical, derived, and serving models; derived state is rebuildable from history
  7. PostgreSQL owns operational state, actions, permissions, and configuration
  8. Use an analytical store for event-heavy funnels/cohorts; adopt ClickHouse only from measured volume/latency needs
  9. Invalid events go to restricted quarantine and never silently enter canonical metrics. Pilot visibility and go-live rules: DATA-QUALITY-BAR.md (T1.10).
  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). restricted and financial are deny for support except operational payment status; enforcement is T5.6.
  11. 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_version
  • raw_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 in SDK-AND-DATA-INGESTION.md §11.7
  • event_deduplication — implemented as event_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_at
  • risk_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, human action / insight, import audit, reconciliation run history.
  • Rebuildable from accepted raw: canonical_event, dedup keys, current-state client / 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.