previews / firm-migrations / Firm provisioning

Platform · Firm migrations

Firm provisioning: the firm exists before the import runs

Where the v3 firm comes from before a firm import, why the seed-scripted rehearsal firm broke, and what Add Firm still gets wrong.

Status: Investigation · recommendation
Ask: review the recommendation

This is the written answer to "where does the v3 firm come from?" (DM-79). It adds no import stage: provisioning stays something you do in admin before the run. It does change code, on the admin side, in the three places named under what still needs code. Companions: import service v2, import runs, dev dry-run lessons, unit model.

TL;DR. Provision the firm in admin with Add Firm before the run, with marketplace enrolment off, an office bank (a placeholder is fine), and one temporary Lawhive admin as the Lead Firm Contact. Point the run at that firm and pick that admin as the owning identity. That replaces seedImportFirm, which we delete. Import itself is not the reason to fill the bank in: a run only checks that the firm and the owning identity exist. The bank matters because without it Legal OS and admin fall over on the firm afterwards. Three gaps still need code: a super-admin cannot really choose the market on the form, Add Firm can leave a half-created firm behind, and the seed script is still on disk.

What we need

A firm that behaves, to every part of the platform except the marketplace, like one that signed up on its own. That is the goal, and it has two halves. A run has to be able to write into the firm, and once the run finishes somebody has to be able to open the firm and judge whether the import did the right thing. The second half is the one that gets dropped, because a run will succeed perfectly well against a firm nobody can log into.

So the target is a firm with a working login, an invoice counterparty, default ledger accounts and a profile row, on a generated id rather than one typed by hand, and with no marketplace enrolment attached. Nothing exotic, and nothing a migration-specific provisioning path needs to invent: Add Firm already produces all of it on a single submit.

What the shortcut produces instead is a shell. A rehearsal firm stood up with the seed script, firm_importtestlawhive, fails the moment anyone tries to add themselves to it in admin:

No counterparty found for firm firm_importtestlawhive and type INVOICE

No office bank means no invoice counterparty, which means the members page cannot create a member. Logins on that firm do not work either (thread). From the outside that looks like marketplace onboarding being forced on a firm that will never touch the marketplace, so the instinct is that provisioning needs loosening. The opposite is true. The rehearsal firm is not too strict, it is too empty: the seed script writes four rows and skips everything else an organic firm gets.

The call

Provision with Add Firm in admin, marketplace unchecked. Delete the seed script.

A run never creates the firm. Provisioning is a thing you do in admin, minutes before, not a stage of the import.

Two facts make that the whole answer. First, createRun refuses a run when the firm id does not resolve (FIRM_MISSING) or the owning identity does not (ACTOR_MISSING), and checks nothing else about the firm. Second, Add Firm already creates, on one submit, everything the seed script skipped.

Desired flow

Same screens as today. What you fill in beyond the obvious is what decides whether the firm works afterwards: the market, enrolment off, the office bank, and one temporary admin.

flowchart LR
  addFirm["Admin: Add Firm\nsteps 1 to 4"]
  fill["You fill in:\nmarket, enrolment off,\noffice bank, temp admin"]
  auto["On save, automatically:\nprofile, default ledger accounts,\ninvoice counterparty, real login"]
  run["Imports: new run\nthat firm plus owning identity"]
  packet["Then the packet:\npeople, contacts, matters"]

  addFirm --> fill --> auto --> run --> packet
1 · Pick the market on the form

A super-admin chooses GB or US here, and that is the new firm's market. Anyone whose admin access is scoped to one market cannot pick the other one, which is the point of the restriction. After you save, the firm list is still filtered by the market in the header, so switch it if the list looks empty. The dropdown does not actually work yet: see What still needs code.

2 · Uncheck "Enrolled to marketplace"

It starts on, and leaving it on pulls in a fee plan nobody wants on a migrated firm. Enrolment off is already a supported path, not a hack: no fee plan is created, and the firm page does not nag for one afterwards.

3 · Fill in the firm, and the temporary admin with it

Name, registered address, an EIN placeholder. The Lead Firm Contact is required on this same form, so the temporary admin is created by the same submit as the firm, not by a later trip to the members page. Give it a name, the role ADMIN rather than LAWYER, and a Lawhive ops email address that does not appear anywhere in the source dump. Import does not create this person, and does not want to.

4 · Fill in the office bank, leave the client account alone

These are two different fields and they get confused constantly. The office bank is required, and it is what creates the invoice counterparty the rehearsal firm was missing. A placeholder sort code and account number is fine for now, because Edit Firm upserts the counterparty when you put the real details in later. Do not pay real invoices out of a placeholder. The client-account bank is a separate optional checkbox: leave it off.

5 · Start the run against that firm

Take the firm id from the page you just created, open Imports, and pick that firm with the temporary admin as the owning identity. A run will not guess who it ran as: that identity lands on every createdBy-style column the boundary does not carry. Dry run and real run use the same firm, so do not create a second one to rehearse against. Creating the firm needs the can_manage_firms grant, starting an import needs is_super_admin, so on a real migration the same person wants both.

