previews / firm-migrations / unit model

Platform · Firm migrations

Units, buckets, and pillars

One decision to approve or veto: the engine's unit of work is semantic, not tabular. Firm-level units, then per-matter buckets of a core plus independent pillars, executed as scoped, incremental, idempotent runs.

Status: Proposal, asking for a design decision
This doc asks for one decision.

The migration engine imports data as semantic units (firm-level units, matter buckets, and per-matter pillars) rather than loading staged tables entity by entity. It is deliberately schema-free: which staged tables exist and what columns they carry is separate work that can evolve without reopening this decision. Here we only care about how data lands, and why.

Companion docs: data provenance (the ImportedRecord registry this model leans on), import runs (the run record and the firm-exists-first rule), and the matter boundary schema (the staged-table contract for the first slice).

TL;DR. The atom is a unit: the smallest set of staged rows that must land atomically for v3 to be valid at every commit point. Units form a hierarchy that mirrors meaning, not table layout: one unit per person, team, and contact at firm level, then one bucket per matter holding a core unit (the matter plus its participants) and independent per-kind pillars (its files, its notes, its tasks). Any pillar can fail without touching its siblings, the run report is a grid of matters × pillars, and runs are scoped, incremental, and idempotent against a firm that always exists before any import.

Two facts about the setup are all the context needed:

  1. Staged data is flat tables in BigQuery, one per kind of record, shaped by what v3 needs rather than by any source system, and carrying no v3 ids. Rows reference each other by externalRef, the source system's identifier.
  2. The ImportedRecord registry is a table in each receiving service's database mapping (sourceSystem, entityType, externalRef) to the v3 id, per firm, written in the same transaction as the record it describes. It is how anything imported earlier gets found later, how re-runs stay idempotent (a unique constraint makes them no-ops), and how every imported v3 row traces back to a run.

The firm itself is created by hand before any import and no run ever creates or mutates it; run config carries its id. That rule is what makes everything below incremental.

The problem the model has to solve

A firm migration writes tens of thousands of rows across half a dozen v3 services: people, matters, participants, contacts, files with real blobs behind them, notes, tasks. Three properties of that workload shape everything:

So the question is: what is the right size of atom? Too big, and one bad row poisons everything committed around it. Too small, and validity shatters across commits while the run dissolves into tens of thousands of transactions nobody can reason about.

The model

The atom is a unit: the smallest set of staged rows that must land atomically for v3 to be valid at every commit point. One unit, one transaction, one independent success or failure. Units are grouped into a hierarchy that mirrors what the data means, not which table it sits in:

flowchart TD
    firm["Firm: created by hand, before any run.<br/>Run config holds its id; no run ever creates or mutates it"]

    subgraph run["an import run: any scoped subset of this structure"]
        subgraph firmLevel["firm-level units, one per real-world record"]
            personU["person unit × N<br/>identity + memberships"]
            teamU["team unit × N"]
            contactU["contact unit × N<br/>contact + its methods + addresses"]
        end

        subgraph matterBucket["matter bucket × N, one per matter"]
            coreU["matter core unit<br/>the matter + its participants"]
            p1["files pillar"]
            p2["notes pillar"]
            p3["tasks pillar"]
            p4["comms pillar"]
            p5["…more pillars as<br/>scope grows"]
        end
    end

    firm --> firmLevel
    firmLevel --> coreU
    coreU --> p1
    coreU --> p2
    coreU --> p3
    coreU --> p4
    coreU --> p5

Three kinds of node:

Dependency order falls out of the hierarchy: firm-level units before matter cores, cores before their own pillars, and nothing else ordered at all. Matter B's pillars never wait for matter A's.

Why pillar granularity, precisely

The pillar is the deliberate middle of a spectrum:

unit = rowunit = pillar (chosen)unit = whole matter
Blast radius of one bad noteone notethat matter's notesthe whole matter
Transactions for a 1,000-matter firm~100k~7k~1k
Multi-row invariantsnowhere to check themchecked at the unit where they livepossible but entangled with content
Report legibilitynoisea matters × pillars grid"failed" hides what failed
Retry costtrivial but endlessone cellre-copy every blob on the matter

What failure looks like, by design

Every unit failure is contained, counted, and reported. The run always finishes and always produces the grid. Blast radius, level by level:

Failing unitBlast radius
person unitthat person, plus any matter core whose participants reference them. A matter without its lead lawyer fails its own validation, before anything is written
team / contact unitthat record, plus the specific downstream cells that reference it
matter core unitthat matter's bucket; its pillars never run
a pillarthat pillar of that matter; sibling pillars and the core stand

