Objective & scope
Give Jes one place to see a graduating 1:1 client's outcome picture, review it, and close the file — replacing three manual habits with one system action.
In scope: a staff-facing Graduation Report section on the existing client detail screen (practitioner console); a single "Graduate" action that approves the report and closes the client file in one step; a client-facing page that appears only after approval, showing only the sections that have real data.
Out of scope: PDF export, print layout, a shareable public link, an all-clients graduation dashboard, any AI-generated narrative, any new symptom-trend visualization beyond a two-point intake-vs-graduation comparison. Each is addressed with a reason in Deliberately Left Out.
Capture-first requirements
This spec works backward from the report. Below is everything that has to be true — schema applied, a button that exists, a habit someone remembers — before a graduating client's report has real content instead of honest placeholders. Ordered by how badly its absence breaks the feature.
RESOLVED — measured against production 2026-09-08. Staff reads are fine. No action needed.
This item originally read as a go/no-go alarm: that the staff-read policy on coaching_clients still checked the retired 'admin'/'onboarding'/'coach' literals, and that the role-ladder remap had therefore locked every staff member out of the table the whole practitioner console sits on. That was drawn from the migration files, not the database, and it is wrong.
Queried directly against project sihbryplxdiriqkaisul:
coaching_clients: staff readhasqual = is_staff().is_staff()checks onlystaff_membersmembership plusis_active— it references no role literal at all, so the remap could not have broken it.coaching_clients: staff updatealready uses the new vocabulary (owner/sales/practitioner).- All four staff rows are on the new labels and active: Shawn
owner, Jesowner, Mariapractitioner, Izzysales. The role table below is confirmed correct. - The
staff_roleenum currently carries all six labels — the ladder migration added the new ones rather than replacing the old ones, so both vocabularies coexist and nothing referencing either can fail on a missing label.
Standing correction for anything downstream of this spec: HMM migration files misstate production state in both directions, and two repos (her-mood-mentor, hmm-course-portal) write migrations against the same Supabase project. Verify claims about what is live with pg_policies.qual, pg_get_functiondef(), and pg_enum. Do not trust a file header. Items 2 through 7 below have not been re-verified this way and should be, before Reybold treats any of them as true.
Apply intake_forms_repeatable_and_discretionary.sql.
This migration adds the Reflection form — the only place a closing 7-pillar quality-of-life score exists anywhere in this system. It reuses the exact same field ids as the live Onboarding Questionnaire (nutrition_satisfaction … resiliency_satisfaction) on purpose, so a before/after comparison needs no mapping table, just the same seven keys read from two different submissions. Without this migration applied, the QOL comparison this order asks for cannot exist — not "will be incomplete," cannot exist at all.
Confirm video_sessions_phase1c_role_ladder_hotfix.sql and video_sessions_phase2.sql are applied (or apply them).
coaching_sessions is the only table in this schema that reliably links a real 1:1 call to a specific client (see session-count source decision below). Two separate blockers sit in front of it: phase1's staff-read policies and create_coaching_session() still check the retired 'admin'/'coach' literals (phase1c fixes this); and phase1's consent gate requires a signature on a counsel-reviewed "Video Session Recording Consent" agreement that was shipped as placeholder text and is_active = false, which means zero sessions can be created for any client until phase2 repoints that gate onto the Client Services Agreement every client already signs at onboarding. Until both are confirmed live, "sessions had" will read zero for every client, regardless of how many calls actually happened.
Add one button: "Assign Reflection form."
assign_form_to_client(p_client_id, p_template_slug) already exists (built in the same migration as item 2) and already knows how to assign a repeatable form. Nothing in the practitioner console calls it. Without a button, the RPC existing changes nothing — Jes still has no way to actually get the Reflection form in front of a graduating client.
Add a staff-read RLS policy on course_lesson_progress.
Confirmed by reading the current policy: it is "course_lesson_progress: owner all" using (user_id = auth.uid()) — the client only. There is no staff-read path onto this table at all. Coursework completion cannot be shown to anyone on staff, for any client, until one is added. See Data model changes for the exact policy.
Operational, not technical: someone has to remember to ask the client to retake her Symptom & Systems Assessment.
assessment_results is a pure self-service table — "assessment_results: taker saves own" only lets the client herself insert a row; staff cannot fill it in on her behalf, and nothing assigns or reminds her the way client_forms does. Production holds exactly one row across all clients today. Nothing about this report can force a second sitting to exist. Cheapest real fix: add one sentence to the Reflection form's own intro copy (program_reflection_intro) pointing her back to the assessment, so the ask travels with the form she's already being asked to fill out instead of depending on a second thing staff have to remember. This is a content edit to a form template, not a schema or code change.
Confirm the client actually has course access.
course_lesson_progress is only populated for a user whose profiles.subscription_tier is 'courses' or 'coaching' (has_courses_access()). If a test client's grant path didn't include the course, her completion will honestly read "not enrolled" — worth catching as a real onboarding gap before graduation, not something this report should paper over.
Role vocabulary — pick one, and it's this one
Two vocabularies coexist in the codebase today: the retired admin / coach / onboarding labels, and the ranked ladder owner (3) / practitioner (2) / sales (1) / none (0) that the 2026-09-02 role-ladder migration introduced and that every file written since uses exclusively. This spec is written against the ladder, and every new object it defines uses current_staff_rank()/current_staff_role() with the new labels only. No new SQL in this feature references 'admin', 'coach', or 'onboarding', full stop.
| Person | Current role | Rank | Sees on this report |
|---|---|---|---|
| Shawn | owner | 3 | Every client, unconditionally. |
| Jes | owner | 3 | Every client, unconditionally. |
| Maria | practitioner | 2 | Only clients where coaching_clients.assigned_coach = auth.uid(). |
| Izzy | sales | 1 | Nothing on this feature. No access to the Graduation Report screen or the client-facing page at all — matches her exclusion from every other clinical-detail surface in this schema. |
Access rule for everything new in this spec: current_staff_rank() >= 2, further scoped to assigned_coach = auth.uid() for rank-2 (practitioner) callers, unconditional for rank-3 (owner). This is the exact shape already used by client_practitioner_notes and (post-hotfix) coaching_sessions — nothing new is invented here.
Data sources per field
Every field the report shows, where it actually comes from, and its honest status. LIVE exists today and works. NEEDS-MIGRATION / NEEDS-BUILD the schema or a small piece of UI doesn't exist yet. CAPTURE GAP the schema exists but nothing makes the data get entered.
| Report field | Source | Status |
|---|---|---|
| Client's name | auth.users.raw_user_meta_data->>'full_name', fallback email — same resolution staff_list_active_clients() already uses | LIVE |
| Assigned practitioner | coaching_clients.assigned_coach → staff_members.display_name | LIVE |
| Program start date | coaching_clients.started_at | LIVE |
| Program end / graduation date | coaching_clients.completed_at, stamped by graduate_client() if not already set | LIVE |
| Sessions had | coaching_sessions where client_id = X and status = 'ended', count(*) | NEEDS-VERIFICATION — schema built, pipeline blocked, see items 1–3 |
| Symptom burden, intake | assessment_results, earliest row by taken_at for the user, average pct across scores | CAPTURE GAP — one row exists in all of production |
| Symptom burden, graduation | assessment_results, latest row by taken_at | CAPTURE GAP — same table, needs a second sitting per client |
| Quality of life, 7-pillar, start | client_forms (onboarding-questionnaire, submitted) .answers.{pillar}_satisfaction ×7 | LIVE |
| Quality of life, 7-pillar, end | client_forms (reflection, submitted, highest sequence_number) .answers.{pillar}_satisfaction ×7 | NEEDS-MIGRATION — item 2 + item 4 |
| Client's stated goal at onboarding | client_forms (onboarding-questionnaire) .answers.goal_statement | LIVE |
| Reflection against that goal | client_forms (reflection) .answers: program_start_symptoms, qol_improvements, most_valuable, most_challenging, most_surprising, message_to_past_self, anything_else_reflection — rendered verbatim, her own words | NEEDS-MIGRATION — item 2 + item 4 |
| Bonus: program satisfaction (1–10) | reflection.answers.program_satisfaction — not requested, comes free once the form exists | NEEDS-MIGRATION |
| Bonus: confidence managing symptoms going forward (1–4) | reflection.answers.symptom_management_confidence | NEEDS-MIGRATION |
| Coursework completion | course_lesson_progress joined to course_lessons/course_modules (kind='main', published=true): count with completed_at is not null / total | NEEDS-BUILD — item 5 |
| Practitioner's own coaching goal (internal) | client_practitioner_notes.notes.goal — staff-only, never on the client-facing page | LIVE |
| Sign-off record | coaching_clients.graduation_report_approved_at / _by — new columns, this spec | NEEDS-BUILD |
Explicitly not sourced — no honest data exists
The order is specific that these must not be invented. None have a live path into this database:
- Lab marker before/after values. Marker data never leaves the local Mac Mini pipeline; only a rendered PDF crosses the boundary. Not included.
- Food & Mood Journal insights. The app writes journal entries to device
AsyncStorageonly — never synced to Supabase. Matches the existing "awaiting the Food and Mood Journal" placeholder already shipped inpractitioner.js. - Structured action steps. No table for this exists anywhere in either repo.
- Per-symptom duration + severity trend.
assessment_resultsgives at most two point-in-time snapshots (intake, graduation), never a time series. This report shows a two-point comparison, not a trend line, and does not manufacture one.
Data model changes
Deliberately small. No new tables. No RLS widening — every existing read policy on coaching_clients, client_forms, assessment_results, and coaching_sessions already exposes what this feature needs to whoever already had it; this spec only assembles it into one screen.
1. Two columns on coaching_clients
alter table public.coaching_clients
add column if not exists graduation_report_approved_at timestamptz,
add column if not exists graduation_report_approved_by uuid
references public.staff_members(user_id);
No RLS change needed. "coaching_clients: client reads own" already exposes every column on her own row; the staff read policy (once item 1 above is confirmed correct) already exposes every column to rank-2+ staff.
2. One RPC: graduate_client(p_client_id uuid)
Does both jobs Jes currently does by hand — closes the file and approves the report — in one atomic call:
- Rejects if caller's
current_staff_rank() < 2, or if rank = 2 (practitioner) andassigned_coach <> auth.uid(). - Rejects with a clear message if the client's current
statusis'prospect'or'withdrawn'— a mis-click cannot graduate someone who never got there. - Sets
status = 'completed',completed_at = coalesce(completed_at, now())— never overwrites an existing completion date. - Sets
graduation_report_approved_at = now(),graduation_report_approved_by = auth.uid()— always overwrites, since re-approval after a correction is the intended second-call behavior. security definer,revoke all ... grant execute to authenticated— the real authorization boundary is the function body's rank check, same pattern as every other write RPC in this schema (create_coaching_session,assign_form_to_client).
3. One new RLS policy: course_lesson_progress: staff read
create policy "course_lesson_progress: staff read"
on public.course_lesson_progress for select
to authenticated
using (
current_staff_role() = 'owner'
or (
current_staff_role() = 'practitioner'
and user_id in (
select cc.user_id from public.coaching_clients cc
where cc.assigned_coach = auth.uid()
)
)
);
The only genuinely new read surface this spec introduces. It exposes completion percentages and timestamps only — no lesson content, no course answers, nothing beyond what "how far along is she" requires.
View states
Staff — Graduation Report section (client detail screen)
Visible when current_staff_rank() >= 2 for a client whose status is 'active' or 'completed'. Not shown for 'prospect' or 'withdrawn'. Five sections render independently — one section running short never blocks the other four.
Program & Sessions
Symptom Burden
Quality of Life, 7 pillars
Goal & Reflection
Coursework
| Section | 0 / insufficient data | Full data |
|---|---|---|
| Symptom burden | 0 sittings: "No symptom assessment on file yet." 1 sitting: shown, labeled "nothing to compare it against yet" — no delta computed. | Intake vs. latest, with directional delta. |
| Quality of life | No onboarding answers: "No starting quality-of-life answers on file." No reflection: start-only, "No closing reflection on file yet." | Full 7-pillar comparison with a directional arrow per pillar. |
| Sessions | 0 ended sessions: "No completed sessions on file" — shown plainly, not alarmed, but visible so Jes notices it. | Count of status = 'ended' rows. |
| Coursework | Never enrolled: "Not enrolled in the course" (distinct from 0 of N completed). | "X of Y core lessons complete." |
| Goal & reflection | Individual reflection fields missing show "(not answered)," same convention as every other field in the existing practitioner console. | Goal plus each reflection answer, verbatim. |
Client-facing — "Your Graduation Report"
A single new page in the portal. Before approval, it does not exist for her — no locked state, no "coming soon," nothing to see, because she has no reason to expect it yet. After graduation_report_approved_at is set, the page renders warmly and plainly, showing only the sections that have complete data at the moment she opens it. An incomplete section is omitted entirely — never shown broken, never shown with a red placeholder tag. client_practitioner_notes is never read by this page under any state.
You did this, Sarah.
Your Journey
May 12 – August 3, 2026 · 7 sessions together
How Far You've Come
Sleep satisfaction: 3 → 8. Nutrition: 5 → 8. (Only pillars with both a start and end answer are shown.)
In Your Own Words
"I finally feel like myself again before my period."
Sign-off flow
- Near the end of the program, Jes opens the client's Graduation Report section and reviews what's there.
- If the Reflection form hasn't been assigned yet, she clicks "Assign Reflection form" (item 4). She separately prompts the client, by message or on a call, to also retake her Symptom & Systems Assessment.
- The client completes the Reflection form — and, ideally, retakes the assessment — herself, in the portal.
- Jes returns to the section once submitted and reviews the full picture with the client on the final call.
- Jes clicks "Graduate [Name]." This calls
graduate_client(client_id), which sets status, stamps completion (first time only), and stamps approval — one transaction, one click. - The client's portal now shows "Your Graduation Report," rendering whatever has complete data at the moment she opens it.
Sign-off is never hard-blocked by missing data. If a section is in its "not enough data yet" state, clicking "Graduate" shows one inline line — "Some sections are still empty — graduate anyway?" — not a modal, not a separate review page. Confirming proceeds.
Edge cases
| Case | Behavior |
|---|---|
| Client withdraws mid-program | Graduation Report section is not shown at all. graduate_client() raises an exception for status in ('prospect','withdrawn'). |
| Client submits Reflection more than once (repeatable form) | Use the row with the highest sequence_number that is status = 'submitted' — same "latest wins" convention as assessment_results. |
| Practitioner reassignment mid-program | Access scoping always uses the current assigned_coach. No history, no retroactive reattribution of past sessions/notes — this spec doesn't add one. |
| A practitioner opens a client not assigned to her | Denied by the existing RLS scoping — she doesn't see the client at all, same as every other practitioner-scoped table today. |
| Client never granted course access | Coursework shows "Not enrolled," visually distinct from "0 of 43 complete" — the two mean different things and must not look the same. |
| Two staff click "Graduate" at nearly the same time | Harmless: graduate_client() only updates columns on an existing row (no new row is ever created), so this is a last-write-wins double-stamp, not a race that can error or duplicate anything. |
Acceptance criteria
- The
coaching_clients: staff readpolicy is verified against the live database to use the current role vocabulary (owner/practitioner/sales) before this feature ships — confirmed by querying as Maria (practitioner) and as Izzy (sales), not by reading the policy text. intake_forms_repeatable_and_discretionary.sqlis applied to production; a real client can submit the Reflection form, and its answers are reachable atclient_forms.answersunder the same field ids as the Onboarding Questionnaire.video_sessions_phase1c_role_ladder_hotfix.sqlandvideo_sessions_phase2.sqlare applied (or confirmed already live); a rank-2+ staff member can create acoaching_sessionsrow for an active client who has a signed Client Services Agreement.- A new "Assign Reflection form" control in the practitioner console calls
assign_form_to_client(client_id, 'reflection')and a newclient_formsrow appears with status'assigned'. - The new
course_lesson_progress: staff readpolicy exists; a practitioner-rank query for an assigned client'suser_idreturns rows; the same query for a non-assigned client'suser_idreturns zero rows. coaching_clientsgainsgraduation_report_approved_at/graduation_report_approved_by, both null by default on every existing row.graduate_client(p_client_id)exists, is callable only at rank ≥ 2 (scoped toassigned_coachat rank 2), sets status/completion/approval correctly on a first call, and is idempotent on a second call — does not clobber an existingcompleted_at, does refreshapproved_at/approved_by.- Calling
graduate_client()against a'prospect'or'withdrawn'client raises an exception and changes no row. - The staff Graduation Report section renders all five sections independently; a section with insufficient data shows its own labeled placeholder without blocking the other four from rendering.
- The symptom burden section correctly handles 0, 1, and 2+
assessment_resultsrows per the three states in View States, and never computes a delta from a single sitting. - The quality-of-life section maps onboarding and reflection answers by their seven shared field ids and shows a directional indicator per pillar, only where both a start and end value exist.
- The client-facing "Your Graduation Report" page is unreachable for a client whose
graduation_report_approved_atis null — verified by a direct navigation attempt as that client, not by hiding a nav link. - Once approved, the client-facing page shows only sections with complete data at render time; an incomplete section is fully absent, never shown with a placeholder or partial state.
- The client-facing page never renders any value sourced from
client_practitioner_notes, under any state. - No SQL added by this feature references the role literals
'admin','coach', or'onboarding'.
Deliberately left out
- PDF / print / share-link export. A real future need, but a materially different rendering surface (print CSS, a public unauthenticated token, or an attachment pipeline) that nothing in the order asked for. Cut; she reads it in her own portal, same access model as everything else there.
- Snapshotting the report at the moment of approval. Considered, matching the
protocol_publish_eventsaudit-table pattern already used elsewhere. Cut: this is a one-time celebratory recap, not a legal or clinical record — the cost of a new audit table and versioning logic isn't earned by the actual risk of a later correction quietly improving accuracy on something nobody re-opens. Revisit if this ever needs to become an exportable or legally-relevant artifact. - Hard-blocking sign-off on missing data. Considered a validation gate that refuses "Graduate" until every section is complete. Cut: real clients will legitimately graduate with a gap (declined the assessment, withdrew from the course), and a hard block just trains staff to route around it.
- A cross-client graduation dashboard. Not asked for. The roster already exists (
staff_list_active_clients()); this spec touches only the single-client detail view. - Using
calcom_bookingsorintro_bookingsas the session-count source. Both exist for different jobs — team calendar view, pre-program purchase tracking — and neither carries a reliableclient_idlink the waycoaching_sessionsdoes. - Any AI-generated narrative. Nothing in the order calls for one, and generating one would add a whole review-and-edit obligation for zero benefit — every word this report shows a client is either her own submitted text or a plain computed number or date. Nothing to review because nothing is generated.
- Rewriting the 7-pillar scale's copy. Out of scope; Jes's own outstanding wording pass on that content is independent of this spec and doesn't block it.
- Per-symptom duration + severity trend. Excluded per the order; no honest source exists for it.
- Lab markers and Food & Mood Journal content. Excluded; neither lives in a database this report can query.
Open questions for the General
- Does Jes's "1:1 success metric" spreadsheet get retired the moment this ships, or does it track an aggregate across all clients that this single-client view was never meant to replace? If it should retire, worth a short follow-up pointing her at this view instead; if not, worth saying so explicitly.
- Confirm live: has
coaching_clients: staff readactually been corrected off the old role literals, or is the roster genuinely dark for every staff member right now? Blocks this feature and the entire practitioner console. - Confirm live: are
video_sessions_phase1c_role_ladder_hotfix.sqlandvideo_sessions_phase2.sqlapplied? If not, decide whether to apply them as a prerequisite here or accept "0 sessions, always" as this report's interim state. - Should a withdrawn client ever get a lightweight version of this ("how far she got")? Assumed graduation-only for this pass — flagging in case there's a business reason (partial-refund conversations, win-back outreach) to want one for withdrawals too.
/Users/sf/_artifacts/spec-graduation-report.md. This spec is delivered as HTML at /Users/sf/_artifacts/graduation-report-spec.html instead, per this role's standing house-style delivery rule. Flagging the substitution rather than making it silently — say the word if a raw Markdown copy is also wanted for Reybold's context or the vault.