Skip to main content

Client Self-Service Specialist Selection Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Let a client pick their own Specialist during token-scoped onboarding (curated list, no billing), with invite sendable before any specialist/expert exists, per the approved spec docs/superpowers/specs/2026-08-14-client-specialist-selection-design.md.

Architecture: One new column (specialists.client_selectable); Phase-0 gate loses its specialist+expert blockers; the authz-free half of OrganizationsService.assignSpecialist moves verbatim into a new AssignmentProvisioningService with a billing switch; two new token-scoped onboarding endpoints (GET :token/catalog, POST :token/specialist) call it; a specialist_selection wizard step appears for orgs without an AM-preassigned specialist.

Tech Stack: NestJS + TypeORM (Postgres), BullMQ, Jest (Object.create(prototype) stub pattern used across this repo's unit specs).

Working directory: /home/alexdel/Projects/humanwork/.claude/worktrees/feat-client-specialist-selection โ€” branch feat/client-specialist-selection (based on origin/dev). All paths below are relative to api/ inside that worktree unless prefixed otherwise.

Test discipline (Alex's machine limits): run only the targeted spec files named in each task, always with --maxWorkers=2. Never start a full suite. From api/: npx jest <file> --maxWorkers=2.

Context notes for the implementer:

  • The repo verifies against origin/dev; the primary checkout is stale. Work only in the worktree.
  • Company research endpoints (GET/POST /onboarding/:token/company-research, #6036) already exist โ€” out of scope, do not touch.
  • getPhase0Status is already informational-only (its specialistAssigned/expertMapped fields are not gate blockers) โ€” it needs no change.
  • Never cite an issue number you weren't given in this plan or the spec.

Task 1: client_selectable column โ€” migration, entity, admin surfaceโ€‹

Files:

  • Create: api/migrations/1813680000000-SpecialistClientSelectable.ts

  • Modify: api/src/specialists/specialist.entity.ts (after isCatalog, ~line 159)

  • Modify: api/src/organizations/organizations.dto.ts (UpdateSpecialistDto, ~line 505)

  • Modify: api/src/organizations/organizations.service.ts (updateSpecialist, ~line 5691)

  • Create: api/src/organizations/update-specialist-client-selectable.spec.ts

  • Step 1: Write the failing test

// api/src/organizations/update-specialist-client-selectable.spec.ts
/**
* client_selectable curation flag โ€” ops flips it on the 3โ€“4 demo catalog
* specialists via PATCH /admin/specialists/:id. Uses the
* Object.create(prototype) pattern from rate-history-seeding.spec.ts.
*/
import { OrganizationsService } from "./organizations.service";

function makeService(existing: Record<string, unknown>) {
const svc: any = Object.create(OrganizationsService.prototype);
svc.specialistRepo = {
findOne: jest.fn().mockResolvedValue(existing),
save: jest.fn().mockImplementation(async (s: unknown) => s),
};
return svc;
}

describe("updateSpecialist clientSelectable", () => {
it("applies clientSelectable=true from the DTO", async () => {
const svc = makeService({ id: "spec-1", clientSelectable: false });
const result = await svc.updateSpecialist("spec-1", { clientSelectable: true });
expect(result.clientSelectable).toBe(true);
expect(svc.specialistRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ clientSelectable: true }),
);
});

it("leaves clientSelectable untouched when absent from the DTO", async () => {
const svc = makeService({ id: "spec-1", clientSelectable: true });
const result = await svc.updateSpecialist("spec-1", { bio: "x" });
expect(result.clientSelectable).toBe(true);
});
});
  • Step 2: Run test to verify it fails

Run: npx jest src/organizations/update-specialist-client-selectable.spec.ts --maxWorkers=2 Expected: FAIL โ€” first test's result.clientSelectable is false (field never applied).

  • Step 3: Add the migration
// api/migrations/1813680000000-SpecialistClientSelectable.ts
import { MigrationInterface, QueryRunner } from "typeorm";

/**
* Client self-service specialist selection (spec 2026-08-14): ops-curated
* visibility flag. Catalog rows (is_catalog = TRUE) with client_selectable
* = TRUE appear in the onboarding wizard's self-service selection list.
* Default FALSE โ€” nothing becomes client-visible until ops flips it.
*/
export class SpecialistClientSelectable1813680000000 implements MigrationInterface {
name = "SpecialistClientSelectable1813680000000";

async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "specialists" ADD COLUMN IF NOT EXISTS "client_selectable" boolean NOT NULL DEFAULT false`,
);
}

async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "specialists" DROP COLUMN IF EXISTS "client_selectable"`,
);
}
}

If a migration with timestamp 1813680000000 or later already exists on dev by implementation time (ls api/migrations | tail -3), bump the timestamp above the newest one and rename class + name to match.

  • Step 4: Add the entity column

In api/src/specialists/specialist.entity.ts, directly after the isCatalog column (~line 159):

/**
* Ops-curated client visibility: catalog rows with TRUE appear in the
* onboarding self-service selection list (GET /onboarding/:token/catalog).
* Meaningless on live (is_catalog = FALSE) rows.
*/
@Column({ name: "client_selectable", default: false })
clientSelectable: boolean;
  • Step 5: Add the DTO field and the update line

In api/src/organizations/organizations.dto.ts, inside UpdateSpecialistDto (keep alongside the other catalog-field entries):

/** Ops curation: show this catalog specialist in client self-service selection. */
@IsOptional()
@IsBoolean()
clientSelectable?: boolean;

(IsBoolean is already imported in this file for AssignSpecialistDto.isPrimary โ€” verify, add to the import if not.)

In api/src/organizations/organizations.service.ts updateSpecialist, alongside the dto.isCatalog handling at the end of the field block:

if (dto.clientSelectable !== undefined)
specialist.clientSelectable = dto.clientSelectable;
  • Step 6: Run test to verify it passes

Run: npx jest src/organizations/update-specialist-client-selectable.spec.ts --maxWorkers=2 Expected: PASS (2 tests).

  • Step 7: Commit
