The boundary schemas define the shapes any source system must be transformed into before the import service brings a firm live in v3. Platform v2 is the first source. The job here was to take v2's data model, entity by entity, and work out where each one lands, then prove the answer by actually producing the staged files from the dev database.
This is the v2 mapping reference: for each boundary schema, which v2 entities feed it, how their meaning translates, and what had to be adjusted or left behind. Use it to review whether the mapping is right. It is not a proposal about the boundary schemas themselves; it takes them as they stand. Every claim on this page comes from a real run against the full v2 dev database, and every v2 entity links to the v2 domain model dashboard so you can check the source meaning in one click.
How to read this page
A few terms carry the whole story:
- A canonical schema (or boundary schema) is one target table shape, like
matteroridentity. Each one gets its own staged file. The full catalogue lives in the boundary schemas doc. - An external ref is the v2 record's own id, carried along so every imported v3 record can be traced back to its source row. Nothing in the staged data uses v3 ids.
- A fix-up is a small, deterministic correction applied during mapping, for example demoting a duplicate lead flag. Every fix-up is counted and logged with example ids.
- An exclusion is a v2 row deliberately not staged, with a recorded reason, for example a soft-deleted file.
Each schema section below follows the same shape: what the concept means in v2 and v3, the column mapping, and the edge cases the real data surfaced. If you only want the totals, the next section has them.
What one run produced
The run scoped to Firm frm_szntlk8kzj2bsbml (Lawhive Legal Ltd), which owns 714 of the dev database's 715 cases:
| Staged file | Rows | Fed by |
|---|---|---|
identity.jsonl | 419 | Person, classified via FirmEmployee / Solicitor / Customer |
firm_member.jsonl | 161 | FirmEmployee (active seats) |
team.jsonl | 26 | Team |
team_member.jsonl | 72 | TeamMember, plus 23 synthesised owner rows |
contact.jsonl | 33 | CaseExternalParty, CaseMailContact, Company |
contact_method.jsonl | 0 | nothing in v2 carries per-contact methods |
contact_address.jsonl | 4 | CaseMailContact address columns |
matter.jsonl | 714 | Case |
matter_participant.jsonl | 1,476 | CaseParty (assigned), plus 113 synthesised client leads |
matter_contact.jsonl | 33 | the three contact sources, linked per case |
matter_file_folder.jsonl | 167 | CaseFileFolder |
matter_file.jsonl | 1,531 | CaseFile joined to File |
matter_note.jsonl | 223 | CaseNote |
key_date.jsonl | 0 | no v2 concept |
task.jsonl | 0 | no v2 concept |
chat_message.jsonl | 1,138 | Message (text) via CaseChannel |
call.jsonl | 67 | Call |
The verifier's verdict on that output: sorted, referentially closed, and the loader walk consumes every row. It raised one warning class worth reading about: 133 matters have no client-side participant at all. The participants section explains why that is genuine v2 reality rather than a mapping bug.
The mapping, schema by schema
External refs are always the v2 record's own id. Where the mapping has to invent a row that v2 never stored, the invented ref is namespaced (for example case:<id>:owner) so it stays identical on every re-run.
The real pipeline will run these mappings as dbt models over the raw v2 tables in BigQuery, which arrive shaped exactly as they came out of the database dump. Each section below ends with a collapsible sketch of what that dbt model might look like. The sketches are there to make the mapping concrete, not to be copied verbatim; column-level tests, sources, and the sorted export models are left out.
1. Person → identity
v3 keeps one Identity per human being. v2's equivalent is Person: one row per person known to the platform, with the roles they play (lawyer, client, firm staff) hanging off it as separate records. The mapping exports every person who is actually referenced by something else being imported, and works out what kind of identity they are:
- a person holding a seat at the firm with a solicitor role becomes a
LAWYER - a person holding a seat without one becomes
STAFF - everyone else, typically someone with a
Customerrecord, becomes aCLIENT
| Boundary column | v2 source | Notes |
|---|---|---|
external_ref | Person.id | only people referenced by some staged row are exported |
identity_type | derived | from the role records described above |
first_name, last_name | firstName, lastName | four nameless dev-data people got an Unknown placeholder |
contact_email | emailAddress | unique per person in v2, so it doubles as a dedupe key |
contact_phone | phoneNumber | |
created_at | createdAt | the v2 creation time, as the conventions require |
Worth knowing:
- v2's
Person.status(invited vs registered) is dropped. Imported identities carry no login either way; how imported people log in is a separate workstream. - People with soft-deleted firm seats still get an identity when they authored notes or files, so history keeps its authors.
A real example from the dev export
// v2 source: Person (the lawyer on case 20345, "Franchise Agreement")
{
"id": "psn_itt7xgkh7qh8tjuz",
"firstName": "Arthur",
"lastName": "Pemberton",
"emailAddress": "arthur.pemberton@example-firm.test",
"phoneNumber": null,
"status": "REGISTERED", // dropped: login state does not migrate
"hasConfirmedDetails": "f", // dropped: v2 UI state
"hasUploadedProfilePhoto": "t", // dropped, along with photoUrl
"createdAt": "2025-06-24 13:54:17.089",
"updatedAt": "2025-06-26 14:23:23.45"
}// canonical: identity
// LAWYER because his firm seat carries the solicitor role (see next section)
{
"external_ref": "psn_itt7xgkh7qh8tjuz",
"identity_type": "LAWYER",
"first_name": "Arthur",
"last_name": "Pemberton",
"contact_email": "arthur.pemberton@example-firm.test",
"contact_phone": null,
"created_at": "2025-06-24T13:54:17.089Z"
}What the dbt model might look like
-- models/canonical/identity.sql
-- One row per person that some other canonical row references.
with seats as (
select personId, roles, solicitorId
from {{ source('v2', 'FirmEmployee') }}
where firmId = '{{ var("firm_id") }}' and not deleted
),
referenced_people as (
select person_external_ref as person_id from {{ ref('matter_participant') }}
union distinct
select author_person_external_ref from {{ ref('matter_note') }}
union distinct
select author_person_external_ref from {{ ref('chat_message') }}
union distinct
select uploader_person_external_ref from {{ ref('matter_file') }}
union distinct
select person_external_ref from {{ ref('call') }}
union distinct
select identity_external_ref from {{ ref('firm_member') }}
union distinct
select identity_external_ref from {{ ref('team_member') }}
)
select
p.id as external_ref,
case
when s.personId is not null
and (s.solicitorId is not null or 'solicitor' in unnest(s.roles))
then 'LAWYER'
when s.personId is not null then 'STAFF'
when sol.personId is not null then 'LAWYER'
else 'CLIENT'
end as identity_type,
coalesce(nullif(trim(p.firstName), ''), 'Unknown') as first_name,
coalesce(nullif(trim(p.lastName), ''), 'Unknown') as last_name,
p.emailAddress as contact_email,
p.phoneNumber as contact_phone,
p.createdAt as created_at
from {{ source('v2', 'Person') }} p
join referenced_people r on r.person_id = p.id
left join seats s on s.personId = p.id
left join {{ source('v2', 'Solicitor') }} sol on sol.personId = p.id2. FirmEmployee → firm_member
A FirmEmployee is one person's seat at one firm, and it is the record most of v2 points at when it means "the lawyer". It becomes a firm_member row tying the person's identity to the run's target firm.
| Boundary column | v2 source | Notes |
|---|---|---|
external_ref | FirmEmployee.id | a natural row, no invented ref needed |
identity_external_ref | personId | |
role | derived | Clerk admin role becomes ADMIN; a solicitor role becomes LAWYER; anything else becomes SUPPORT |
job_title | nothing | v2 stores no seat-level job title |
created_at | createdAt |
Worth knowing:
- Soft-deleted seats (27 in the dump) are not staged as members, though their person can still appear as an identity.
- v2's compliance roles (
colp,cofa) collapse away because the boundary's role vocabulary has no equivalent.
A real example from the dev export
// v2 source: FirmEmployee (Arthur's seat at the firm)
{
"id": "frmemp_6u2zf1bd039oes15",
"firmId": "frm_szntlk8kzj2bsbml", // dropped: the run targets one firm
"personId": "psn_itt7xgkh7qh8tjuz",
"solicitorId": "sol_gd64lh1h4xmtcb7a",
"roles": "{solicitor,colp,cofa}", // solicitor wins: role = LAWYER; colp/cofa collapse away
"clerkRole": "basic_member", // dropped: auth
"clerkOrganisationMembershipId": "orgmem_2yxPTQuYWOu2euKlVpqeTHkYOSu", // dropped: auth
"deleted": "f",
"isActive": "t",
"instructionCredits": "0", // dropped: marketplace
"caseLimit": null, // dropped: marketplace
"profileId": "legprf_vg8xz611clfgoao0", // dropped: lawyer profile (a known gap)
"legalEntityId": "legent_a3uly6wrhhy7h4yf", // dropped: money
"firmEmployeeLedgerId": "frmempldg_46wgq0b2xlfg1xcv", // dropped: money
"firmEmployeeDisbursementLedgerId": "frmempdisldg_3qe9p420w7wirufs", // dropped: money
"createdAt": "2025-06-24 13:54:18.73",
"updatedAt": "2025-11-05 13:53:21.982"
}// canonical: firm_member
{
"external_ref": "frmemp_6u2zf1bd039oes15",
"identity_external_ref": "psn_itt7xgkh7qh8tjuz",
"role": "LAWYER",
"job_title": null,
"created_at": "2025-06-24T13:54:18.73Z"
}What the dbt model might look like
-- models/canonical/firm_member.sql
select
fe.id as external_ref,
fe.personId as identity_external_ref,
case
when fe.clerkRole = 'admin' then 'ADMIN'
when 'solicitor' in unnest(fe.roles) then 'LAWYER'
else 'SUPPORT'
end as role,
cast(null as string) as job_title,
fe.createdAt as created_at
from {{ source('v2', 'FirmEmployee') }} fe
where fe.firmId = '{{ var("firm_id") }}'
and not fe.deleted3. Team and TeamMember → team and team_member
v2 Teams are odd: each one belongs to a single owning lawyer, has no name, and the owner is recorded only as a pointer, never as a member. TeamMember rows hold the colleagues who were given access to the owner's cases. Two inventions were needed to make these fit a normal team shape:
- Each team gets a name built from its owner, for example "Anna Reid's team". The name is generated the same way every run, and every generated name is flagged in the run report.
- Each owner gets a synthesised membership row (
team:<id>:owner) with theOWNERrole, since v2 never wrote one. RealTeamMemberrows becomeMEMBERs.
Worth knowing:
- 26 teams and 72 memberships staged, of which 23 memberships are the synthesised owners. Three owners have soft-deleted seats and stage nothing.
TeamMember.roleNameis free text in v2 ("Paralegal", "Assistant", also "Legend" and "law guyy" in dev data). The boundary's team roles are only owner or member, so these labels are dropped and the loss is recorded.
A real example from the dev export
// v2 source: Team (Arthur's team; no name, owner is just a pointer)
{
"id": "team_o7jpd9wuhtkovqqr",
"ownerId": "frmemp_6u2zf1bd039oes15",
"firmId": "frm_szntlk8kzj2bsbml",
"createdAt": "2025-11-05 16:54:56.3",
"updatedAt": "2025-11-05 16:54:56.3"
}// v2 source: TeamMember (Nina, a colleague on Arthur's team)
{
"id": "tm_7zgrspnfbjw9iu24",
"teamId": "team_o7jpd9wuhtkovqqr",
"firmEmployeeId": "frmemp_89z695sgbusqqvdf",
"roleName": "Lawyer", // dropped: free text, no boundary column
"isDeleted": "f",
"partyOnAllCases": "f", // dropped: behaviour, not history
"createdAt": "2025-11-05 16:54:56.331",
"updatedAt": "2025-11-05 16:54:56.331"
}// canonical: team (name synthesised from the owner)
{
"external_ref": "team_o7jpd9wuhtkovqqr",
"name": "Arthur Pemberton's team",
"created_at": "2025-11-05T16:54:56.3Z"
}// canonical: team_member, two rows.
// The first is invented: v2 never stores the owner as a member.
[
{
"external_ref": "team:team_o7jpd9wuhtkovqqr:owner",
"team_external_ref": "team_o7jpd9wuhtkovqqr",
"identity_external_ref": "psn_itt7xgkh7qh8tjuz",
"role": "OWNER",
"created_at": "2025-11-05T16:54:56.3Z"
},
{
"external_ref": "tm_7zgrspnfbjw9iu24",
"team_external_ref": "team_o7jpd9wuhtkovqqr",
"identity_external_ref": "psn_595cke9mauudrujt",
"role": "MEMBER",
"created_at": "2025-11-05T16:54:56.331Z"
}
]What the dbt models might look like
-- models/canonical/team.sql
select
t.id as external_ref,
coalesce(
concat(trim(p.firstName), ' ', trim(p.lastName), "'s team"),
concat('Team ', substr(t.id, -6))
) as name,
t.createdAt as created_at
from {{ source('v2', 'Team') }} t
left join {{ source('v2', 'FirmEmployee') }} fe on fe.id = t.ownerId
left join {{ source('v2', 'Person') }} p on p.id = fe.personId
where t.firmId = '{{ var("firm_id") }}'-- models/canonical/team_member.sql
with real_members as (
select
tm.id as external_ref,
tm.teamId as team_external_ref,
fe.personId as identity_external_ref,
'MEMBER' as role,
tm.createdAt as created_at
from {{ source('v2', 'TeamMember') }} tm
join {{ ref('team') }} t on t.external_ref = tm.teamId
join {{ source('v2', 'FirmEmployee') }} fe on fe.id = tm.firmEmployeeId
where not tm.isDeleted and not fe.deleted
),
-- v2 stores the owner as a pointer, never as a member row
synthesised_owners as (
select
concat('team:', t.id, ':owner') as external_ref,
t.id as team_external_ref,
fe.personId as identity_external_ref,
'OWNER' as role,
t.createdAt as created_at
from {{ source('v2', 'Team') }} t
join {{ source('v2', 'FirmEmployee') }} fe
on fe.id = t.ownerId and not fe.deleted
where t.firmId = '{{ var("firm_id") }}'
)
select * from real_members
union all
select * from synthesised_owners4. Case and CaseParty → matter and matter_participant
A v2 Case is one piece of legal work for a client, owned by one firm seat. It becomes a matter. The people on it are CaseParty rows, which become matter_participants.
v2 has seven case statuses; the boundary has four. The collapse:
| v2 status | Becomes | Reasoning |
|---|---|---|
PENDING, OPENING | OPENING | both are pre-work states |
ACTIVE, REASSIGNING, SUBMITTED_FOR_CLOSING | ACTIVE | all three are legally open |
COMPLETED | COMPLETED | |
CANCELLED | CANCELLED |
The rest of the matter row is straightforward:
human_readable_idkeeps the case number the firm has been citing for years.external_sourcekeeps v2's own classification (lawhive,byoc,followOn) as data.marketandprimary_jurisdiction_idare constants for this source:GBandgb-england-wales.- Timestamps are the original v2 history. Three cases never had a last-activity time and fall back to their update time; a handful of completion timestamps are made consistent with the collapsed status. All logged.
Participants map by party type:
| v2 party type | Side | Participant type |
|---|---|---|
SOLICITOR | FIRM | LAWYER |
TEAM_MEMBER | FIRM | TEAM_MEMBER |
CUSTOMER | CLIENT | CLIENT |
Pending invitations (INVITED parties, 267 of them) are never staged. An unaccepted invitation is a pending workflow, not participation; if it gets accepted later, a later run picks it up.
The delicate part is the lead flags. v3 wants exactly one lead per side, and the real data disagrees in three ways, each with a deterministic fix:
- The case owner is authoritative for the firm side. If the owner's person somehow had no party row, one would be synthesised (
case:<id>:owner); this dump needed zero. - 246 cases have no client party at all. Where v2 recorded a lead customer on the case itself, a client participant is synthesised from it (113 cases). The remaining 133 matters genuinely have no client side: marketplace cases that died in intake before anyone was invited. Inventing a client for them would be worse than importing them as they are, so they stage without a client lead and the run report says so.
- Duplicate or missing lead flags are normalised: one extra firm-side lead was demoted, five client-side leads were promoted where assigned customers existed but no flag was set.
A real example from the dev export
// v2 source: Case 20345, "Franchise Agreement" (a completed matter)
{
"id": "cas_zlkoq0q3ih8lb4o4",
"humanReadableId": "20345",
"status": "COMPLETED",
"title": "Franchise Agreement",
"source": "lawhive",
"ownerId": "frmemp_6u2zf1bd039oes15", // drives the firm-side lead check
"leadCustomerId": "cus_jwrgv3x9u631t26c", // not needed here: a real client party exists
"createdAt": "2024-11-28 11:51:34.652",
"lastActivity": "2026-06-16 09:07:41.479",
"completedAt": "2026-06-16 09:08:19.172",
"enabledModules": "{lawrenceBasic,caseAssessments,informationRequest}", // dropped: deprecated
"caseLedgerId": "csldg_596jnd6hvhnf2904", // dropped: money
"nonBillableLedgerId": "nbldg_cuwndkev9ju78krl", // dropped: money
"officeAccountLedgerId": "coaldg_fml9ci1xcg26j0h6", // dropped: money
"baseFeePlanId": "fp_odf4vqmy00npfaml", // dropped: money
"autoBillingEnabled": "t", // dropped: money
"hasRequestedReview": "f" // dropped: marketplace
}// canonical: matter
// COMPLETED passes through unchanged and keeps its completedAt
{
"external_ref": "cas_zlkoq0q3ih8lb4o4",
"human_readable_id": "20345",
"title": "Franchise Agreement",
"matter_status": "COMPLETED",
"external_source": "lawhive",
"market": "GB",
"primary_jurisdiction_id": "gb-england-wales",
"created_at": "2024-11-28T11:51:34.652Z",
"last_activity_at": "2026-06-16T09:07:41.479Z",
"completed_at": "2026-06-16T09:08:19.172Z"
}// v2 source: the three CaseParty rows on this case (all ASSIGNED)
[
{
"id": "caspar_9juw0fk84sfb9a9k",
"type": "SOLICITOR",
"personId": "psn_itt7xgkh7qh8tjuz",
"isLead": "t",
"createdAt": "2024-11-28 11:51:34.652"
},
{
"id": "caspar_husqm4gx4hjjtc9s",
"type": "CUSTOMER",
"personId": "psn_makckimjucnnw4wf",
"isLead": "t",
"createdAt": "2024-11-28 11:51:34.652"
},
{
"id": "caspar_zjbvgkbp2ejhbt8c",
"type": "TEAM_MEMBER",
"personId": "psn_595cke9mauudrujt",
"teamMemberId": "tm_7zgrspnfbjw9iu24",
"isLead": "f",
"createdAt": "2025-12-09 12:15:30.946"
}
]// canonical: matter_participant
// A well-behaved case: one lead per side already, so no fix-ups fired
[
{
"external_ref": "caspar_9juw0fk84sfb9a9k",
"matter_external_ref": "cas_zlkoq0q3ih8lb4o4",
"person_external_ref": "psn_itt7xgkh7qh8tjuz",
"side": "FIRM",
"participant_type": "LAWYER",
"is_lead": true,
"created_at": "2024-11-28T11:51:34.652Z"
},
{
"external_ref": "caspar_husqm4gx4hjjtc9s",
"matter_external_ref": "cas_zlkoq0q3ih8lb4o4",
"person_external_ref": "psn_makckimjucnnw4wf",
"side": "CLIENT",
"participant_type": "CLIENT",
"is_lead": true,
"created_at": "2024-11-28T11:51:34.652Z"
},
{
"external_ref": "caspar_zjbvgkbp2ejhbt8c",
"matter_external_ref": "cas_zlkoq0q3ih8lb4o4",
"person_external_ref": "psn_595cke9mauudrujt",
"side": "FIRM",
"participant_type": "TEAM_MEMBER",
"is_lead": false,
"created_at": "2025-12-09T12:15:30.946Z"
}
]What the dbt models might look like
-- models/canonical/matter.sql
select
c.id as external_ref,
cast(c.humanReadableId as string) as human_readable_id,
c.title,
case c.status
when 'PENDING' then 'OPENING'
when 'REASSIGNING' then 'ACTIVE'
when 'SUBMITTED_FOR_CLOSING' then 'ACTIVE'
else c.status -- OPENING, ACTIVE, COMPLETED, CANCELLED pass through
end as matter_status,
c.source as external_source,
'GB' as market,
'gb-england-wales' as primary_jurisdiction_id,
c.createdAt as created_at,
coalesce(c.lastActivity, c.updatedAt) as last_activity_at,
-- completed_at present exactly when the collapsed status is COMPLETED
case when c.status = 'COMPLETED'
then coalesce(c.completedAt, c.lastActivity, c.updatedAt)
end as completed_at
from {{ source('v2', 'Case') }} c
join {{ source('v2', 'FirmEmployee') }} owner on owner.id = c.ownerId
where owner.firmId = '{{ var("firm_id") }}'-- models/canonical/matter_participant.sql (sketch)
with assigned as (
select
cp.id as external_ref,
cp.caseId as matter_external_ref,
cp.personId as person_external_ref,
if(cp.type = 'CUSTOMER', 'CLIENT', 'FIRM') as side,
case cp.type
when 'CUSTOMER' then 'CLIENT'
when 'SOLICITOR' then 'LAWYER'
else 'TEAM_MEMBER'
end as participant_type,
cp.isLead as source_is_lead,
cp.createdAt as created_at
from {{ source('v2', 'CaseParty') }} cp
join {{ ref('matter') }} m on m.external_ref = cp.caseId
where cp.status = 'ASSIGNED' -- INVITED parties are never staged
-- one row per person per matter: keep the earliest
qualify row_number() over (
partition by cp.caseId, cp.personId order by cp.createdAt
) = 1
),
-- cases with no customer party but a recorded lead customer
synthesised_client_leads as (
select
concat('case:', c.id, ':lead-client') as external_ref,
c.id as matter_external_ref,
cus.personId as person_external_ref,
'CLIENT' as side,
'CLIENT' as participant_type,
true as source_is_lead,
c.createdAt as created_at
from {{ source('v2', 'Case') }} c
join {{ source('v2', 'Customer') }} cus on cus.id = c.leadCustomerId
where not exists (
select 1 from assigned a
where a.matter_external_ref = c.id and a.side = 'CLIENT'
)
),
unioned as (
select *, false as is_owner from assigned
union all
select *, false as is_owner from synthesised_client_leads
)
-- exactly one lead per side: the case owner wins the firm side,
-- the earliest flagged lead wins otherwise
select
* except (source_is_lead, is_owner),
row_number() over (
partition by matter_external_ref, side
order by is_owner desc, source_is_lead desc, created_at
) = 1 as is_lead
from unioned
-- (owner detection and the case:<id>:owner synthesis are elided here)5. CaseExternalParty, CaseMailContact and Company → contact and contact_address, matter_contact
v3 gives each firm a proper address book: contact rows (a person or a company), with methods, addresses, and per-matter links. v2 never had one. What it has instead is three partial, case-scoped stand-ins, and all three map in:
- A
CaseExternalPartyis someone on the other side of a case, stored as just a name and a person/company flag. It becomes an individual or company contact plus a matter link labelled "External party". 22 live rows staged; 13 soft-deleted ones excluded. - A
CaseMailContactis a postal address book entry a lawyer saved on a case. It becomes an individual contact with a fullcontact_address, linked as "Mail contact". v2 stores no person/company flag here, so all four are staged as individuals. - A
Companylinked from a client party becomes a company contact with its Companies House number, linked as "Client company". The registered address is one unstructured string in v2, which cannot fill the structured address columns, so it rides along in the contact's notes.
Worth knowing:
- Individual names arrive as one string and are split on the final space. Single-word names keep only a first name, which the schema allows.
contact_methodstages zero rows: v2 has no per-contact emails or phones outsidePerson, and those map toidentityinstead.GlobalMailContact(courts, Land Registry) is deliberately out. It is platform-curated shared data, not any one firm's book; if v3 wants it, it is seed data, not an import.
A real example from the dev export
// v2 source: two live CaseExternalParty rows on case 20345.
// Four soft-deleted siblings (isDeleted = t) were excluded, including
// earlier duplicates of these two names.
[
{
"id": "casct_9dgxx26dzjsby1p0",
"caseId": "cas_zlkoq0q3ih8lb4o4",
"type": "person",
"name": "Nina Ashford", // one string; split on the final space
"isDeleted": "f",
"createdAt": "2026-03-20 14:42:38.673"
},
{
"id": "casct_e6fd0t8p5svhttdq",
"caseId": "cas_zlkoq0q3ih8lb4o4",
"type": "company",
"name": "Ashford Co", // companies keep the whole name
"isDeleted": "f",
"createdAt": "2026-03-20 14:42:31.346"
}
]// canonical: contact
[
{
"external_ref": "casct_9dgxx26dzjsby1p0",
"contact_type": "INDIVIDUAL",
"first_name": "Nina",
"last_name": "Ashford",
"title": null,
"legal_name": null,
"trading_name": null,
"registration_number": null,
"person_external_ref": null,
"notes": null,
"created_at": "2026-03-20T14:42:38.673Z"
},
{
"external_ref": "casct_e6fd0t8p5svhttdq",
"contact_type": "COMPANY",
"first_name": null,
"last_name": null,
"title": null,
"legal_name": "Ashford Co",
"trading_name": null,
"registration_number": null,
"person_external_ref": null,
"notes": null,
"created_at": "2026-03-20T14:42:31.346Z"
}
]// canonical: matter_contact (the link back to the matter carries the role)
{
"external_ref": "cas_zlkoq0q3ih8lb4o4:casct_9dgxx26dzjsby1p0",
"matter_external_ref": "cas_zlkoq0q3ih8lb4o4",
"contact_external_ref": "casct_9dgxx26dzjsby1p0",
"role": "OTHER",
"role_label": "External party",
"notes": null,
"created_at": "2026-03-20T14:42:38.673Z"
}What the dbt models might look like
-- models/canonical/contact.sql
-- Three partial v2 sources become one address book.
with external_parties as (
select
cep.id as external_ref,
if(cep.type = 'person', 'INDIVIDUAL', 'COMPANY') as contact_type,
if(cep.type = 'person',
regexp_extract(trim(cep.name), r'^(.*)\s+\S+$'), null) as first_name,
if(cep.type = 'person',
regexp_extract(trim(cep.name), r'(\S+)$'), null) as last_name,
if(cep.type = 'company', trim(cep.name), null) as legal_name,
cast(null as string) as registration_number,
cast(null as string) as notes,
cep.createdAt as created_at
from {{ source('v2', 'CaseExternalParty') }} cep
join {{ ref('matter') }} m on m.external_ref = cep.caseId
where not cep.isDeleted
),
mail_contacts as (
select
cmc.id as external_ref,
'INDIVIDUAL' as contact_type,
regexp_extract(trim(cmc.name), r'^(.*)\s+\S+$') as first_name,
regexp_extract(trim(cmc.name), r'(\S+)$') as last_name,
cast(null as string) as legal_name,
cast(null as string) as registration_number,
cast(null as string) as notes,
cmc.createdAt as created_at
from {{ source('v2', 'CaseMailContact') }} cmc
join {{ ref('matter') }} m on m.external_ref = cmc.caseId
where not cmc.isDeleted
),
client_companies as (
select distinct
co.id as external_ref,
'COMPANY' as contact_type,
cast(null as string) as first_name,
cast(null as string) as last_name,
co.name as legal_name,
co.companyNumber as registration_number,
-- one unstructured string; rides in notes, not contact_address
concat('Registered address: ', co.address) as notes,
co.createdAt as created_at
from {{ source('v2', 'CaseParty') }} cp
join {{ source('v2', 'Company') }} co on co.id = cp.companyId
join {{ ref('matter') }} m on m.external_ref = cp.caseId
where cp.status = 'ASSIGNED'
)
select * from external_parties
union all select * from mail_contacts
union all select * from client_companiescontact_address (from the mail contacts' address columns) and matter_contact (one link per contact per case, with its role label) are sibling models built from the same three sources.
6. CaseFileFolder, CaseFile and File → matter_file_folder and matter_file
v2 documents are two records: a File (the object in S3 with its name and size) and a CaseFile (that file's place on a case: who filed it, who can see it). Together they become one matter_file row. CaseFileFolders become matter_file_folders.
| Boundary column | v2 source | Notes |
|---|---|---|
folder_external_ref | the file-to-folder join | v2 lets a file sit in several folders; the boundary allows one, so the lowest-ordered folder wins (zero files actually needed this) |
payload_uri | File.s3Key | points at the source object; the blob staging step re-homes it |
payload_sha256 | placeholder | see below |
filename | originalFilename | |
file_size | fileSize | |
visibility | visibility | v2 all becomes ALL (client can see it); v2 solicitors becomes FIRM_SIDE_ONLY |
uploader_person_external_ref | uploadingPersonId |
Worth knowing:
- 143 soft-deleted files and 4 zero-byte files are excluded, with reasons recorded.
- Folder colours and v2's fine-grained ordering are simplified to what the boundary carries.
- v2 records where each file came from (upload, filed email, accepted AI draft). The boundary has no column for that provenance; in v3 every imported file is simply marked as imported.
- The database dump holds no file checksums, so the staged checksum is a deterministic stand-in derived from the file's storage key. The step that actually copies file contents into the staging store computes the real hash and replaces it. Until then, the staged files are structurally complete but their checksums are provisional.
A real example from the dev export
// v2 source: CaseFileFolder on case 20345
{
"id": "caseFilFol_0r5z9mlry5m86qfl",
"title": "Email Correspondence",
"caseId": "cas_zlkoq0q3ih8lb4o4",
"colour": "#3b82f6", // dropped: no boundary column
"order": "0.000000000000000000000000000000", // rounded to an int
"createdAt": "2026-05-19 12:31:04.92"
}// v2 source: CaseFile + File (one document, two v2 records)
{
"id": "casfil_edojnsxhziik4ahw",
"caseId": "cas_zlkoq0q3ih8lb4o4",
"fileId": "file_f3kqx7kg70jugux7",
"source": "upload", // dropped: v3 stamps IMPORT on everything
"visibility": "solicitors", // becomes FIRM_SIDE_ONLY
"uploadingPersonId": "psn_itt7xgkh7qh8tjuz",
"createdAt": "2026-05-19 12:30:15.724"
}{
"id": "file_f3kqx7kg70jugux7",
"s3Key": "cases/cas_zlkoq0q3ih8lb4o4/39e14433-5c84-4be8-88e6-c0fb65fb8840-Letter%20to%20Blogs.docx",
"originalFilename": "Letter to Blogs.docx",
"fileSize": "67228",
"displayName": "Letter to Blogs",
"extension": "docx",
"isDeleted": "f",
"aiIngestionV3Status": "SUCCESS", // dropped: v3 re-ingests on arrival
"createdAt": "2026-05-19 12:30:15.724"
}// canonical: matter_file_folder + matter_file
{
"external_ref": "caseFilFol_0r5z9mlry5m86qfl",
"matter_external_ref": "cas_zlkoq0q3ih8lb4o4",
"title": "Email Correspondence",
"order": 0
}{
"external_ref": "casfil_edojnsxhziik4ahw",
"matter_external_ref": "cas_zlkoq0q3ih8lb4o4",
"folder_external_ref": "caseFilFol_0r5z9mlry5m86qfl", // via the join table
"payload_uri": "s3://lawhive-v2-case-files/cases/cas_zlkoq0q3ih8lb4o4/39e14433-5c84-4be8-88e6-c0fb65fb8840-Letter%20to%20Blogs.docx",
"payload_sha256": "7d55646dbe0b5ba4bf60c01982baaf62fba2be5ca353e989333bd16d62ca2d24", // provisional
"filename": "Letter to Blogs.docx",
"file_size": 67228,
"visibility": "FIRM_SIDE_ONLY",
"uploader_person_external_ref": "psn_itt7xgkh7qh8tjuz",
"created_at": "2026-05-19T12:30:15.724Z"
}What the dbt models might look like
-- models/canonical/matter_file_folder.sql
select
f.id as external_ref,
f.caseId as matter_external_ref,
f.title,
cast(round(f.`order`) as int64) as `order`
from {{ source('v2', 'CaseFileFolder') }} f
join {{ ref('matter') }} m on m.external_ref = f.caseId-- models/canonical/matter_file.sql
with folder_choice as (
-- v2 allows many folders per file; the lowest-ordered one wins
select j.A as case_file_id, j.B as folder_id
from {{ source('v2', '_CaseFileToCaseFileFolder') }} j
join {{ source('v2', 'CaseFileFolder') }} f on f.id = j.B
qualify row_number() over (
partition by j.A order by f.`order`, f.id
) = 1
)
select
cf.id as external_ref,
cf.caseId as matter_external_ref,
fc.folder_id as folder_external_ref,
concat('s3://', '{{ var("payload_bucket") }}', '/', f.s3Key) as payload_uri,
-- provisional: replaced with the real hash when blobs are staged
to_hex(sha256(concat('v2:s3Key:', f.s3Key))) as payload_sha256,
coalesce(nullif(f.originalFilename, ''),
concat(f.displayName, '.', f.extension)) as filename,
f.fileSize as file_size,
if(cf.visibility = 'all', 'ALL', 'FIRM_SIDE_ONLY') as visibility,
cf.uploadingPersonId as uploader_person_external_ref,
cf.createdAt as created_at
from {{ source('v2', 'CaseFile') }} cf
join {{ source('v2', 'File') }} f on f.id = cf.fileId
join {{ ref('matter') }} m on m.external_ref = cf.caseId
left join folder_choice fc on fc.case_file_id = cf.id
where not f.isDeleted
and f.fileSize > 07. CaseNote → matter_note
A CaseNote is a note a lawyer wrote on a case for their own side. It maps almost one to one:
- The body carries over as markdown. v2 marks which notes are markdown and which are plain text, and plain text is valid markdown, so both pass through untouched.
- The author chain runs
createdByIdto the firm seat to the person, which is why some departed employees still appear as identities. - v2 notes have no title, so the boundary's optional name stays empty.
- 20 soft-deleted notes are excluded. 223 staged.
A pleasant side effect: v2's AI-written call attendance notes are already stored as CaseNotes, so they arrive through this mapping with no extra work.
A real example from the dev export
// v2 source: CaseNote on case 20345
{
"id": "casnt_zo2spbvwb4lx8l2l",
"createdById": "frmemp_6u2zf1bd039oes15", // a firm seat, resolved to its person
"content": "Example note",
"caseId": "cas_zlkoq0q3ih8lb4o4",
"isMarkdown": "t",
"isDeleted": "f",
"createdAt": "2025-10-24 11:16:50.522"
}// canonical: matter_note
{
"external_ref": "casnt_zo2spbvwb4lx8l2l",
"matter_external_ref": "cas_zlkoq0q3ih8lb4o4",
"name": null,
"body_markdown": "Example note",
"author_person_external_ref": "psn_itt7xgkh7qh8tjuz", // the seat's person
"created_at": "2025-10-24T11:16:50.522Z"
}What the dbt model might look like
-- models/canonical/matter_note.sql
select
n.id as external_ref,
n.caseId as matter_external_ref,
cast(null as string) as name, -- v2 notes have no title
n.content as body_markdown, -- plain text is valid markdown as-is
fe.personId as author_person_external_ref,
n.createdAt as created_at
from {{ source('v2', 'CaseNote') }} n
join {{ ref('matter') }} m on m.external_ref = n.caseId
join {{ source('v2', 'FirmEmployee') }} fe on fe.id = n.createdById
where not n.isDeleted
and trim(n.content) != ''8. No v2 equivalent → task and key_date
v2 has no task, to-do, deadline, or key-date model at all. The closest things are workflow state blobs, case stage tags, and booked meetings, and none of those is a task. Pretending otherwise would import noise, so both files stage empty and the import service treats an empty file as a valid one.
9. Message via CaseChannel → chat_message
Every v2 case has one chat channel (Channel, joined to the case by CaseChannel). The Messages in it become chat_message rows so the v3 Messages tab keeps its history.
- Only real person-written messages map (1,138 of them). v2 also stores 269 system messages, the "X left the case, Y joined" notices; those have no author and v3 generates its own, so they stay behind.
- 11 soft-deleted messages are excluded.
ChannelMemberneeds no staging at all: v3 derives who is in a channel from who wrote messages, exactly as the boundary specifies.
A real example from the dev export
// v2 source: Message on case 20345's channel
{
"id": "msg_8mkxfpzugpi5xfmz",
"type": "text",
"data": "{\"type\": \"text\", \"userId\": \"psn_itt7xgkh7qh8tjuz\", \"message\": \"Hi client\"}",
"channelId": "chn_wfx61pwt35yeonkn", // resolved to the case via CaseChannel
"authorId": "psn_itt7xgkh7qh8tjuz",
"isDeleted": null,
"sendbirdMessageId": "12607981468", // dropped: Sendbird correlation id
"createdAt": "2025-08-13 13:41:43.124"
}// canonical: chat_message (the body is pulled out of the data JSON)
{
"external_ref": "msg_8mkxfpzugpi5xfmz",
"matter_external_ref": "cas_zlkoq0q3ih8lb4o4",
"author_person_external_ref": "psn_itt7xgkh7qh8tjuz",
"body": "Hi client",
"sent_at": "2025-08-13T13:41:43.124Z"
}What the dbt model might look like
-- models/canonical/chat_message.sql
select
msg.id as external_ref,
cc.caseId as matter_external_ref,
msg.authorId as author_person_external_ref,
json_value(msg.data, '$.message') as body,
msg.createdAt as sent_at
from {{ source('v2', 'Message') }} msg
join {{ source('v2', 'CaseChannel') }} cc
on cc.channelId = msg.channelId and cc.isPrimary
join {{ ref('matter') }} m on m.external_ref = cc.caseId
where msg.type = 'text' -- system/admin notices stay behind
and coalesce(msg.isDeleted, false) = false
and msg.authorId is not null
and trim(coalesce(json_value(msg.data, '$.message'), '')) != ''10. Call → call
A v2 Call is a phone call a lawyer dialled from the platform about a case, with an optional recording and transcript. All 67 staged.
- Every v2 call is outbound by construction (there is no inbound telephony in v2), so the direction is constant.
- The person on the call is the dialler. The other side becomes a readable label built from the other parties' names or numbers.
- 28 calls never connected and have no start time; they fall back to their creation time, logged as fix-ups.
- Recordings ride along as payloads, with the same provisional-checksum caveat as files.
- The transcript text is not in the database (it lives next to the recording in S3), so the note field stays empty. The attendance note a lawyer would actually read arrives as a matter note anyway.
A real example from the dev export
// v2 source: Call on case 20345 (a failed call: it never connected)
{
"id": "call_fz1xfn7ljki40bzz",
"initiatedById": "psn_itt7xgkh7qh8tjuz",
"caseId": "cas_zlkoq0q3ih8lb4o4",
"parties": "[{\"name\": \"Arthur Pemberton\", \"personId\": \"psn_itt7xgkh7qh8tjuz\", \"phoneNumber\": \"+447700900747\"}, {\"name\": \"Nina Ashford\", \"personId\": \"psn_595cke9mauudrujt\", \"phoneNumber\": \"+442079460001\"}]",
"twilioSid": "CA9c36ff0f0e5ac504fff2b00c152816ab", // dropped: telephony plumbing
"recordingUrl": null,
"transcriptionUrl": null,
"durationSeconds": null,
"startedAt": "2026-01-30 15:32:43.772",
"completedAt": null,
"callStatus": "failed",
"attendanceNoteStatus": "not_requested",
"createdAt": "2026-01-30 15:32:43.773"
}// canonical: call
// The other-party label is everyone in parties except the dialler
{
"external_ref": "call_fz1xfn7ljki40bzz",
"matter_external_ref": "cas_zlkoq0q3ih8lb4o4",
"direction": "OUTBOUND",
"person_external_ref": "psn_itt7xgkh7qh8tjuz",
"other_party_label": "Nina Ashford",
"started_at": "2026-01-30T15:32:43.772Z",
"ended_at": null,
"recording_payload_uri": null,
"recording_payload_sha256": null,
"note_text": null
}What the dbt model might look like
-- models/canonical/call.sql
select
c.id as external_ref,
c.caseId as matter_external_ref,
'OUTBOUND' as direction, -- every v2 call is platform-dialled
c.initiatedById as person_external_ref,
(
select string_agg(
coalesce(json_value(p, '$.name'), json_value(p, '$.phoneNumber')),
', '
)
from unnest(json_query_array(c.parties)) p
where coalesce(json_value(p, '$.personId'), '') != c.initiatedById
) as other_party_label,
coalesce(c.startedAt, c.createdAt) as started_at, -- failed calls never started
c.completedAt as ended_at,
if(c.recordingUrl is not null,
concat('s3://', '{{ var("payload_bucket") }}', '/', c.recordingUrl),
null) as recording_payload_uri,
if(c.recordingUrl is not null,
to_hex(sha256(concat('v2:s3Key:', c.recordingUrl))), -- provisional
null) as recording_payload_sha256,
cast(null as string) as note_text
from {{ source('v2', 'Call') }} c
join {{ ref('matter') }} m on m.external_ref = c.caseIdWhat stays behind, and why
The full dev database holds 144 tables and about 1.65 million rows. Most of it was never a candidate. Every table has a recorded disposition:
| Disposition | Tables | Rows | What it is |
|---|---|---|---|
| Feeds the boundary today | 22 | 13,567 | everything above, plus ChannelMember, which v3 re-derives |
| Could map with catalogue additions | 15 | 8,170 | addresses, tags, agreements, artifacts, compliance records (next section) |
| Waits for the money slice | 58 | 24,534 | ledgers, bills, quotes, payments, fee plans, time tracking |
| Waits for the email design pass | 2 | 112 | filed Outlook emails (Communication, CommunicationThread) |
| Parked: comments | 1 | 362 | admin-only notes on marketplace records |
| Marketplace and ops | 38 | 1,475,942 | instructions, assessments, solicitor scoring, KYC, product catalogue; not part of any firm's book |
| Auth, deliberately out | 3 | 1,142 | logins and onboarding state; credentials never migrate |
| Runtime and derived | 5 | 127,786 | event logs, job state, file previews that v3 regenerates |
Reading those numbers the useful way: strip out marketplace analytics and runtime logs, and the firm's actual book is about 48,000 rows. Of that, 28% maps today, another 17% could map with catalogue additions, and 52% is money data waiting for the money slice. The remaining sliver is auth, which stays dead on purpose.
Gaps this mapping surfaced
Working through real data showed five places where v2 holds something the boundary catalogue has no home for yet. These are findings for the catalogue's owners to weigh, listed loudest first:
- Case addresses. v2's
Addressrows are structured property addresses recorded per case, and they power conflict-of-interest searches. Only 8 rows in dev data, but load-bearing for any conveyancing firm. - Signed client agreements. A
ClientAgreement's signed PDF is something a regulated firm must retain. These could plausibly travel as ordinary matter files in a "Signed agreements" folder without any new schema. At 1,569 agreements, this is the largest single unlocked slice. - Case tags.
CaseTagholds workflow-stage labels like "Awaiting client response" and "On hold". Whether they belong in v3 depends on v3's labelling story, so the decision is cheap either way. - Draft AI documents. Accepted
CaseArtifacts already arrive as case files, but drafts (1,004 of 1,207 in dev) have no home and are probably an acceptable loss. Worth confirming. - Lawyer profile details.
LegalPersonProfile(admission year, biography) and theSolicitor's professional registration are dropped today for every imported lawyer.
Also noted while categorising, without a proposal attached: case-to-case links (CaseAssociation), compliance reviews (RiskAssessment, CaseInternalReview), time entries (TimeTrackingEntry, which belongs with money), and historical meetings (Meeting).
How this mapping was produced
Everything above comes from a small transformer that reads a v2 database dump and writes the staged files, so the mapping is executable rather than theoretical. The pieces, all in transform/:
| Artifact | What it does |
|---|---|
transform.mjs | the mapping itself: reads the dump, applies every rule on this page, writes the staged files |
boundary-schemas.mjs | a runtime mirror of the boundary contracts, used to validate every staged row |
copy-parser.mjs | streams the Postgres dump format |
verify.mjs | the dry run: re-reads the output the way the import loader would and proves nothing would be lost or mis-ordered |
flowchart LR dump["v2 database dump"] --> parse["parse the 21<br/>relevant tables"] parse --> map["map and fix up<br/>v2 rows to canonical rows"] map --> validate["validate every row against<br/>its boundary schema"] validate --> sort["sort files the way<br/>the loader requires"] sort --> files["17 staged JSONL files<br/>+ manifest + report"] files --> verify["dry-run the loader<br/>over the output"]
To run it:
node transform/transform.mjs "V2 dev data.sql" --out ./staged
node transform/verify.mjs ./staged
Notes on the machinery, for whoever picks it up:
- It needs only Node, no dependencies. The 477 MB dump streams through; only the 21 relevant tables are held in memory, while the 1.4 million rows of marketplace analytics stream past.
--firmpicks which firm's book to export; by default it takes the firm owning the most cases.- The output is one JSONL file per canonical schema, sorted the way the import loader's forward-only cursors require (roots by their ref, children grouped under their parent), plus a
manifest.jsonwith row counts and file hashes, and areport.jsonlisting every fix-up, exclusion, and warning with example ids. - The verifier replays the loader's walk over the output: contract validation, reference resolution, sort checks, and proof that no row would be silently stranded.
- Runs are deterministic. The same dump produces byte-identical files, which matters because re-runs and idempotency lean on stable refs and stable content.
An example export to build against
To make the output tangible, one complete case is checked in as fixtures: case 20345, "Franchise Agreement", chosen because it touches every populated schema (participants on both sides, a team-member colleague, contacts, folders, files, a note, chat, and a call). The set is cut from the real run and is referentially closed: every ref in it resolves to a row that is also in it, and the verifier passes on the folder as-is. It is exactly what the import service would receive for a one-matter run, so implementing the canonical-to-v3 side can start against these files directly.
Download everything as example-export.zip (16 KB), or open the files individually:
| File | Rows | |
|---|---|---|
matter.jsonl | 1 | the case itself |
matter_participant.jsonl | 3 | lead lawyer, lead client, team-member colleague |
identity.jsonl | 4 | everyone the case references |
firm_member.jsonl | 3 | the firm-side seats |
team.jsonl | 2 | including one synthesised name |
team_member.jsonl | 3 | including synthesised owner rows |
contact.jsonl | 2 | one individual, one company |
contact_method.jsonl | 0 | empty, as for all of v2 |
contact_address.jsonl | 0 | this case has no mail contacts |
matter_contact.jsonl | 2 | the contact-to-matter links |
matter_file_folder.jsonl | 2 | |
matter_file.jsonl | 76 | provisional checksums, see above |
matter_note.jsonl | 1 | |
key_date.jsonl | 0 | empty, as for all of v2 |
task.jsonl | 0 | empty, as for all of v2 |
chat_message.jsonl | 7 | |
call.jsonl | 1 |
Two things to keep in mind when building against it. The file checksums are the provisional stand-ins described in the files section, and the file payloads themselves are not included, only their metadata rows. Everything else is the real staged shape, sorted the way the loader expects.