Client self-service specialist selection β design
Date: 2026-08-14 Status: Approved (Alex), pending implementation plan Scope owner: Backend rewiring + client-facing selection API. UI (catalog cards, fit badge, wizard screens) is Degen's; company research and billing-on-expert-attach are explicitly out of scope.
Goalβ
Change the client lifecycle from "admin assigns a Specialist before the client sees an invite" to:
- Admin creates a client org from an email. No specialist assignment at this step; the invite goes out immediately.
- Client enters onboarding: company info (typed by hand, or later pre-filled by research) β picks a Specialist from a curated list β Tavus onboarding call with that Specialist.
- Admin assigns an Expert to the specialist later. A specialist without an expert works and is not a defect.
Locked product decisions (manager, 2026-08-14): no recommendation service (3β4 expert-backed specialists shown; "likely fit" highlight is UI logic on the industry field); onboarding is free and not a trial; the paid unit stays specialist-per-month where the Expert is what is paid for; billing correctness is out of demo scope; client assigns, only admin unassigns; Tavus onboarding call is the only Tavus flow; the Tavus-less path is the same flow with the company step answered by hand.
Clarified decisions (this design): multiple assignments per org remain allowed at the data level, the UI guides the client to one; curation is an explicit visibility flag on catalog specialists; client self-assignment skips the Lago subscription; selection lives in the token-scoped onboarding wizard (not the portal); Phase-0 specialist/expert blockers are dropped outright (no feature flag).
Current state (verified against origin/dev)β
- Org creation (
createAmOrgAsync,api/src/organizations/organizations.service.ts:840) already excludes specialist assignment βCreateOrgDtohas no specialist field. The coupling is the Phase-0 gate (checkPhase0Gate,organizations.service.ts:1713β1750), which blocksPOST /am/orgs/:orgId/inviteandPOST /am/orgs/:orgId/finish-setupunless the org has β₯1 specialist and every assigned specialist has expert coverage. - Assignment is AM/SA-only:
POST /am/orgs/:orgId/specialistβassignSpecialist(organizations.service.ts:2623), which fuses authz with catalog materialization, OSA upsert + first-assignmentisPrimarypromotion (advisory lockosa_primary:<orgId>, 23505 retry), rate history, Gsuite mailbox enqueue, org total-rate recompute, Lago subscription sync (fail-open), and runtime/release provisioning (fail-open; without it the specialist cannot draft). - Experts link via
expert_accessgrants (org- or specialist-scoped); no FK on the assignment, no DB constraint requires an expert. Only the Phase-0 blockers and the last-covering-expert revoke guard (expert-access.service.ts:620, escapable with?confirm=true) enforce coverage. - No authenticated client catalog browse exists (only public
GET /marketing/specialistsand opsGET /am/specialists); no client-authorized path can create an assignment. - The OSA is the tenancy anchor of the runtime layer (session/memory/receipt tables resolve RLS tenant through
org_specialist_assignments); chat 503s without an OSA. Selection-before-call preserves this invariant.
Designβ
1. Schemaβ
One migration: specialists.client_selectable boolean NOT NULL DEFAULT false. Meaningful on catalog rows (is_catalog = true). Ops toggles it via the existing PATCH /admin/specialists/:id (DTO + list/detail responses gain the field). No backfill; no changes to org_specialist_assignments or expert_access.
2. Phase-0 gate relaxationβ
In checkPhase0Gate: remove the "At least one Specialist must be assigned" blocker and the per-specialist expert-coverage blockers. Everything else in the checklist stays. getPhase0Status (organizations.service.ts:4870) keeps computing expertMapped/coverage as informational fields but stops emitting them as blockers. The older duplicate Phase0ValidationService.checkGate (phase0-validation.service.ts:31) gets the same relaxation, or is deleted if implementation confirms it has no live call sites. Net effect: invite can be sent immediately after org creation.
3. Assignment core extractionβ
New AssignmentProvisioningService (organizations module):
assign({ orgId, specialistId, rateConfig?, actor, options: { billing: boolean } })
β OrgSpecialistAssignment
It absorbs, verbatim, everything in assignSpecialist below the authz line: foreign-org live-specialist rejection, catalog materialization, OSA upsert with first-assignment isPrimary promotion, the advisory-lock single-primary write with 23505 re-fetch, then the side-effect chain β rate history, Gsuite mailbox enqueue, total-rate recompute, runtime/release provisioning, and Lago subscription only when options.billing. Transaction boundaries and lock semantics move unchanged; this is a cut, not a rewrite.
Entry points:
OrganizationsService.assignSpecialistbecomesgetAmOrgDetailauthz +assign(..., { billing: true })with the DTO's rate config. AM behavior is unchanged.- The onboarding selection endpoint (below) calls
assign(..., { billing: false })with no rate overrides (catalog defaults apply).
actor is recorded for audit in the OSA metadata jsonb (e.g. assigned_via: "am" | "client_onboarding" plus the AM user id when applicable), so ops can always tell how an assignment came to exist.
4. Onboarding endpoints (token-scoped) β the contract for the UIβ
Additions to the existing capability-token onboarding controller (api/src/onboarding/onboarding.controller.ts):
GET /onboarding/:token/catalogβ catalog specialists whereclient_selectable = true. Returns per specialist: id, slug, name, title, bio, avatar, and raw industry/category fields. The UI computes the "likely fit" badge by comparing against the org's industry (already available viaGET /onboarding/:token/org).POST /onboarding/:token/specialistβ body{ specialistId }. Validates the token session; validates the target is a catalog row withclient_selectable = true(404 otherwise); calls the core withbilling: false. Returns{ assignmentId, specialist: { β¦ } }so the wizard proceeds to the call step. Re-picking is permitted (creates another assignment; multiple are allowed). No unassign endpoint exists on this surface β unassign remains AM/SA-only.
Wizard order (getWizardSteps, api/src/onboarding/onboarding.service.ts:146): workspace β agreements? β business_context β specialist_selection β done. Company info comes before selection per the locked flow. The specialist_selection step auto-skips when the org already has an active (non-suspended) assignment, so AM-preassigned orgs keep their current experience with no migration and no feature flag (same pattern as the existing "Meet your Specialist" skip).
The Tavus call needs no backend change: POST /assignments/:assignmentId/onboarding/start (api/src/meetings/meetings.controller.ts:54) already keys on the assignment, which now exists by the time the call step runs and carries the chosen specialist's Tavus persona/replica.
5. Expert optionalityβ
No structural change. With the Phase-0 blockers gone, a specialist runs expert-less end to end. Admin attaches an expert later via existing POST /am/orgs/:orgId/experts (specialist scope). The last-covering-expert 409 guard stays; its ?confirm=true escape is the admin-only unassign path.
Accepted demo behaviors (documented so they are not filed as bugs):
- Escalations on an expert-less specialist commit but notify nobody (dispatch audience resolves to empty and fails closed). The expert queue itself is pull-based and unaffected.
- Client-picked specialists carry no Lago subscription. Wiring subscription creation to expert-attach is deferred; the AM assignment path keeps billing exactly as today.
6. Authorization and error handlingβ
- Selection endpoints ride the existing onboarding token/session validation; no new guard types. The tokenβorg binding determines which org the assignment lands on.
- Select endpoint: 404 for unknown or non-selectable specialists; the core's existing 409s (e.g. foreign-org live specialist) propagate.
- Side effects keep their current fail-open/fail-closed split;
POST /am/orgs/:orgId/specialist-assignments/:assignmentId/provisionremains the recovery hatch for missed provisioning.
7. Testingβ
- Extraction parity (unit): AM path unchanged β Lago sync invoked with
billing: trueand skipped withbilling: false; rate config honored; first-assignment primary promotion; 23505 re-fetch preserved; foreign-org rejection preserved. - Flow integration (service-level, standalone DataSource): create org β send invite with zero specialists (gate passes) β token select β assignment exists,
isPrimary = true, runtime provisioning enqueued, no subscription created. - Gate regression: Phase-0 tests updated for removed blockers;
getPhase0Statusstill reports coverage fields informationally. - Catalog endpoint: returns only
client_selectable = truecatalog rows; excludes materialized per-org clones.
Out of scopeβ
Recommendation service and catalog filters (industry/services/skills β post-demo); company research service (its output lands in organizations.metadata.business_info + the KB doc, exactly where manual entry lands today, so no seam work is needed); billing-on-expert-attach; portal-based selection for active orgs; specialist_change_requests rework; proration/plan-change billing correctness.