git add api/migrations/1813680000000-SpecialistClientSelectable.ts api/src/specialists/specialist.entity.ts api/src/organizations/organizations.dto.ts api/src/organizations/organizations.service.ts api/src/organizations/update-specialist-client-selectable.spec.ts
git commit -m "feat: client_selectable curation flag on catalog specialists"

Task 2: Phase-0 gate relaxation + delete dead Phase0ValidationServiceโ€‹

Files:

  • Modify: api/src/organizations/organizations.service.ts (checkPhase0Gate, lines ~1710โ€“1756)
  • Delete: api/src/organizations/phase0-validation.service.ts
  • Modify: api/src/organizations/organizations.module.ts (remove import at line 32, provider at 107, export at 123)
  • Create: api/src/organizations/phase0-gate-relaxation.spec.ts

Phase0ValidationService has zero call sites outside its module registration (verified 2026-08-14: grep -rn "Phase0ValidationService" api/src --include=*.ts โ†’ only organizations.module.ts). Re-run that grep before deleting; if a new consumer appeared, relax it the same way instead of deleting.

  • Step 1: Write the failing test
// api/src/organizations/phase0-gate-relaxation.spec.ts
/**
* Spec 2026-08-14 (client self-service selection): the Phase-0 gate no longer
* requires a specialist assignment or expert coverage โ€” the invite goes out
* right after org creation and the client picks their own specialist.
* Company-fields and AM-profile blockers are unchanged.
*/
import { OrganizationsService } from "./organizations.service";

const ORG = {
id: "org-1",
name: "Acme",
slug: "acme",
industry: "logistics",
corporateDomains: ["acme.com"],
adminEmail: "ceo@acme.com",
timezone: "UTC",
};
const AM = { id: "am-1", displayName: "Ann Manager", name: "Ann Manager" };

function makeService(opts: { assignments?: unknown[]; grants?: unknown[] } = {}) {
const svc: any = Object.create(OrganizationsService.prototype);
svc.assignmentRepo = {
findOne: jest.fn().mockResolvedValue(opts.assignments?.[0] ?? null),
find: jest.fn().mockResolvedValue(opts.assignments ?? []),
};
svc.specialistRepo = { findOne: jest.fn().mockResolvedValue(null) };
svc.expertAccessService = {
listGrantsByOrg: jest.fn().mockResolvedValue(opts.grants ?? []),
};
return svc;
}

describe("checkPhase0Gate relaxation", () => {
it("passes with ZERO specialist assignments", async () => {
const svc = makeService();
const result = await svc.checkPhase0Gate(ORG as any, AM as any);
expect(result.passed).toBe(true);
expect(result.blockers).toEqual([]);
});

it("passes with an assignment that has NO expert coverage", async () => {
const svc = makeService({
assignments: [{ id: "osa-1", specialistId: "spec-1" }],
grants: [],
});
const result = await svc.checkPhase0Gate(ORG as any, AM as any);
expect(result.passed).toBe(true);
});

it("still blocks on missing company fields", async () => {
const svc = makeService();
const result = await svc.checkPhase0Gate(
{ ...ORG, industry: null } as any,
AM as any,
);
expect(result.passed).toBe(false);
expect(result.blockers).toContain("Industry is required");
});

it("still blocks on missing AM profile when required", async () => {
const svc = makeService();
const result = await svc.checkPhase0Gate(
ORG as any,
{ id: "am-1", displayName: null, name: null } as any,
);
expect(result.blockers).toContain("AM profile: full name is required");
});
});
  • Step 2: Run test to verify it fails

Run: npx jest src/organizations/phase0-gate-relaxation.spec.ts --maxWorkers=2 Expected: FAIL โ€” first two tests get blockers "At least one Specialist must be assigned" / expert-coverage messages.

  • Step 3: Relax the gate

