05 — Data Model & Event Spec
Audience: Data Lead, Engineering Use: Source of truth for Supabase schema feeding the client dashboard (§04) and SROI rollup (§07).
Tables (logical model)
Naming: snake_case. Every table has
id uuid pk default gen_random_uuid(),created_at timestamptz default now(),updated_at timestamptz.
Core
client_orgs
name text not nullaccount_mgr_id uuid references staff(id)sector textheadcount int
cohorts
client_org_id uuid references client_orgs(id) on delete cascadename text not null(e.g. "Q1 2026 — Frontline Managers")curriculum_key text not null(e.g.sprint-1-manager-mental-health)start_date date not nullstate text not null check (state in ('pre-engagement','live','wrapping','archived'))expected_participants int
participants
cohort_id uuid references cohorts(id) on delete cascadepseudonym text not null(NEVER store real name; PII lives in client HR system)external_ref text(opaque ID from client HRIS for join, hashed)nudge_channel text check (nudge_channel in ('slack','teams','email','sms','calendar'))coach_id uuid references coaches(id)
coaches
pseudonym text not nulllived_experience_tags text[]sector_tags text[]capacity_per_week int
Delivery events
workshop_attendance
cohort_id uuidparticipant_id uuidworkshop_date dateattended boolean not null
coaching_sessions
participant_id uuidcoach_id uuidscheduled_at timestamptzdelivered boolean not null default falsedelivered_at timestamptzduration_min intnote_id uuid references coaching_notes(id)
coaching_notes (RLS: coach-only + admin)
participant_id uuidcoach_id uuidnote_body text(encrypted at rest)share_anonymised boolean default falseparticipant_consent boolean default falseanonymised_quote text(curator-edited, ≤30 words)
nudges_sent
participant_id uuidsent_at timestamptzchannel texttemplate_key text(e.g.s1-nudge-04-conversation-prompt)engaged_at timestamptz(null if no open/click)
Measurement
pulse_responses
participant_id uuidpulse_key text(e.g.s1-day30,s4-confidence-quarterly)submitted_at timestamptzresponses jsonb(per-question answers; question keys from pulse instrument)anonymised boolean default true
sroi_rollups (read-only output)
cohort_id uuidsnapshot_date daterow1_ratio numeric(e.g. 4.20 → £1 returns £4.20)top_drivers text[]calculation_jsonb jsonb(full working for audit)
Access control
client_org_members — maps client users to orgs for dashboard auth.
client_org_id uuidauth_user_id uuid references auth.users(id)role text check (role in ('hr_sponsor','exec_sponsor'))
user_roles — staff roles only (per project memory + user-roles knowledge).
user_id uuid references auth.users(id)role app_role(enum:admin,staff,coach)
has_role(user_id, role) SECURITY DEFINER function used in every staff-scoped RLS policy.
Event spec (write paths)
| Event | Triggered by | Writes to |
|---|---|---|
workshop.attended | Facilitator marks attendance in coach tooling post-workshop | workshop_attendance |
coaching.scheduled | Participant books via portal | coaching_sessions (delivered=false) |
coaching.delivered | Coach marks complete + adds note | coaching_sessions (delivered=true) + coaching_notes |
nudge.sent | Automated job (cron) | nudges_sent |
nudge.engaged | Webhook from email/SMS provider | nudges_sent.engaged_at |
pulse.submitted | Participant submits pulse form | pulse_responses |
sroi.recalculated | Weekly cron from W6 onward | sroi_rollups |
RLS principles
- Coaches see only their own participants' rows.
- HR sponsors see only their org's cohorts, and only aggregated views (no row-level access to
coaching_notes,pulse_responsesindividual rows — only via aggregated views withn ≥ 5filter). - Exec sponsors see only Row 1 + Row 2 aggregates.
- Staff (
has_role(auth.uid(), 'staff')) bypass for ops. - Admin (
has_role(auth.uid(), 'admin')) full read; writes still go through server functions.
Aggregated views (the only way client roles read sensitive tables)
v_cohort_engagement— counts and percentages from attendance/sessions/nudges, joined by cohort.v_cohort_sentiment_deltas— mean confidence deltas, with row-level filter rejecting cohorts where pulse n<5.v_cohort_stories—coaching_notesfiltered toshare_anonymised AND participant_consentrows only, exposinganonymised_quoteonly.
Anonymisation enforcement
Three layers, all required:
- Schema: no PII columns on
participantsorpulse_responses. - RLS: client roles cannot query base tables — only views.
- View logic: small-n suppression baked into view SQL with
having count(*) >= 5.
If any layer is removed, the next layer still holds. This is non-negotiable.
Migration plan (when build starts)
Phase the migration:
- Core (
client_orgs,cohorts,participants,coaches,user_roles,client_org_members) + RLS. - Delivery events tables + write paths from coach tooling.
- Measurement tables + pulse form server-fn writes.
- Aggregated views + dashboard server-fn reads.
- SROI rollup job.
Do not start with measurement before core RLS is in place.
