Skip to main content

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:

  1. Admin creates a client org from an email. No specialist assignment at this step; the invite goes out immediately.
  2. 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.
  3. 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 β€” CreateOrgDto has no specialist field. The coupling is the Phase-0 gate (checkPhase0Gate, organizations.service.ts:1713–1750), which blocks POST /am/orgs/:orgId/invite and POST /am/orgs/:orgId/finish-setup unless 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-assignment isPrimary promotion (advisory lock osa_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_access grants (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/specialists and ops GET /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.assignSpecialist becomes getAmOrgDetail authz + 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 where client_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 via GET /onboarding/:token/org).
  • POST /onboarding/:token/specialist β€” body { specialistId }. Validates the token session; validates the target is a catalog row with client_selectable = true (404 otherwise); calls the core with billing: 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/provision remains the recovery hatch for missed provisioning.

7. Testing​

  • Extraction parity (unit): AM path unchanged β€” Lago sync invoked with billing: true and skipped with billing: 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; getPhase0Status still reports coverage fields informationally.
  • Catalog endpoint: returns only client_selectable = true catalog 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.