In api/src/organizations/organizations.service.ts, checkPhase0Gate: delete the two blocks โ€” the specialist-assignment check (starts const assignment = await this.assignmentRepo.findOne( ~line 1716, through its else closing brace ~line 1730) and the expert-coverage check (starts if (assignment && this.expertAccessService) { ~line 1734, through its closing brace ~line 1756). Replace both with:

// Client self-service selection (spec 2026-08-14): the gate deliberately
// does NOT require a specialist assignment or expert coverage. The client
// picks their own specialist during onboarding, and a specialist without
// an expert is valid (the expert is what gets billed later, not a
// precondition). getPhase0Status still surfaces coverage informationally.
  • Step 4: Run test to verify it passes

Run: npx jest src/organizations/phase0-gate-relaxation.spec.ts --maxWorkers=2 Expected: PASS (4 tests).

  • Step 5: Delete Phase0ValidationService
grep -rn "Phase0ValidationService\|phase0-validation" api/src --include=*.ts
# expect: only organizations.module.ts hits
git rm api/src/organizations/phase0-validation.service.ts

In api/src/organizations/organizations.module.ts remove the import (line 32) and the two list entries (lines 107, 123).

  • Step 6: Confirm nothing else broke

Run: npx tsc -p api/tsconfig.json --noEmit (from repo root; if the api has its own build script, cd api && npx tsc --noEmit) Expected: clean. Then re-run the gate-adjacent suites that stub checkPhase0Gate (they mock it, so they must still pass untouched): npx jest src/organizations/trial-realign-hook.spec.ts src/organizations/engagement-date-paths.spec.ts --maxWorkers=2 Expected: PASS.

  • Step 7: Commit
git add -A api/src/organizations
git commit -m "feat: drop specialist+expert blockers from Phase-0 gate; remove dead Phase0ValidationService"

Task 3: Extract AssignmentProvisioningService (the billing-switch core)โ€‹

Files:

  • Create: api/src/organizations/assignment-provisioning.service.ts
  • Create: api/src/organizations/osa-primary-lock.ts
  • Create: api/src/common/pg-error.util.ts
  • Modify: api/src/organizations/organizations.service.ts
  • Modify: api/src/organizations/organizations.module.ts
  • Modify: api/src/organizations/rate-history-seeding.spec.ts
  • Modify: api/src/organizations/workspace-provisioning-hook.spec.ts
  • Create: api/src/organizations/assignment-provisioning-billing.spec.ts

This is a MOVE, not a rewrite. The bodies of assignSpecialist (below its getAmOrgDetail call), _materializeCatalogSpecialist, _writeAssignmentRateHistory, _enqueueWorkspaceProvisioning, _enqueueRuntimeProvisioning, _recomputeOrgTotalRate, and _getOrgMetadata move verbatim from organizations.service.ts into the new service โ€” copy them exactly, including every comment (the comments carry issue-history that reviewers rely on). The only deliberate diffs are listed in Step 3. Helper-callsite facts (verified): _lockOrgPrimary is also used at ~line 3432 (updateSpecialistAssignment) โ†’ becomes a shared util; _recomputeOrgTotalRate is also used at ~lines 3488, 4028 (passes an EntityManager), 4095 โ†’ becomes a public method on the new service, delegated to; the other helpers have no callers outside assignSpecialist.

  • Step 1: Write the failing billing-switch test
// api/src/organizations/assignment-provisioning-billing.spec.ts
/**
* The billing switch is the reason AssignmentProvisioningService exists:
* AM assignment keeps the Lago subscription sync; client self-assignment
* (onboarding) must NOT create a subscription (spec 2026-08-14 โ€” onboarding
* is free; the expert is what gets paid for).
* Object.create(prototype) pattern from rate-history-seeding.spec.ts.
*/
import { AssignmentProvisioningService } from "./assignment-provisioning.service";

function makeService() {
const svc: any = Object.create(AssignmentProvisioningService.prototype);
svc.logger = { warn: jest.fn(), log: jest.fn(), error: jest.fn(), debug: jest.fn() };
const specialist = {
id: "spec-live-1",
isCatalog: false,
orgId: "org-1",
catalogSlug: "ops-sam",
slug: null,
firstName: "Sam",
lastName: "Morgan",
monthlyRateConfig: null,
};
svc.specialistRepo = { findOne: jest.fn().mockResolvedValue(specialist) };
svc.assignmentRepo = {
findOne: jest.fn().mockResolvedValue(null),
count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockImplementation((x: object) => ({ id: "osa-1", ...x })),
save: jest.fn().mockImplementation(async (x: object) => ({ id: "osa-1", assignedAt: new Date(), ...x })),
update: jest.fn().mockResolvedValue(undefined),
find: jest.fn().mockResolvedValue([]),
};
svc.orgRepo = {
findOne: jest.fn().mockResolvedValue({ id: "org-1", slug: "acme", metadata: {} }),
update: jest.fn().mockResolvedValue(undefined),
};
svc.rateHistoryRepo = {};
svc.rateHistoryWriter = {
seedBaseline: jest.fn().mockResolvedValue(true),
recordChange: jest.fn().mockResolvedValue(undefined),
};
svc.assignmentBillingInputResolver = { resolve: jest.fn().mockResolvedValue({ assignmentId: "osa-1" }) };
svc.syncSpecialistSubscription = { execute: jest.fn().mockResolvedValue(undefined) };
// queues absent โ†’ both enqueue helpers no-op (their guard clauses)
return svc;
}

const AM_ACTOR = { kind: "am" as const, userId: "am-1" };
const CLIENT_ACTOR = { kind: "client_onboarding" as const, userId: "client-1" };

describe("AssignmentProvisioningService billing switch", () => {
it("billing:true โ†’ Lago sync runs", async () => {
const svc = makeService();
await svc.assign("org-1", { specialistId: "spec-live-1" }, AM_ACTOR, { billing: true });
expect(svc.assignmentBillingInputResolver.resolve).toHaveBeenCalledWith("osa-1");
expect(svc.syncSpecialistSubscription.execute).toHaveBeenCalledTimes(1);
});

it("billing:false โ†’ Lago sync is skipped entirely", async () => {
const svc = makeService();
await svc.assign("org-1", { specialistId: "spec-live-1" }, CLIENT_ACTOR, { billing: false });
expect(svc.assignmentBillingInputResolver.resolve).not.toHaveBeenCalled();
expect(svc.syncSpecialistSubscription.execute).not.toHaveBeenCalled();
});

it("stamps assigned_via and assignedBy from the actor", async () => {
const svc = makeService();
await svc.assign("org-1", { specialistId: "spec-live-1" }, CLIENT_ACTOR, { billing: false });
const created = svc.assignmentRepo.create.mock.calls[0][0];
expect(created.assignedBy).toBe("client-1");
expect(created.metadata).toEqual(
expect.objectContaining({ assigned_via: "client_onboarding" }),
);
});

it("first assignment is auto-promoted to primary", async () => {
const svc = makeService();
await svc.assign("org-1", { specialistId: "spec-live-1" }, CLIENT_ACTOR, { billing: false });
const created = svc.assignmentRepo.create.mock.calls[0][0];
expect(created.isPrimary).toBe(true);
});

it("rejects a live specialist owned by another org with 409", async () => {
const svc = makeService();
svc.specialistRepo.findOne = jest.fn().mockResolvedValue({
id: "spec-foreign", isCatalog: false, orgId: "org-OTHER",
});
await expect(
svc.assign("org-1", { specialistId: "spec-foreign" }, AM_ACTOR, { billing: true }),
).rejects.toThrow(/different org/);
});
});
  • Step 2: Run test to verify it fails

Run: npx jest src/organizations/assignment-provisioning-billing.spec.ts --maxWorkers=2 Expected: FAIL โ€” module ./assignment-provisioning.service does not exist.

  • Step 3: Create the shared utils, then the service

api/src/common/pg-error.util.ts โ€” move the PgErrorLike interface + asPgErrorLike function verbatim from organizations.service.ts (~lines 118โ€“134), exported. In organizations.service.ts delete the local copies and import: import { asPgErrorLike } from "../common/pg-error.util";

api/src/organizations/osa-primary-lock.ts โ€” move _lockOrgPrimary (lines ~2614โ€“2627) verbatim, including its full #4490 doc comment, as an exported free function:

export async function lockOrgPrimary(
manager: { query?: (sql: string, params?: unknown[]) => Promise<unknown> } | undefined,
orgId: string,
): Promise<void> {
if (typeof manager?.query !== "function") return;
try {
await manager.query("SELECT pg_advisory_xact_lock(hashtext($1))", [
`osa_primary:${orgId}`,
]);
} catch {
// Advisory locks are a Postgres-only serialization aid; drivers without
// them (sqlite) fall through โ€” non-concurrent flows stay correct.
}
}

api/src/organizations/assignment-provisioning.service.ts โ€” skeleton (moved bodies elided here as /* MOVED VERBATIM from organizations.service.ts:<lines> */; the implementer pastes the real bodies, keeping the original private method names so the repointed specs in Step 5 keep working):

import { ConflictException, Injectable, Logger, NotFoundException, Optional } from "@nestjs/common";
import { InjectQueue } from "@nestjs/bullmq";
import { InjectRepository } from "@nestjs/typeorm";
import type { Queue } from "bullmq";
import { DataSource, EntityManager, IsNull, Not, Repository } from "typeorm";
import { Organization } from "../auth/auth.entities";
import type { MonthlyRateConfig } from "../common/entities";
import { generateEmailAlias } from "../common/email-alias-generator";
import { APP_ENV, getSpecialistEmailDomain } from "../common/env";
import { asPgErrorLike } from "../common/pg-error.util";
import { AssignmentBillingInputResolver } from "../billing/application/assignment-billing-input.resolver";
import { SyncSpecialistSubscriptionCommand } from "../billing/application/commands/sync-specialist-subscription.command";
import {
PROVISION_PAIR_JOB,
SPECIALIST_RUNTIME_PROVISIONING_QUEUE,
buildProvisionPairJob,
} from "../config-assets/specialist-runtime-provisioning.enqueuer";
import { OrgSpecialistAssignment, OrgSpecialistAssignmentRateHistory } from "../onboarding/onboarding.entities";
import { Specialist } from "../specialists/specialist.entity";
import { isGsuiteProvisioningEnabledForOrg } from "../workspace-lifecycle/gsuite-provisioning-policy";
import {
WORKSPACE_JOB_CREATE,
WORKSPACE_JOB_RESTORE,
WORKSPACE_PROVISIONING_QUEUE,
} from "../workspace-lifecycle/workspace-lifecycle.constants";
import { AssignmentRateHistoryWriter } from "./assignment-rate-history.writer";
import { lockOrgPrimary } from "./osa-primary-lock";

/** Who initiated the assignment โ€” recorded in OSA metadata.assigned_via + assignedBy. */
export type AssignmentActor =
| { kind: "am"; userId: string }
| { kind: "client_onboarding"; userId: string };

export interface AssignSpecialistInput {
specialistId: string;
isPrimary?: boolean;
monthlyRateConfig?: MonthlyRateConfig;
}

export interface AssignOptions {
/** true = sync the Lago subscription (AM path). false = client self-assign
* during free onboarding โ€” everything provisions EXCEPT billing. */
billing: boolean;
}

/**
* Authz-free core of specialist assignment, extracted from
* OrganizationsService.assignSpecialist (spec 2026-08-14) so the client
* self-service onboarding path and the AM path provably run the same
* side-effect chain: catalog materialization โ†’ OSA upsert + single-primary
* invariant โ†’ rate history โ†’ Gsuite mailbox โ†’ org total rate โ†’ (billing?) โ†’
* runtime/release provisioning. Callers own authorization.
*/
@Injectable()
export class AssignmentProvisioningService {
private readonly logger = new Logger(AssignmentProvisioningService.name);

constructor(
@InjectRepository(Specialist)
private readonly specialistRepo: Repository<Specialist>,
@InjectRepository(OrgSpecialistAssignment)
private readonly assignmentRepo: Repository<OrgSpecialistAssignment>,
@InjectRepository(OrgSpecialistAssignmentRateHistory)
private readonly rateHistoryRepo: Repository<OrgSpecialistAssignmentRateHistory>,
@InjectRepository(Organization)
private readonly orgRepo: Repository<Organization>,
@Optional() private readonly dataSource?: DataSource,
@Optional() private readonly assignmentBillingInputResolver?: AssignmentBillingInputResolver,
@Optional() private readonly syncSpecialistSubscription?: SyncSpecialistSubscriptionCommand,
@Optional()
@InjectQueue(WORKSPACE_PROVISIONING_QUEUE)
private readonly workspaceProvisioningQueue?: Queue,
@Optional()
@InjectQueue(SPECIALIST_RUNTIME_PROVISIONING_QUEUE)
private readonly runtimeProvisioningQueue?: Queue,
// #2413 convention โ€” deliberately NO @Optional(): a missing provider fails
// at NestJS startup instead of a runtime TypeError after the OSA was saved
// without its baseline history. TS `?` exists only for parameter ordering.
private readonly rateHistoryWriter?: AssignmentRateHistoryWriter,
) {}

async assign(
orgId: string,
input: AssignSpecialistInput,
actor: AssignmentActor,
options: AssignOptions,
): Promise<OrgSpecialistAssignment> {
/* MOVED VERBATIM from organizations.service.ts:2637-2849 (assignSpecialist
body below the getAmOrgDetail call), with ONLY these edits:
1. `dto` โ†’ `input`; `amId` โ†’ `actor.userId` (assignedBy, rate-config
updated_by, _writeAssignmentRateHistory arg).
2. `this._lockOrgPrimary(...)` โ†’ `lockOrgPrimary(...)` (imported util).
3. metadata stamp โ€” in the CREATE branch add to assignmentRepo.create():
metadata: { assigned_via: actor.kind },
and in the UPDATE branch, after the isPrimary/assignedBy lines:
assignment.metadata = { ...(assignment.metadata ?? {}), assigned_via: actor.kind };
4. the Lago block (originally lines 2834-2845) gains the switch:
if (options.billing && this.assignmentBillingInputResolver && this.syncSpecialistSubscription) {
(body unchanged).
Everything else โ€” the 23505 catch with the uq_osa_org_catalog_slug
winner re-fetch, wasCreate/rateChangedOnReassign logic, transaction
shape, comments โ€” is untouched. */
}

/** MOVED VERBATIM: _materializeCatalogSpecialist (organizations.service.ts:3110-3235). */
private async _materializeCatalogSpecialist(archetype: Specialist, orgId: string): Promise<Specialist> {
/* moved body */
}

/** MOVED VERBATIM: _writeAssignmentRateHistory (organizations.service.ts:2906-2933). */
private async _writeAssignmentRateHistory(/* same signature, amId param renamed userId */): Promise<void> {
/* moved body */
}

/** MOVED VERBATIM: _enqueueWorkspaceProvisioning (organizations.service.ts:2957-3096). */
private async _enqueueWorkspaceProvisioning(osa: OrgSpecialistAssignment, specialist: Specialist, orgId: string): Promise<void> {
/* moved body */
}

/** MOVED VERBATIM: _enqueueRuntimeProvisioning (organizations.service.ts:2871-2890). */
private async _enqueueRuntimeProvisioning(orgId: string, specialistId: string | null | undefined): Promise<void> {
/* moved body */
}

/** MOVED VERBATIM from _recomputeOrgTotalRate + _getOrgMetadata
* (organizations.service.ts:3243-3310) โ€” now PUBLIC: OrganizationsService
* delegates its suspend/restore/unassign call sites here. */
async recomputeOrgTotalRate(orgId: string, manager?: EntityManager): Promise<void> {
/* moved body */
}
private async _getOrgMetadata(/* unchanged signature */) {
/* moved body */
}
}
  • Step 4: Shrink OrganizationsService to a delegating wrapper

In api/src/organizations/organizations.service.ts:

  1. Inject the new service at the end of the constructor (the file's own comment says new params go last so positional test setups keep their slots):
// Spec 2026-08-14 โ€” authz-free assignment core shared with the client
// onboarding selection path. @Optional so the many hand-built positional
// spec instances keep constructing; module wiring always provides it.
@Optional()
private readonly assignmentProvisioning?: AssignmentProvisioningService,
  1. Replace assignSpecialist's body (keep the signature โ€” the controller is untouched):
async assignSpecialist(
orgId: string,
amId: string,
isSuperAdmin: boolean,
dto: AssignSpecialistDto,
): Promise<OrgSpecialistAssignment> {
await this.getAmOrgDetail(orgId, amId, isSuperAdmin);
if (!this.assignmentProvisioning) {
throw new Error("AssignmentProvisioningService not wired");
}
return this.assignmentProvisioning.assign(
orgId,
{
specialistId: dto.specialistId,
isPrimary: dto.isPrimary,
monthlyRateConfig: dto.monthlyRateConfig,
},
{ kind: "am", userId: amId },
{ billing: true },
);
}
  1. Delete the moved private methods (_lockOrgPrimary, _materializeCatalogSpecialist, _writeAssignmentRateHistory, _enqueueWorkspaceProvisioning, _enqueueRuntimeProvisioning, _recomputeOrgTotalRate, _getOrgMetadata) and the now-unused imports they exclusively carried (compare with npx tsc --noEmit).

  2. Re-wire the surviving call sites:

    • ~line 3432 (updateSpecialistAssignment): await this._lockOrgPrimary(...) โ†’ await lockOrgPrimary(...) (add the import).
    • ~lines 3488, 4028, 4095: await this._recomputeOrgTotalRate(orgId) โ†’ await this.assignmentProvisioning?.recomputeOrgTotalRate(orgId) (4028 passes its manager through unchanged).
    • ~line 3826 (provisionSpecialistAssignment, the manual re-provision) references _enqueueWorkspaceProvisioning only in a comment โ€” update the comment to name the new service.
  3. api/src/organizations/organizations.module.ts: add AssignmentProvisioningService to imports, providers, and exports (OnboardingModule consumes it in Task 4 via its existing OrganizationsModule import).

  • Step 5: Repoint the two moved-logic specs

api/src/organizations/rate-history-seeding.spec.ts and api/src/organizations/workspace-provisioning-hook.spec.ts test the moved private methods via Object.create(OrganizationsService.prototype). In both files change the import and prototype to AssignmentProvisioningService / ./assignment-provisioning.service. The private method names and signatures are unchanged (rate-history's amId param rename is internal; the spec passes it positionally). If either spec invokes the full assignSpecialist(orgId, amId, isSuperAdmin, dto) rather than a private helper, rewrite that call as assign(orgId, { specialistId: dto.specialistId, isPrimary: dto.isPrimary, monthlyRateConfig: dto.monthlyRateConfig }, { kind: "am", userId: "am-1" }, { billing: true }) and drop any getAmOrgDetail stub (authz stayed in OrganizationsService).

  • Step 6: Run the affected suites

Run: npx jest src/organizations/assignment-provisioning-billing.spec.ts src/organizations/rate-history-seeding.spec.ts src/organizations/workspace-provisioning-hook.spec.ts src/organizations/provision-specialist-assignment.spec.ts src/organizations/organizations.service.spec.ts src/organizations/trial-realign-hook.spec.ts --maxWorkers=2 Expected: PASS. Then npx tsc --noEmit from api/: clean.

If organizations.service.spec.ts or provision-specialist-assignment.spec.ts fail on the recompute delegation (they may stub _recomputeOrgTotalRate directly), update those stubs to svc.assignmentProvisioning = { recomputeOrgTotalRate: jest.fn() }.

  • Step 7: Commit
git add -A api/src
git commit -m "refactor: extract AssignmentProvisioningService with billing switch from assignSpecialist"

Task 4: Token-scoped selection endpoints (the UI contract)โ€‹

Files:

  • Modify: api/src/onboarding/onboarding.dto.ts

  • Modify: api/src/onboarding/onboarding.service.ts

  • Modify: api/src/onboarding/onboarding.controller.ts

  • Create: api/src/onboarding/specialist-selection.spec.ts

  • Step 1: Write the failing test

// api/src/onboarding/specialist-selection.spec.ts
/**
* Client self-service selection (spec 2026-08-14):
* - GET /onboarding/:token/catalog โ†’ client_selectable catalog rows only
* - POST /onboarding/:token/specialist โ†’ assign via core, billing:false
* Object.create(prototype) pattern from onboarding-wizard-shape.spec.ts.
*/
import { OnboardingService } from "./onboarding.service";

const ORG_ID = "11111111-1111-1111-1111-111111111111";

function makeService(opts: {
catalog?: unknown[];
pickedSpecialist?: Record<string, unknown> | null;
clientUser?: Record<string, unknown> | null;
}) {
const svc: any = Object.create(OnboardingService.prototype);
svc.logger = { warn: jest.fn(), log: jest.fn(), error: jest.fn() };
const invitation = {
id: "inv-1",
orgId: ORG_ID,
status: "pending",
adminEmail: "ceo@acme.com",
expiresAt: new Date(Date.now() + 60_000),
};
svc.invitationRepo = { findOne: jest.fn().mockResolvedValue(invitation) };
svc.specialistRepo = {
find: jest.fn().mockResolvedValue(opts.catalog ?? []),
findOne: jest.fn().mockResolvedValue(opts.pickedSpecialist ?? null),
};
svc.userRepo = { findOne: jest.fn().mockResolvedValue(opts.clientUser ?? null) };
svc.assignmentProvisioning = {
assign: jest.fn().mockResolvedValue({ id: "osa-1", specialistId: "spec-cat-1", isPrimary: true }),
};
return svc;
}

const SELECTABLE = {
id: "spec-cat-1",
isCatalog: true,
clientSelectable: true,
slug: "ops-sam",
name: "Sam",
fullName: "Sam Morgan",
title: "Ops Specialist",
category: "operations_strategy",
industries: ["logistics"],
tagline: "t",
bio: "b",
description: "d",
avatarUrl: "http://a",
coreCapabilities: ["x"],
tools: ["y"],
seniorityTier: "senior",
};

describe("getSelectableCatalog", () => {
it("queries only client_selectable catalog rows and maps card fields", async () => {
const svc = makeService({ catalog: [SELECTABLE] });
const result = await svc.getSelectableCatalog("tok");
expect(svc.specialistRepo.find).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ isCatalog: true, clientSelectable: true }),
}),
);
expect(result).toEqual([
expect.objectContaining({
id: "spec-cat-1",
fullName: "Sam Morgan",
industries: ["logistics"],
}),
]);
});
});

