previews / firm-migrations / Imported firm-member activation: tech plan

Imported firm-member activation: tech plan

Status: Tech plan, decisions taken

The files tech plan covers a firm's data and bytes. This page covers its people: an imported person exists in v3 with their matters, files and firm seat, but cannot log in. The research behind this plan traced the whole gap to one row, and this locks the interfaces that close it.

TL;DR.

The pre-existing gap this also closes a cousin of: "Add firm member" with an imported person's email silently creates a duplicate Identity and FirmMember today, stranding their imported matters on the orphan. A guard ships alongside this work (see the build sequence), independent of activation itself.

Problem, goals, non-goals

Problem. The import writes Identity, FirmMember, TeamMember, MatterParticipant and MatterTeamAccess rows. Auth resolves the Clerk JWT's user id through Credential.externalId and throws NOT_FOUND without a match (getFirmMemberIdentityByClerkId.ts:28-36), and no claim-by-email exists anywhere. So a migrated person can never reach the product, and nothing today can attach a Clerk user to a pre-existing identity.

Goals. Once shipped: an operator activates an import run's members from the imports screen; each activated person can sign in, is forced through /set-password, and sees their matters and files; the invite email is a separate explicit action; re-running activation is free; teardown of an activated run unwinds the bindings it created (and the Clerk users this process created — never an account that existed before the import); an activation report shows who is activated, claimed, skipped, or failed.

Non-goals. Claim-by-email at login (an authentication-surface change; rejected — email is neither unique nor normalised, and pre-provisioning makes it unnecessary). Client-side (client-os) users. Migrating passwords or sessions from v2 — everyone sets a fresh password. Choosing the sign-in first factor (dashboard configuration, confirmation steps below). Bulk Clerk invitations (see alternatives). Parked, wanted later: mirroring firm membership as Clerk organisation membership — v2 tracked it and v3 dropped it; it would allow per-firm auth methods, and deserves its own ticket rather than riding this one.

Domain