Nothing in that table fails the run. The run is an orchestration, not a transaction: it lands everything that can land and says precisely what could not.

run report (illustrative)

matter        core   files   notes   tasks   comms
SMITH-2019     ✓       ✓       ✓       ✓      ✓
JONES-2021     ✓       ✗       ✓       ✓      ✓     ← blob checksum, 1 file
KHAN-2023      ✗       ·       ·       ·      ·     ← unresolvable person ref
1,204 others   ✓       ✓       ✓       ✓      ✓

Healing is the same operation as importing. The registry's unique constraint makes every unit idempotent, so "fix the staged data and run again" is always safe: units that already landed no-op, and the failed cells run for real. Healing JONES-2021's files never touches SMITH-2019, never re-copies landed blobs, and never asks for a fresh firm.

One failure mode is invisible by construction and needs its own guard (raised by Jaime in review): a staged row that never assembles into any unit. Assembly must be provably exhaustive, so the run reconciles at the end — every imported v3 record tracks which canonical rows fed it (model names plus row hashes), and staged rows consumed by no unit are reported as dangling instead of silently skipped.

Runs are incremental

A run executes any scoped subset of the hierarchy against the firm. This works because every unit resolves its references through the registry rather than through in-memory state, so it makes no difference whether the thing referenced landed thirty seconds ago or three weeks ago.

flowchart LR
    provision["firm created<br/>by hand"]
    run1["run 1<br/>all people + teams"]
    run2["run 2<br/>matters, batch 1"]
    run3["run 3<br/>matters, batch 2"]
    run4["run 4<br/>re-run failed cells<br/>from runs 2 and 3"]
    run5["run 5<br/>comms pillars only,<br/>all matters"]

    provision --> run1 --> run2 --> run3 --> run4 --> run5

This buys the operational shapes a real migration needs, none of which an everything-or-nothing engine can do:

A run's precondition is only that its units can resolve their references. A matters-only run against a firm with no imported people fails loudly at validation, before any writes.

Approaches considered and rejected

A. Entity-by-entity table loading

The default shape everyone reaches for first: load the matters table, then participants, then notes, then files, each table across the whole breadth of the migration, like a warehouse backfill.

flowchart TD
    subgraph tableWise["table-by-table: failure mid-run"]
        t1["matters: 1,207 / 1,207 ✓"]
        t2["participants: 1,207 / 1,207 ✓"]
        t3["notes: 604 / 8,000 ✗ crashed"]
        t4["files: 0, never ran"]
        t1 --> t2 --> t3 --> t4
    end

    subgraph unitWise["unit-based: same failure"]
        u1["512 matters: fully live ✓"]
        u2["1 matter: notes pillar failed ✗"]
        u3["694 matters: not started"]
    end

Why it loses:

Its one genuine advantage, bulk-insert throughput per table, is not the bottleneck. Migrations are constrained by correctness and operability, not insert speed.

B. One transaction per firm

Atomicity at the migration level: the whole firm lands or nothing does. Rejected on three grounds:

Even simulated with compensating teardown, it turns every small failure into the maximum event.

C. Matter mega-units (no pillars)

The tempting one. It keeps the semantic story ("a case lands whole") but couples the durable, cheap parts of a matter to its riskiest part, blob transfer, and makes the most common failure (one bad file) cost the most valuable thing (the whole case). The pillar split exists so the risky parts can fail without touching the sound parts.

D. Row-level units everywhere

Maximum granularity, minimum semantics. Where multi-row invariants exist (matter plus participants) it is simply incorrect, because the invariant has no unit to be checked on. Where they don't (notes), it buys nothing that pillars don't already give, at over ten times the transaction count.

Costs accepted

The model is not free:

Ask: approve or veto one decision

That the migration engine adopts, as settled design:

  1. Unit = smallest atomically-valid set of rows; one unit, one transaction. Unit boundaries are defined by v3's validity invariants, not by table layout.
  2. The hierarchy: firm-level units, then per-matter buckets of a core plus independent pillars, with dependency order firm-level → core → pillars and no ordering between siblings.
  3. Runs are scoped and incremental: any subset of the hierarchy, any number of runs per firm, resolution across runs through the registry, and the firm itself always created by hand outside the engine.
  4. Failure is contained and reported at cell granularity (matter × pillar), and healing is re-running failed cells, idempotent through the registry.

Everything schema-shaped, meaning which staged tables exist and what each pillar contains at each stage of delivery, is defined separately and evolves without touching this decision.