describe("selectSpecialist", () => {
it("assigns via the core with billing:false and client_onboarding actor", async () => {
const svc = makeService({
pickedSpecialist: SELECTABLE,
clientUser: { id: "client-1", email: "ceo@acme.com" },
});
const result = await svc.selectSpecialist("tok", { specialistId: "spec-cat-1" });
expect(svc.assignmentProvisioning.assign).toHaveBeenCalledWith(
ORG_ID,
{ specialistId: "spec-cat-1" },
{ kind: "client_onboarding", userId: "client-1" },
{ billing: false },
);
expect(result.assignmentId).toBe("osa-1");
expect(result.specialist).toEqual(expect.objectContaining({ id: "spec-cat-1" }));
});

it("404s a specialist that is not client_selectable", async () => {
const svc = makeService({
pickedSpecialist: { ...SELECTABLE, clientSelectable: false },
clientUser: { id: "client-1" },
});
await expect(
svc.selectSpecialist("tok", { specialistId: "spec-cat-1" }),
).rejects.toThrow(/not available for selection/);
});

it("404s a non-catalog (live) specialist even if flagged", async () => {
const svc = makeService({
pickedSpecialist: { ...SELECTABLE, isCatalog: false },
clientUser: { id: "client-1" },
});
await expect(
svc.selectSpecialist("tok", { specialistId: "spec-cat-1" }),
).rejects.toThrow(/not available for selection/);
});

it("409s when the client user profile does not exist yet (workspace step not done)", async () => {
const svc = makeService({ pickedSpecialist: SELECTABLE, clientUser: null });
await expect(
svc.selectSpecialist("tok", { specialistId: "spec-cat-1" }),
).rejects.toThrow(/workspace/i);
});
});
  • Step 2: Run test to verify it fails

