The boundary schema for the first import slice, how it stays true to what the services accept, and the decisions that came out of review — every fork raised is now settled.
Lukas Ondre · 2026-08-10 · 16 min read
Status: Reviewed, ready to accept
Reviewed and settled.
This went out as a proposal, collected review from Sean and Daniele, and every fork it raised is now decided — the answers are folded into the Decided table below. This is the contract between staged import data (BigQuery) and the import service, for the first slice: one matter with its participants. The contract is defined by what platform v3 needs, never by a source system. Data can come from v2, a practice management system, or spreadsheets; each source gets a transform into this one shape, and v2 is simply the first.
Assumed already landed: Matter.humanReadableId is a per-firm-unique string (the human-readable ids doc), and the ImportedRecord registry exists in each service database, tracking (entityType, externalRef) → entityId per firm and source system — shared across runs, so later runs resolve what earlier runs imported (the data provenance doc). Unfamiliar entities: see the v2 and v3 dashboards.
One companion decision is asked for elsewhere, and this doc leans on it: the unit model (how the engine writes: units, buckets, pillars, incremental runs, failure containment). What is decided here is only the staged contract for the matter slice: the two tables, their fields, and the rules that shaped them.
TL;DR. Two BigQuery tables, matters and matter_participants, shaped like the v3 target and validated by a zod contract that lives in the import service. Staged rows carry no v3 ids at all: every reference is an externalRef resolved through the registry, and the target firm (created by hand before any run) lives in run config. The contract composes from matter-service's own published schemas, dbt derives its models and tests from the same contract, and the importer derives everything derivable (v3 ids, team access, provenance) instead of staging it. The handoff is decided too: the staged tables export as sorted JSONL files per entity on S3, and the importer streams them from there. One worked example takes a v2 Case all the way through.
The proposed schema
Two BigQuery tables, matters and matter_participants. The zod contract is the source of truth; the BQ tables are the same schema rendered as dbt models (snake_case names, string→STRING, date→TIMESTAMP, boolean→BOOL).
// the source system's id for a record; branded per source systemconst externalRef = z.string().min(1);// One staged row per matter to createexport const matterImport = z.object({ // unique; becomes ImportedRecord.externalRef. // The v3 Matter gets a fresh mat_ id externalRef, // the firm's own matter reference (e.g. "25/16095"); // lands directly on Matter.humanReadableId (per-firm unique) humanReadableId: z.string().min(1), title: z.string().min(1), // generated: OPENING | ACTIVE | COMPLETED | CANCELLED // every source's status vocabulary collapses to these (decided) matterStatus: MatterStatusSchema, // the source's own origin/type classification, kept as data; // null when the source has no such concept externalSource: z.string().nullable(), // no firm column: the run targets one firm, whose id lives in // run config; the importer stamps it on every row it writes // generated: US | GB; staged per matter (decided) — realistically // it always matches the firm's, but the state can technically // diverge, so the field stays for flexibility market: MarketSchema, // pre-populated at transform time (decided): GB matters get // "gb-england-wales", the only GB jurisdiction in use. Jurisdiction // ids are static reference slugs, not run-minted v3 ids, so the // no-v3-ids rule stands primaryJurisdictionId: JurisdictionIdSchema, // original source-system timestamps throughout, never import time; // always UTC (decided) — local-time values resolve in the transform createdAt: z.coerce.date(), lastActivityAt: z.coerce.date(), completedAt: z.coerce.date().nullable(),});// One staged row per person on the matterexport const matterParticipantImport = z.object({ // unique; synthetic refs allowed where the source has no row, // namespaced so they stay stable across re-runs (case:<id>:owner) externalRef, // joins to the matter's externalRef matterExternalRef: externalRef, // resolves to a v3 Identity through the ImportedRecord registry — // identities are imported first, by the engine's own people units; // unresolvable → the unit fails personExternalRef: externalRef, // generated: CLIENT | FIRM; must agree with participantType side: MatterSideSchema, // generated: LAWYER | TEAM_MEMBER | CLIENT participantType: MatterParticipantTypeSchema, isLead: z.boolean(), // no firm column: the importer stamps the run's firm on // firm-side participants (LAWYER, TEAM_MEMBER) createdAt: z.coerce.date(),});
Four rules shaped this, for every source and every future slice:
Target-shaped, not source-shaped. Enum columns hold v3 values. Every lossy collapse happens in the transform, where it is reviewable. A new source means a new transform, never a new contract.
One kind of reference. Staged rows never carry a v3 id — not a person's, not the firm's. Every reference is an externalRef, resolved through the ImportedRecord registry, because everything referenced was itself imported — by an earlier unit of the same run, or by an earlier run against the same firm (people land first, as their own foundation units). The only real id a run knows is its target firm's, held in run config: the firm is created by hand before any import happens, and the importer stamps its id onto every row it writes.
Timestamps are source history, in UTC. When the import happened is the ImportedRecord's business. Every staged timestamp is UTC; if a source holds local-time values, the transform resolves them before the canonical schema ever sees them.
Derivable means derived. Staged data carries nothing the importer can compute: v3 ids, firmOfRecordId, MatterTeamAccess rows, ImportedRecord rows, Matter.source. (One deliberate exception: side is staged even though it usually follows from participantType, as a cheap cross-check.)
One prerequisite, owned by an earlier stage — of the same run or of an earlier run against the same firm: people. Identities, firm members, and teams are imported through their own boundary tables, whose contracts follow the same rules as this one and get their own doc, so by the time matter units run, ImportedRecord already resolves every personExternalRef. A matter unit fails loudly on an unresolvable person; it never creates one.
The matter core unit
How the engine writes — unit sizing, pillar independence, failure containment, ordering, incremental runs, healing — is the unit model's decision, and this doc deliberately repeats none of it. All that belongs here is this slice's unit definition. Nothing nested is ever staged; the importer assembles the unit at read time by grouping participant rows onto their matter by the matter reference:
// Importer-internal: assembled by grouping staged rows,// never staged itself. One unit, one transaction.export const matterUnitImport = z .object({ matter: matterImport, participants: z.array(matterParticipantImport).min(1), }) .refine( hasExactlyOneLeadPerSide, "one lead firm-side and one lead client participant required" );
Participants are in because a matter with no lead lawyer breaks access rules the moment it exists; notes and files arrive later as this matter's pillars. Cross-row rules run on the assembled unit, in the refinement above: one lead per side, every participant pointing at the unit's matter, completedAt present exactly when status is COMPLETED. Duplicate identities on one matter are not the unit's problem: the source-specific dbt transform resolves them before staging (decided; see the Decided table).
One schema, enforced end to end
The contract lives in the import service because the services already know what can go in their fields; dbt can't see them. Constraints flow one way:
flowchart TD
servicePackage["matter-service published zod package:<br/>enums, id types, format rules"]
contract["import contract in the import service<br/>+ unit-level invariants"]
codegen["zod → dbt codegen<br/>(zod-to-dbt, decided)"]
dbtModels["dbt models:<br/>enforced contracts + generated tests"]
stagedTables["staged tables (BigQuery)"]
importRun["import run"]
servicePackage -->|"imported as code"| contract
contract -->|"generated in CI"| codegen
codegen -->|"keeps schema.yml in sync"| dbtModels
dbtModels -->|"build + tests"| stagedTables
stagedTables --> importRun
contract -->|"zod-validates every row"| importRun
codegen -.->|"contract versions must match"| importRun
Enums and id types are imported from matter-service's published zod package. When the service adds a status, the contract picks it up on a package bump. Nobody re-types an enum.
Format rules that today live only inside service procedures (say, a string the write path always normalises) get lifted into that published package as refinements. The service's write path and this contract then import the same rule: one definition, both writers.
The dbt surface is generated from the contract (columns, BQ types, nullability, enum values, unique keys), so every contract change shows up in dbt as a reviewable PR instead of surfacing mid-run. Decided: direct codegen with zod-to-dbt (Daniele's DM-6 experiment, PR #13454) — it walks the zod schemas and upserts the zod-owned parts of schema.yml, a scheduled action opens a PR on drift, and a stale drift PR is replaced by a fresh one. No versioned intermediate artifact.
dbt models declare contract: {enforced: true} plus not_null, unique, and accepted_values tests. Every source's transform builds into the same contracted models, so a new source inherits the checks for free.
A run refuses to start unless the staged tables' tests are green and they were built against the importer's contract version. The importer zod-validates every row anyway; CI catches drift at PR time, the gate catches version skew between deploys.
The first source: platform v2
flowchart LR
subgraph v2["Source system (first: platform v2)"]
Case["Case"]
CaseParty["CaseParty"]
PersonV2["Person"]
FirmV2["Firm"]
end
subgraph bq["Boundary schema (BigQuery)"]
mattersTable["matters"]
participantsTable["matter_participants"]
end
subgraph v3["v3 target (platform-v3)"]
Matter["Matter"]
MatterParticipant["MatterParticipant"]
MatterTeamAccess["MatterTeamAccess"]
end
Case -->|"dbt"| mattersTable
CaseParty -->|"dbt"| participantsTable
mattersTable -->|"import service"| Matter
participantsTable -->|"import service"| MatterParticipant
MatterParticipant -.->|"derived at import"| MatterTeamAccess
PersonV2 -.->|"imported first: person units"| v3
FirmV2 -.->|"created by hand before any run"| v3
Not carried from v2: ledger and fee-plan fields (money slice, later), Case.enabledModules (deprecated), companyId/teamMemberId/invitationId (no v3 equivalent; counted in the run report as unmapped).
// matters: one row{ "external_ref": "case_71", // the firm's reference, now a string "human_readable_id": "16095", "title": "Purchase of 12 Acacia Avenue", // SUBMITTED_FOR_CLOSING collapses to ACTIVE (decided) "matter_status": "ACTIVE", // v2's classification, kept as data "external_source": "byoc", // no firm_id: the target firm lives in run config // staged per matter (decided) "market": "GB", // pre-populated at transform (decided): the only GB jurisdiction "primary_jurisdiction_id": "gb-england-wales", // original v2 history "created_at": "2024-03-02T09:14:00Z", "last_activity_at": "2026-07-29T16:02:00Z", "completed_at": null}
// matter_participants: two rows (matter_external_ref "case_71" and// the original v2 created_at on both).// cp_303 (INVITED) never becomes a participant (decided): the importer// records it as unsupported in the ImportRun report, and a later run// backfills it if the invitation gets accepted.// The owner already appears as cp_301, so no synthetic lead row;// dbt only synthesises one ("case:case_71:owner") when the owner// has no CaseParty row.[ { "external_ref": "cp_301", "person_external_ref": "person_anna", "side": "FIRM", "participant_type": "LAWYER", "is_lead": true }, { "external_ref": "cp_302", "person_external_ref": "person_john", "side": "CLIENT", "participant_type": "CLIENT", "is_lead": true }]
Stage 3: what the importer writes
One transaction in matter-service:
// Matter{ // freshly minted "id": "mat_k4p", // the firm keeps the reference it has been citing for years "humanReadableId": "16095", "title": "Purchase of 12 Acacia Avenue", "status": "ACTIVE", "source": "IMPORT", "firmId": "firm_9y2", // derived: the importing firm (decided) "firmOfRecordId": "firm_9y2", "market": "GB", // from the staged row (decided) "primaryJurisdictionId": "gb-england-wales", "createdAt": "2024-03-02T09:14:00Z", "lastActivityAt": "2026-07-29T16:02:00Z", "completedAt": null}
// MatterParticipant rows (matterId "mat_k4p", original created_at).// identityId resolved through ImportedRecord: the identities were// imported by the foundation units of the same run. firmId on the// firm-side row is stamped from run config[ { "id": "matpar_c2", "identityId": "idn_anna", "side": "FIRM", "participantType": "LAWYER", "isLead": true, "firmId": "firm_9y2" }, { "id": "matpar_c3", "identityId": "idn_john", "side": "CLIENT", "participantType": "CLIENT", "isLead": true, "firmId": null }]
// MatterTeamAccess: derived from the lead lawyer, replicating what// createMatter.ts does on the organic path{ "id": "mta_88", "matterId": "mat_k4p", "teamId": "team_5rw" }
// ImportedRecord: one per domain row, same transaction.// importRunId is an opaque, self-describing string; the run table// lives in the engine's own database, so there is no FK[ { "entityType": "Matter", "entityId": "mat_k4p", "externalRef": "case_71", "importRunId": "imprun_platform-v2_firm_9y2_1_20260815" }, { "entityType": "MatterParticipant", "entityId": "matpar_c2", "externalRef": "cp_301", "importRunId": "imprun_platform-v2_firm_9y2_1_20260815" }, { "entityType": "MatterParticipant", "entityId": "matpar_c3", "externalRef": "cp_302", "importRunId": "imprun_platform-v2_firm_9y2_1_20260815" }]
That importRunId points back to the run record in the engine's database (see the import runs doc). The report object below is illustrative only: the report format needs its own design pass off the back of the validation discussion (agreed in review, not blocking this contract):
Forcing imported matters into MARKETPLACE/ADMIN misstates their origin; MatterFileSource already made exactly this move
firmOfRecordId
The importing firm itself, derived at import
First migrations are whole firms bringing their own book of work. externalSource keeps the data to revisit marketplace-sourced cases
Where the contract lives
Import service, composed from published service schemas
The services know what their fields accept; dbt can't see them. dbt consumes the generated contract instead of defining validity
Human-readable id
Imported matters keep the firm's reference as Matter.humanReadableId
This is why the column became a per-firm-unique string: any source system's reference format fits
Participant side
Staged explicitly, not derived from participantType
Cheap to stage and removes a silent derivation; the contract requires the two fields to agree
Warehouse-only metadata
dbt may add run metadata and canonical hashes to its tables; none of it is part of this contract
Hashes can be computed now but nothing consumes them yet; re-runs stay teardown-and-rebuild
v3 ids in staged data
None, ever — not even the firm id, which lives in run config
The importer mints ids at write time and resolves every externalRef through the registry; BigQuery stays free of v3 id semantics
zod → dbt sync
Direct codegen with zod-to-dbt (DM-6); no versioned intermediate artifact
The experiment already exists as code; generating from the dbt side controls update cadence, and stale drift PRs are replaced by fresh ones
primaryJurisdictionId
Staged, pre-populated at transform time in the canonical dbt output (gb-england-wales for GB matters)
Raised by Sean in review: it is the only GB jurisdiction, used by every Woodstock matter in prod. Jurisdiction ids are static reference slugs, so no v3 id leaks into staging
Timestamps
Every staged timestamp is UTC
Raised by Daniele in review: any local-time values in a source are resolved during transform, before the canonical schema
BigQuery → importer handoff
File export: one sorted UTF-8 JSONL file per entity on S3; no direct BQ reads
Confirmed by Daniele in review, with an S3 regulated bucket provided by infra. Zod validates row by row, and sorted per-entity files let the importer stream a matter's related rows without loading whole files — see the last section
Matter market
Stays on the schema, staged per matter
Sean in review: it realistically always matches the firm's, but the state can technically diverge. Staging it costs nothing and keeps the flexibility
Two participant rows, one identity
Resolved in the source-specific dbt transform, not the import logic
Sean in review: v3 allows at most one participant per identity per matter, enforced in application code; the only failure mode (one person as both CLIENT and LAWYER) is unrealistic. How duplicates collapse depends on the source system, so it belongs in that source's transform
SUBMITTED_FOR_CLOSING / REASSIGNING
Both collapse to ACTIVE
Both are legally open. An ACTIVE matter can be closed by hand; a wrongly COMPLETED one is worse. The original status survives in the source data. Unchallenged in review
INVITED parties
Never imported; recorded as unsupported in the ImportRun report; later runs backfill acceptances
An unaccepted invitation is a pending workflow, not participation. Sean's suggestion: subsequent import runs pick up invitations accepted between runs. Detection lives with the importer so all drop-reporting is centralised in one report, not split with a pipeline-side one
Client participants
Stay in the contract; not every import is firm-side only
Jaime asked whether imports ever carry anything but firm-side lawyers. v2 does: clients are platform users with CUSTOMER parties, so they stage as CLIENT participants. Sources without client logins (a typical PMS) represent clients as matter contacts instead — a per-source mapping choice, both paths valid
Status collapse ownership
The collapse table is a per-source mapping rule, signed off with the firm
Jaime in review: which source statuses map where is not ours to hard-code globally; it may even end up firm-specific (e.g. the original status kept as an "Additional Status" custom field once custom fields exist). The contract only fixes the target vocabulary
Row-level provenance
Planned: ImportedRecord grows provenance columns — the canonical rows that fed the v3 record (model names + row hashes) and a hash of the values actually applied
Jaime in review: id checks alone catch duplicate refs, not content drift. Row hashes buy exact dedup on re-runs and let the run detect staged rows no unit ever consumed (the dangling-row check in the unit model). Supersedes "nothing consumes the hashes yet" above
One running log to keep, from review: primaryJurisdictionId is the first of the marketplace-shaped domain concepts an import has to work around. Each one we meet gets collected as a candidate for future domain cleanup rather than silently absorbed.
Decided: handoff from BigQuery to the importer
The last fork went to file exports. The staged tables export from BigQuery as one sorted UTF-8 JSONL file per entity on S3 (matters.jsonl, matter_participants.jsonl, …) — roots sorted by primary key, children by (parent ref, primary key) — and the import service streams them from there; it never queries BigQuery directly. Daniele confirmed the choice in review, and infra provides an S3 regulated bucket.
The two mechanics that had to be worked out before deciding are worked out, with the reasoning in two companion walkthroughs:
Daniele's hierarchical unit load walk — the dump format and the loading algorithm: one file per entity (not per unit), sorted so the loader opens one S3 stream per entity and assembles each unit — a matter plus its participants — with a single forward scan per file, resuming broken streams via byte-range reads.