erDiagram
    ImportRun ||--o{ FirmMemberActivation : "activates people of"
    FirmMemberActivation }o--|| Identity : "for"
    Identity ||--o| Credential : "logs in via"
    Identity ||--o{ FirmMember : "holds seat"
    FirmMemberActivation {
        string id PK
        string importRunId FK
        string identityId
        string status "EXPECTED | ACTIVATED | FAILED | CANCELLED"
        string clerkUserId "null until activated"
        boolean clerkUserCreated "false = claimed an existing account"
        boolean inviteSent
        string failureReason
    }
    Credential {
        string externalId "clerk user id"
        string externalProviderInstanceId "clerk instance"
        string identityId UK
    }

Contract

1. The identity-service verb (owns Clerk and the credential)

// identity-service, internal (service-to-service) procedures
identity.internal.activateImportedIdentity({
  identityId: IdentityId,
  requestedBy: IdentityId,      // the operator, named on every log line
  firmId: FirmId,               // the firm the login is for — must hold a live membership
  // An existing Clerk account is someone's property: claiming it (binding it to
  // this identity) happens only when the operator explicitly allows it.
  allowExistingAccount?: boolean,
  // Send the Knock invite as part of this activation. Off by default —
  // inviting is a separate, deliberate operator action.
  sendInvite?: boolean,
  // Non-production only: activate against a test mailbox instead of the
  // imported contactEmail. Refused in prd, and refused anywhere unless
  // ACTIVATION_EMAIL_OVERRIDE_ALLOWED is set — absent means no.
  emailOverride?: string,
}) => Promise<
  | { outcome: "ACTIVATED"; clerkUserId: string; clerkUserCreated: boolean; inviteSent: boolean }
  | { outcome: "ALREADY_ACTIVE"; clerkUserId: string }   // credential exists — idempotent
  | { outcome: "REFUSED"; reason: "NO_EMAIL" | "EMAIL_IN_USE_BY_OTHER_IDENTITY" | "EXISTING_ACCOUNT_NOT_ALLOWED" }
>

// teardown's counterpart: drops the credential; deletes the Clerk account only
// when asked, and the caller only asks where activation itself created it
identity.internal.deactivateImportedIdentity({
  identityId: IdentityId,
  requestedBy: IdentityId,
  deleteClerkUser: boolean,
}) => Promise<
  | { outcome: "DEACTIVATED"; clerkUserDeleted: boolean }
  | { outcome: "NOT_ACTIVE" }   // no credential — idempotent
>

Steps, all existing shapes: refuse unless the identity is a live USER with a non-deleted membership of the named firm; lowercase the email; users.getUserList({ emailAddress }) — found means an existing account, refused unless allowExistingAccount (claimed, clerkUserCreated: false); else users.createUser({ ..., skipPasswordRequirement: true }) with no user metadata — verified: nothing in v3 reads Clerk user metadata, so bare users are safe; credential.create({ externalId, externalProviderInstanceId: instance.get().id, identityId }); ensureIdentitiesSynced so the Knock user exists and email can deliver. A credential already on the other Clerk instance is a conflict, never a silent success. Refusals: NO_EMAIL (imported identity has no contactEmail), EMAIL_IN_USE_BY_OTHER_IDENTITY (the email's Clerk user already holds a credential bound to a different identity — never rebind), EXISTING_ACCOUNT_NOT_ALLOWED (a claim the operator did not opt into).

2. The import-service orchestration (owns the ritual)

// events
"import-service/firm-member-activation.batch.requested"  // { importRunId, sendInvites, allowExistingAccounts, afterIdentityId? }

// control surface (gateway derives from these, like placement's)
imports.admin.requestFirmMemberActivation({ importRunId, identityIds?, sendInvites: boolean, allowExistingAccounts?: boolean })
  => Promise<{ expected: number }>          // waiting rows for the run + batch requested
imports.admin.requeueFailedFirmMemberActivations({ importRunId, identityIds?, sendInvites: boolean, allowExistingAccounts?: boolean })
  => Promise<{ requeued: number }>          // FAILED → back in the waiting room (e.g. after a fixed email)
imports.admin.getFirmMemberActivationReport({ importRunId })
  => Promise<{ totals: { expected, activated, claimed, failed, cancelled, invited }, people: PersonActivationRow[] }>

requestFirmMemberActivation reads the run's imported identities from the receiving registry (ImportedRecord where entityType = "Identity"), refuses any named id the registry does not hold, writes create-only FirmMemberActivation rows (skipDuplicates — asking twice is free), and emits the batch event. The worker is single-flight per run, pages people in batches of 10 with a pause (dev Clerk BAPI limit is 100 requests per 10 s), and calls the identity verb per person with the run's own actorIdentityId and firmId — the invite, when sendInvites is on, rides the verb itself (identity-service holds the email, the firm name, and the Knock client), so an invite can never go out for an activation that did not happen. Each row settles by compare-and-set; a Clerk throttle or outage is retried, never blamed on the person; refusals settle FAILED with the reason, and requeueFailedFirmMemberActivations puts a fixed one back.

3. Teardown learns about credentials (closes a live FK hazard)

Today, tearing down a run whose members were activated fails on a foreign-key violation — credentials are not in the registry and do not cascade — and every import-teardown cycle leaks Clerk seats into the dev cap. Teardown first cancels the run's still-waiting rows (so an activation batch mid-flight cannot mint a login behind the walk's back), then, ledger-driven via deactivateImportedIdentity: for each ACTIVATED row, delete the Credential — the binding this run created; delete the Clerk account only where clerkUserCreated is true. An account that existed before the import was never the run's to delete, whichever way it got bound. Rows settle CANCELLED, and identity teardown proceeds as it does now.

4. Admin surface

A "Members" panel on RunDetail beside the placement panel, same grammar: totals tiles (expected / activated / claimed / failed / invited), a per-person table (searchable by name/email, capped at the source like the matter rollups), and the same refusal rules (real runs only, not torn down). Each waiting row carries a checkbox, and the action button follows the selection — Activate 2 selected when rows are ticked, Activate all 87 when none are — passing the ticked identityIds to requestFirmMemberActivation. That selection is how a pilot works: activate five people for testing and training, watch them land, then activate the rest with one click. Failed rows get a Try again matching placement's. The send-invites toggle defaults off, and switching it on puts a confirm dialog in front of the action — it names how many people will be emailed, so a quiet activate-and-verify pass can never send a firm's inboxes anything by accident.

Runs· 6New runStatusKindRunRUNNINGIMPORTimprun_…_6_2026…COMPLETEDIMPORTimprun_…_5_2026…TORN_DOWNIMPORTimprun_…_4_2026…RUNNINGreal importCancelResumeTeardown (dry)TeardownRunimprun_platform-v2_firm_szntlk…_6_20260826T101500ZFirmfirm_szntlk8kzj2bsbmlDumpfirms/lawhive-legal-ltd/target/20260826T090000Z/data2BUCKETS7CELLS OK0FAILED107ROWS WRITTENFile placement36 files waitingRequeue failedRequest sweep28PLACED0QUARANTINED0FAILED21AWAITING SCAN7SCANNEDCounted from the placement ledger rather than from the bucket. …newMembers12 awaiting activationSend invitesActivate 2 selected12EXPECTED76ACTIVATED9CLAIMED2FAILED74INVITEDSearch name or email…PersonEmailClerkStatusPriya Natarajanpriya.n@firm.co.ukaccount createdactivatedDaniel Okafordaniel.o@firm.co.ukclaimed existingactivatedAmara Boatengamara.b@firm.co.ukwaitingSofia Marchettis.marchetti@firm.co.ukwaitingShowing 4 of 87 in this run, waiting first. 2 selected — the button activates only them.Counted from the activation ledger. Invites go only to people whose activation created the login, never at import time.
Where it lands: the imports screen as it is today (runs list, run card, File placement panel), with the Members panel as the one addition — same components, same grammar, one card lower on the same run. Waiting rows carry checkboxes, so a pilot subset activates first and the rest later.

5. The person's own path (all existing product)

Knock email → /sign-in → first factor (see confirmation steps) → passwordEnabled === false/set-password forces a password → home → their matters, with their files. Nothing new is built here.

What must be confirmed in dashboards (decision 4, held open)

The recommendation is email-code first sign-in with zero new code, but two things live outside the repo and gate it:

  1. Clerk — confirmed (2026-08-26): the lawhive instance has "Sign-in with email" with email verification code enabled, so a password-less user signs in with an emailed code and /set-password takes over. Remaining: the dev and prd Clerk instances are configured independently — glance at the other instance's User & Authentication before its first activation. Fallback if a factor ever disappears: a sign-in-token page (signIn.ticket + signIn.finalize — one new page, links delivered through Knock).
  2. Knock — confirmed (2026-08-26): lawyer-invited-to-firm's email step already fits migrated firm members — "click the button to sign in; you'll be prompted to set your password on first login", with the button linking vars.v3_legal_os_base_url (the root bounces unauthenticated visitors to /sign-in, so it lands right). Two dashboard-only touch-ups before the first real invite: the email opens with {{ actor.name }}, and activation's actor is the migration identity — give it a presentable name or pass the firm's lead contact as actor; and one optional clause ("we'll email you a sign-in code") saves a password-less user hunting for the email-code method on the sign-in form.

Dev test plan (decision 1)

The dev Clerk instance is hard-capped at 100 users, and most v2-dev people are Lawhive employees who may already hold Clerk accounts — those exercise the claimed path (with allowExistingAccounts deliberately switched on for the test). The must-pass test is the fully-new user: activate the single staged matter's participants with emailOverride pointing at a fresh, non-staff mailbox and sendInvites explicitly on; receive the Knock invite; sign in with no pre-existing account; set a password; see exactly one matter and its 36 files. Then a small pilot subset (≤10) without overrides. The full ~90 happens only where the instance is uncapped.

Risks

Build sequence

  1. activateImportedIdentity (identity-service): the verb, its refusals, and tests incl. the claim path and the never-rebind rule.
  2. Activation ledger + worker (import-service): FirmMemberActivation migration, the batch worker, the control-surface procedures, teardown's member step. The placement suite is the template.
  3. Admin panel (platform-api passthroughs + admin-app): the Members panel and button (send-invites behind a confirm dialog), component-tested like placement's.
  4. Add-member duplicate guard (independent, any time): creating a firm member whose email matches an identity that already holds a seat in the firm is a CONFLICT with a message naming the person.
  5. Dashboard confirmations (human, before the pilot): Clerk first factor; Knock template.
  6. Dev proof: the fully-new-user single-matter test above, then the pilot subset.

Each step ships alone: after 1 nothing calls the verb; after 2 activation works from a shell; after 3 it is an admin feature; 4 is orthogonal.

Success

The dev proof passes end to end with a fresh mailbox; re-running activation on the same run activates nothing twice; tearing the run down removes the credential and the created Clerk user and the seat count returns to its starting value; the activation report's totals reconcile with the identity registry's count.

Sources

Files tech plan · research: provisioning-flow, identity-gap and Clerk-mechanics sweeps (2026-08-26, session artifacts) · getFirmMemberIdentityByClerkId.ts · getOrCreateFromClerk.ts · createClerkUser.ts · db-seeding/setupClerkUser.ts · Clerk system limits · Clerk environments