Run: npx jest src/onboarding/specialist-selection.spec.ts --maxWorkers=2 Expected: FAIL โ€” getSelectableCatalog / selectSpecialist are not functions.

  • Step 3: DTO

In api/src/onboarding/onboarding.dto.ts:

export class SelectSpecialistDto {
@IsUUID()
specialistId: string;
}

(Add IsUUID to the class-validator import if absent.)

  • Step 4: Service methods

In api/src/onboarding/onboarding.service.ts:

  1. Constructor โ€” add at the very end (after companyResearchSeed):
// Spec 2026-08-14 โ€” client self-service specialist selection. @Optional so
// hand-built spec instances keep constructing; module wiring provides it
// via OrganizationsModule's export.
@Optional()
private readonly assignmentProvisioning?: AssignmentProvisioningService,

with import { AssignmentProvisioningService } from "../organizations/assignment-provisioning.service"; (and its AssignmentActor type is not needed โ€” the literal is inline).

  1. Methods (place near getSpecialists, ~line 510):
/** Card shape for the self-service selection list. Raw industry/category
* fields included โ€” the UI computes the "likely fit" highlight itself. */
private toCatalogCard(s: Specialist) {
return {
id: s.id,
slug: s.slug,
name: s.name,
fullName: s.fullName,
title: s.title,
category: s.category,
industries: s.industries ?? [],
tagline: s.tagline,
// Catalog archetypes keep persona text in `description` when `bio` is
// blank (same fallback rule as materialization).
bio: s.bio?.trim() ? s.bio : s.description,
avatarUrl: s.avatarUrl,
coreCapabilities: s.coreCapabilities ?? [],
tools: s.tools ?? [],
seniorityTier: s.seniorityTier,
};
}

