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.
- Lawyer login resolves a Clerk user id to an
Identitythrough exactly one table:Credential. The import writesIdentityandFirmMemberbut no credential, so an imported person is hard-401 at the front door — and everything past that door already works: matter lists and matter access read the imported participant and team-access rows as they are. - Activation is an imports-admin action, like placement: an operator activates a run's firm members from the run screen, an Inngest batch mints a Clerk user and a
Credentialper person, and a ledger records what was created so teardown can unwind it. Claiming an email's existing Clerk account is refused unless the operator explicitly allows it. - Inviting people is a separate, deliberate step — never automatic. Activation mints logins quietly; the Knock invite (
lawyer-invited-to-firm, same as regular sign-up) goes out only when the operator explicitly asks, behind a confirm dialog, so a Sunday activation-and-verify pass emails nobody and the firm announcement happens on its own schedule. The person signs in, and legal-os's existing/set-passwordgate makes them create a password. No new user-facing UI. - Clerk invitations are deliberately not used: they mint a Clerk id at accept time that nothing links back, which is either a hard 401 or a duplicate identity.
- The dev Clerk instance is a development instance with a hard 100-user cap, so dev activates a pilot subset only — including one fully-new, non-staff email to prove the whole path.
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
Credential(identity-service, exists): the one link from a Clerk user to anIdentity.identityIdis unique — one login per identity, ever — andexternalProviderInstanceIdmust carry the Clerk instance id, or the first login takes a legacy lazy-fill branch.FirmMemberActivation(import-service, new): the activation ledger, one row per person per run, mirroringFilePlacement's discipline — create-only rows, compare-and-set settling, an Inngest batch worker. It records the one fact nothing else holds: whether activation created the Clerk user or claimed one that already existed, which is what teardown needs to know what it may delete.- The seam: identity-service owns credentials and Clerk; import-service owns the migration ritual and its ledgers; the admin imports screen is the only operator surface. Knock stays the only invite channel, and inviting is its own operator action. Which Lawhive role each person gets is the transformation's decision (the dbt pipeline maps source-system roles onto
FirmMember.rolein the packet); the import writes it verbatim and activation never touches roles.
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.
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:
- 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-passwordtakes 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). - 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 linkingvars.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
- Shared emails: two imported people with one email — the second activation returns
EMAIL_IN_USE_BY_OTHER_IDENTITYand settlesFAILEDwith that reason; resolving it is a human decision, visible in the report. Accepted. - Claiming an existing account is opt-in everywhere (
allowExistingAccounts, default off): a person whose email already has a Clerk account refuses withEXISTING_ACCOUNT_NOT_ALLOWEDuntil the operator explicitly allows the claim — an accidental cross-tie of someone's existing account to an imported identity is a refusal in the report, not a silent bind. When a claim is allowed, teardown still leaves the account alone (claimed, not created) and removes only the binding. Domain filtering (claim only when the email's domain matches the firm's) is a possible further safeguard, not built. - Email normalisation: the import does not lowercase
contactEmail; the verb lowercases before every Clerk call so lookups cannot fork on case. The import gains lowercasing at the boundary as a follow-up. - Invite mis-fires:
sendInvitesdefaults off at every layer and the admin toggle sits behind a confirm dialog, so activation on its own emails nobody — a dress rehearsal or a pre-announcement verification pass is silent by construction. Invite branding and copy for a non-Lawhive-flavoured firm get checked before the first real send (the Knock touch-ups above). - Alternative rejected: Clerk invitations — the invite mints a Clerk id at accept time that nothing links to the imported identity; making that safe needs a
user.createdwebhook linker (today log-only, Hookdeck-routed) and an email-matching rule, which is the exact auth-surface change this design avoids.
Build sequence
activateImportedIdentity(identity-service): the verb, its refusals, and tests incl. the claim path and the never-rebind rule.- Activation ledger + worker (import-service):
FirmMemberActivationmigration, the batch worker, the control-surface procedures, teardown's member step. The placement suite is the template. - Admin panel (platform-api passthroughs + admin-app): the Members panel and button (send-invites behind a confirm dialog), component-tested like placement's.
- 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
CONFLICTwith a message naming the person. - Dashboard confirmations (human, before the pilot): Clerk first factor; Knock template.
- 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