Skip the rest of the form: fee plan, marketplace, lawyer products, default jurisdiction, client-account bank, extra members, telephony, Stripe.

What the seed script left out

The seed script and Add Firm are trying to do the same job. The difference is how much of the firm each one actually builds.

On submitseedImportFirmAdd Firm
Firm rowyes, with an id you typedyes, generated
Firm profileyesyes
Lead contact / admina bare identity rowa real user, with a login
Login (Clerk, Knock)noyes
Default OFFICE and CLIENT ledger accountsnoyes
Invoice counterpartynoyes, from the office bank
Fee plannoonly if you enrol

The pers_migration_* identity the script writes exists for exactly one reason: a run needs some identity to stamp on createdBy. It can never sign in, so nobody can open the firm it belongs to and check what the import did. That is the whole gap between "the import wrote its rows perfectly" and "the firm works".

What import actually needs, and what it does not

Worth stating plainly, because the Slack thread reads as though the import demanded a bank account. It does not.

A run needs

The firm row to exist. The owning identity to exist. Nothing else: no bank, no counterparty, no marketplace state, and the owning identity does not even have to be a member of the firm.

Legal OS and admin need

That admin to be able to log in. Default ledger accounts. An invoice counterparty, which comes from the office bank. Without these the import succeeds and the product still looks broken.

Firm members come from the dump, in the people pass, not from Add Firm. The temporary admin is scaffolding for the migration team, not the firm's staff: real users and their logins are their own piece of work.

So filling in the office bank is not an import requirement we are working around. It is the difference between a firm you can open afterwards and one that throws on its members page.

What still needs code

Three gaps, each worth its own ticket. None of them block a rehearsal today.

A super-admin cannot really choose the market

The dropdown is on the modal, and it is ignored. The mutation strips market out of its input and injects the market from the admin header instead, so a GB-scoped admin cannot create a US firm by accident. That guard is right for a market-scoped admin and wrong for a super-admin, who is the person standing up a migration firm and knows which market the dump belongs to.

Change: take the market from the form when the caller is a super-admin, keep the header for everyone else, and lock the dropdown when it is not yours to change. Worth a test that a super-admin can create a firm in the market that is not their header.

Add Firm can leave a half-created firm behind

Creating a firm hops identity, then default accounts, then the counterparty, with login as a fourth actor, and no transaction spans them. Identity rolls back the firm row if the profile or temp admin fails. Nothing after that does. A failed counterparty leaves a firm you cannot use, and pressing submit again creates a second one.

Inline undo will not save that. It runs inside the request that is already failing, so a timeout takes the rollback with it, and firm.delete hits a foreign key as soon as a member or account exists. Teardown has to be its own operation: a button on the firm page, idempotent, safe to retry. The failed create can call the same operation, and that call is allowed to fail, because the button is the backstop.

It is not a hard delete (members soft-delete, accounts archive, counterparties go inactive) so the id stays spent, and the button needs guards: empty firm, name typed to confirm, can_manage_firms, an audit record. Record a provisioning state on the firm at the same time, so the list can say incomplete and a second button can finish the upserts instead of tearing down.

The seed script is still there

Delete seedImportFirm. Do not wrap it around the real create.

It is a laptop shortcut, not a shared-env path. The script's own comment says as much: a run never creates the firm, "and locally that means this script." The import-service README already tells people to use Add Firm anywhere shared, and calls the script a shortcut that is on its way out. Tests do not use it; they seed through a different helper.

That still leaves it on disk for the next person following the README's local path, and that is how firm_importtestlawhive happened: someone used the laptop shortcut against a shared firm. Two provisioning paths is how we got here, and the shortcut is only a shortcut because it skips the parts that matter. Local rehearsal uses Add Firm against a local admin, same as everywhere else.

Follow-up work to name

  • Honour the form's market for super-admins, keep header injection for everyone else
  • Build firm teardown as its own idempotent operation, with a button on the firm page, and have the failed create call it
  • Record a provisioning state on the firm so an incomplete one is visible in the firm list
  • Delete seedImportFirm and point the import-service README at Add Firm
  • Optional: default enrolment to off, or force an explicit choice on the form

Open questions

Should a non-marketplace firm need a bank at all? Locked for now: fill it, because the invoice counterparty hangs off it and admin breaks without one. Still open as a product question, whether a firm that never bills through us should be asked for bank details on create at all.

Does the run care about the market? Locked: it does not check. A run resolves the firm by id and stamps it, while matters in the packet carry their own market. So a GB packet can be loaded into a US firm and nothing refuses it. Still open whether the run should compare the firm's market against the packet, which needs a firm-level market on the dump rather than an inference from matter rows.

Who owns the temporary admin afterwards? It holds a Lawhive ops email and firm-admin rights on a real firm. Still open: whether it gets removed at cutover, downgraded, or kept as the migration team's way in.