// โ”€โ”€ GET /onboarding/:token/catalog โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
/** Ops-curated catalog for client self-service selection (spec 2026-08-14). */
async getSelectableCatalog(token: string) {
await this.validateToken(token, true);
const specialists = await this.specialistRepo.find({
where: { isCatalog: true, clientSelectable: true },
order: { fullName: "ASC" },
});
return specialists.map((s) => this.toCatalogCard(s));
}

// โ”€โ”€ POST /onboarding/:token/specialist โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
/**
* Client picks their specialist. Runs the SAME provisioning chain as AM
* assignment (materialize โ†’ OSA โ†’ Gsuite โ†’ runtime release) minus billing โ€”
* onboarding is free; billing attaches with the expert later. Re-picking is
* permitted (multiple assignments allowed); unassign stays AM/SA-only.
*/
async selectSpecialist(token: string, dto: SelectSpecialistDto) {
const invitation = await this.validateToken(token, true);
if (!this.assignmentProvisioning) {
throw new Error("AssignmentProvisioningService not wired");
}

const picked = await this.specialistRepo.findOne({
where: { id: dto.specialistId },
});
// One error shape for "doesn't exist", "not catalog", and "not curated" โ€”
// don't leak which non-selectable ids exist.
if (!picked || !picked.isCatalog || !picked.clientSelectable) {
throw new NotFoundException(
`Specialist ${dto.specialistId} is not available for selection.`,
);
}

// assignedBy / rate-history need a user id; the workspace step (setProfile)
// creates the client admin user before this step is reachable.
const clientUser = await this.userRepo.findOne({
where: { email: invitation.adminEmail },
});
if (!clientUser) {
throw new ConflictException(
"Complete the workspace step before selecting a Specialist.",
);
}

const assignment = await this.assignmentProvisioning.assign(
invitation.orgId,
{ specialistId: picked.id },
{ kind: "client_onboarding", userId: clientUser.id },
{ billing: false },
);

return {
assignmentId: assignment.id,
specialist: this.toCatalogCard(picked),
};
}

