The roster and pickers stop pretending and start talking to the backend
Before a staffer can grant themselves access, they must be eligible for the firm — its CSM owner, or on its support roster. The endpoints were live, but the UI was placeholder: the add-to-roster dialog listed two hardcoded names behind a disabled button, roster removal was a pending stub, the CSM reassignment picker used a hardcoded CSM_OPTIONS array, and the grant dialog assumed any firm with a plan was eligible. This wires every one of those to the real roster and staff endpoints, and drives eligibility from who the caller actually is.
What changed
The detail page now fetches the live roster and the active-staff list, resolves the caller through GET /admin/me, and hands real data to dialogs that were previously mock-only. No hand-written contract mirrors were added — the consumed shapes come from src/gen.
The CSM reassign and add-to-roster dialogs take options derived from listStaff — active CSMs for reassign, active staff (minus the existing roster and CSM owner) for roster-add. The hardcoded CSM_OPTIONS and staff UUIDs are gone.
getFirmByRef now also fetches GET /firms/{id}/support_roster; new addFirmSupportRosterMember (POST) and removeFirmSupportRosterMember (DELETE) server actions parse via assertContract and revalidate. Removal is gated behind an AlertDialog confirmation; the panel renders a live roster with an empty state.
The grant dialog’s eligible-firms list is no longer an assumption. A cached getCurrentStaff() resolves the caller’s PRAMAAN id; this firm appears only when the caller is its CSM owner (relationship: csm) or on its support roster (supportRoster).
Operational behavior
An admin can add and remove a real staff member to or from a firm’s support roster from the detail page; the picker excludes staff already on the roster and the CSM owner, and the backend remains the authority on who may be added. The roster panel shows the live membership with each member’s added date, and an explicit empty state when only the CSM is assigned. The CSM reassignment picker lists real active CSMs. Removal asks for confirmation and keeps its dialog open on failure, surfacing the error as a toast rather than closing as if it succeeded. Every roster, staff, and identity response is parsed against the generated Zod schema via assertContract before the UI trusts a field, and mutations revalidate the firm-detail cache tag.
The same branch also discharges a standing piece of debt this story sat on top of: the hand-written DTO mirrors in src/types/firm.ts were migrated to the generated client per AGENTS.md (“API contract schemas come ONLY from src/gen/”). Thirteen firm contracts re-point to @/gen/types; the stale FirmCreateRequest (carrying csmOwnerUserId instead of the live ownerUserId + supportStaffUserIds) is deleted as drift; and the billing types it composes follow, behaviour-neutral, since the subscription-state members match and the rich callToAction was provably dead. The three remaining hand types — FirmSupportRosterMember, FirmSupportGrantSummary, FirmActivityEvent — are verified consumer view-model projections built by adapters from the wire shapes, not mirrors, and stay.
Verification proof
Gates green: pnpm exec tsc --noEmit, pnpm run lint (0 errors; pre-existing warnings only), pnpm run test (544 passed, including the firm-detail / grants / activity contract tests updated for the new roster fetch), and pnpm run test:storybook (425 passed, covering the reassign, add-to-roster, roster-panel, and detail-view stories with their new picker and empty states).
Contract integrity: the consumed roster, staff, and identity shapes come from src/gen via assertContract; src/types/firm.ts is net −212 lines after deleting the hand mirrors.
Remaining live check: the end-to-end acceptance criterion — an eligible staffer granting themselves access on staging — is owned by the operator and runs on the deployed preview, not on mock or fixture data.