(ConflictException / NotFoundException are already imported in this file โ€” verify; validateToken(token, true) allows the accepted-status resume case, same as getSpecialists.)

  • Step 5: Controller routes

In api/src/onboarding/onboarding.controller.ts, after the getSpecialists route (~line 129):

// โ”€โ”€ Client self-service selection (spec 2026-08-14) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

/** GET /onboarding/:token/catalog โ€” ops-curated selectable catalog cards. */
@Get(":token/catalog")
getSelectableCatalog(@Param("token") token: string) {
return this.onboardingService.getSelectableCatalog(token);
}

/** POST /onboarding/:token/specialist โ€” client picks their Specialist.
* Same provisioning as AM assignment, minus billing. */
@Post(":token/specialist")
selectSpecialist(
@Param("token") token: string,
@Body() dto: SelectSpecialistDto,
) {
return this.onboardingService.selectSpecialist(token, dto);
}

(Add SelectSpecialistDto to the dto import.)

  • Step 6: Run test to verify it passes

Run: npx jest src/onboarding/specialist-selection.spec.ts --maxWorkers=2 Expected: PASS (5 tests). Then npx tsc --noEmit: clean.

  • Step 7: Commit
git add api/src/onboarding
git commit -m "feat: token-scoped catalog browse + specialist selection endpoints"

Task 5: specialist_selection wizard stepโ€‹

Files:

  • Modify: api/src/onboarding/onboarding.service.ts (OnboardingWizardStepId ~line 70, getWizardSteps ~line 151)
  • Modify: api/src/onboarding/onboarding-wizard-shape.spec.ts

Step rule (from the spec): the step is present when the org has zero active assignments (new flow) or any assignment was made via client onboarding (keeps the list stable after the client picks โ€” the resume index math must not shift mid-wizard). AM-preassigned orgs never see it. "Active" = gsuiteSuspendedAt IS NULL AND gsuiteArchivedAt IS NULL (same filter as recomputeOrgTotalRate).

  • Step 1: Update the wizard-shape spec โ€” add failing cases

In api/src/onboarding/onboarding-wizard-shape.spec.ts:

  1. The makeService helper needs an assignments stub. Add to makeService (and a new option):
// near the other repo stubs; opts gains `assignments?: unknown[]`
svc.assignmentRepo = {
find: jest.fn().mockResolvedValue(opts.assignments ?? []),
};
  1. Every existing expected step-list in the file gains "specialist_selection" between "business_context" and "done" (the default stub is zero assignments โ†’ step present). Update each expect(...steps...).toEqual([...]) accordingly.

  2. New cases:

it("hides specialist_selection when an AM-preassigned active assignment exists", async () => {
const svc = makeService({
org: { id: ORG_ID, status: "pending_client_confirmation", slug: "acme", slugReservedAt: new Date() },
assignments: [{ id: "osa-1", metadata: {}, gsuiteSuspendedAt: null, gsuiteArchivedAt: null }],
});
const { steps } = await svc.getResumeStep("tok");
expect(steps).not.toContain("specialist_selection");
});

it("keeps specialist_selection after the client's own pick (list stays stable)", async () => {
const svc = makeService({
org: { id: ORG_ID, status: "pending_client_confirmation", slug: "acme", slugReservedAt: new Date() },
assignments: [{ id: "osa-1", metadata: { assigned_via: "client_onboarding" }, gsuiteSuspendedAt: null, gsuiteArchivedAt: null }],
});
const { steps } = await svc.getResumeStep("tok");
expect(steps).toContain("specialist_selection");
});

(Adapt the org literal keys to whatever the existing cases in this file use โ€” copy a passing case's org shape.)

  • Step 2: Run to verify the new cases fail

Run: npx jest src/onboarding/onboarding-wizard-shape.spec.ts --maxWorkers=2 Expected: FAIL โ€” "specialist_selection" never appears.

  • Step 3: Implement

In api/src/onboarding/onboarding.service.ts:

  1. Extend the union (and its doc comment):
export type OnboardingWizardStepId =
| "workspace"
| "agreements"
| "specialist_intake"
| "business_context"
| "specialist_selection"
| "done";
  1. getWizardSteps becomes:
private async getWizardSteps(orgId: string): Promise<OnboardingWizardStepId[]> {
const [agreementsRequired, videoIntakeEnabled] = await Promise.all([
this.resolveWizardFlag("onboarding_agreements_required", orgId),
this.resolveWizardFlag("onboarding_video_intake_enabled", orgId),
]);
// Client self-service selection (spec 2026-08-14): present when the org
// has no active assignment (new flow) OR the assignment came from this
// step (keeps the list โ€” and resume indexes โ€” stable after the pick).
// AM-preassigned orgs (existing flow) never see it. "Active" mirrors
// recomputeOrgTotalRate's filter.
const assignments = await this.assignmentRepo.find({ where: { orgId } });
const active = assignments.filter(
(a) => !a.gsuiteSuspendedAt && !a.gsuiteArchivedAt,
);
const showSelection =
active.length === 0 ||
active.some(
(a) => (a.metadata as Record<string, unknown> | null)?.assigned_via === "client_onboarding",
);
return [
"workspace",
...(agreementsRequired ? (["agreements"] as const) : []),
...(videoIntakeEnabled ? (["specialist_intake"] as const) : []),
"business_context",
...(showSelection ? (["specialist_selection"] as const) : []),
"done",
];
}

getResumeStep needs no logic change: business_context (which precedes selection) already has no completion signal, so resume lands at/before it and the client advances forward โ€” same behavior the wizard has today for signal-less steps.

  • Step 4: Run to verify all wizard-shape tests pass

Run: npx jest src/onboarding/onboarding-wizard-shape.spec.ts src/onboarding/completeOnboarding-engagement-starts.spec.ts src/onboarding/specialist-selection.spec.ts --maxWorkers=2 Expected: PASS. (If completeOnboarding-engagement-starts.spec.ts constructs a service that reaches getWizardSteps without an assignmentRepo.find stub, add the same empty-array stub there.)

  • Step 5: Commit
git add api/src/onboarding
git commit -m "feat: specialist_selection wizard step, auto-hidden for AM-preassigned orgs"

Task 6: Verification sweep + PRโ€‹

Files: none new (fixes only if something below fails).

  • Step 1: Targeted regression run (one batch, capped workers)

From api/:

npx jest src/organizations/update-specialist-client-selectable.spec.ts \
src/organizations/phase0-gate-relaxation.spec.ts \
src/organizations/assignment-provisioning-billing.spec.ts \
src/organizations/rate-history-seeding.spec.ts \
src/organizations/workspace-provisioning-hook.spec.ts \
src/organizations/provision-specialist-assignment.spec.ts \
src/organizations/organizations.service.spec.ts \
src/organizations/trial-realign-hook.spec.ts \
src/organizations/engagement-date-paths.spec.ts \
src/onboarding/onboarding-wizard-shape.spec.ts \
src/onboarding/specialist-selection.spec.ts \
src/onboarding/completeOnboarding-engagement-starts.spec.ts \
--maxWorkers=2

Expected: all PASS. Then npx tsc --noEmit: clean. Run the api lint command from api/package.json scripts if one exists.

  • Step 2: RLS sanity note (read-only check)

The selection endpoint inserts into org_specialist_assignments from a @Public() (token) request context. Existing onboarding endpoints already write org-scoped RLS'd tables from the same context (e.g. POST :token/whitelist โ†’ email_whitelists), so the pattern is proven. Verify, don't assume: grep -n "org_specialist_assignments" api/migrations/1714000000033-RlsComplete.ts api/migrations/1794000000001-RlsForceRowLevelSecurity.ts and read the policy. If the policy is fail-closed against a missing app.current_org_id, follow the RlsSessionContextService internal-binding precedent used by hardDeleteOrg (#5155, see organizations.service.ts constructor comment) inside selectSpecialist โ€” and add that as its own commit. Do NOT weaken any RLS policy.

  • Step 3: Dev smoke checklist (the spec's flow-integration verification)

The spec's end-to-end flow check runs against dev after deploy, not as a local full-app spec (those hang on Redis on this machine). Record the results in the PR description:

  1. Ops flips client_selectable=true on one catalog specialist: PATCH /admin/specialists/:id {"clientSelectable": true} (superadmin).
  2. AM creates a fresh org from email only โ†’ POST /am/orgs/:orgId/invite succeeds with zero specialists (gate relaxed).
  3. Open the invite link, complete workspace: GET /onboarding/:token/catalog returns exactly the flipped specialist(s).
  4. POST /onboarding/:token/specialist โ†’ 200 with assignmentId; verify in DB: OSA row exists, is_primary=true, metadata->>'assigned_via'='client_onboarding', no Lago subscription created, and a provision-pair job ran (soul + managed-runtime-release config assets exist for the pair).
  5. GET /onboarding/:token/resume-step includes specialist_selection for this org; a pre-existing AM-assigned org does NOT include it.
  • Step 4: Push and open a DRAFT PR to dev
git push -u origin feat/client-specialist-selection
gh pr create --draft --base dev --title "feat: client self-service specialist selection (onboarding)" --body "$(cat <<'EOF'
Implements docs/superpowers/specs/2026-08-14-client-specialist-selection-design.md

- `specialists.client_selectable` ops-curation flag (+ admin PATCH surface)
- Phase-0 gate: specialist + expert-coverage blockers removed (invite sendable at org creation); dead Phase0ValidationService deleted
- `AssignmentProvisioningService`: authz-free assignment core extracted verbatim from `assignSpecialist`, with a `billing` switch (AM path unchanged, billing:true)
- Token-scoped `GET /onboarding/:token/catalog` + `POST /onboarding/:token/specialist` (billing:false, `assigned_via` audit stamp)
- `specialist_selection` wizard step, auto-hidden for AM-preassigned orgs

Accepted demo behaviors (per spec, not bugs): expert-less escalations notify nobody; client-picked assignments carry no Lago subscription.

Deploy coordination: the FE `specialist_selection` step component (Degen) must ship before inviting an org that has no preassigned specialist; AM-preassigned orgs are unaffected either way.

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"

Un-draft when ready so Greptile reviews it; iterate to green. STOP after the PR is green โ€” never merge without Alex's explicit approval.


Deferred / out of scope (do not build)โ€‹

Recommendation service, catalog filters, company research changes, billing-on-expert-attach, portal-based selection, specialist_change_requests rework, FE wizard UI (Degen).