previews / firm-migrations / import boundary schemas

Platform · Firm migrations

The import boundary schemas

One canonical schema per section, in priority order: the zod contract, sample canonical data, and the exact v3 entities the importer inserts from it. Every v3 entity links to the domain model dashboard.

Status: Proposal, for feedback
Proposal, not settled. Poke holes in it.

These are the canonical boundary schemas: the shapes any external system (platform v2, a practice management system, spreadsheets) is transformed into so the import service can bring a firm's matters live in platform v3. The boundary is designed from what v3 requires (its real entities), never from a source system. Per-source mapping work starts from these shapes and comes after.

The matter boundary schema defined the first slice and the rules; this doc extends the same contract style across everything needed to bring a firm's matters live. How the rows land (units, transactions, failure containment) is the unit model's business, not this doc's. Every v3 entity below links to its entry on the v3 domain model dashboard; click through for the full field list and meaning.

All examples share one world: firm firm_9y2, lawyer Anna, client John, matter "Purchase of 12 Acacia Avenue" (case_71 in the source).

TL;DR. Eleven canonical schemas, priority-ordered: people foundation (identity, firm membership, teams), the matter core, contacts, then per-matter content (files, notes, tasks, chat, calls). Each section shows the zod contract, sample canonical rows, and the actual v3 rows the importer inserts, including the ImportedRecord registry rows written in the same transaction. Email, money, comments, and custom fields are deliberately out, each with a reason at the end.

Conventions

  1. Target-shaped, not source-shaped. Enum columns hold v3 vocabulary, imported from the owning service's published zod package. Lossy collapses happen in the per-source transform.
  2. No v3 ids, ever. Every reference is an externalRef resolved through the ImportedRecord registry. The target firm's id lives in run config and is stamped by the importer.
  3. Timestamps are source history, always UTC. createdAt is when the record was created in the source system, never import time; when v3 minted the row is the ImportedRecord's business (clarified in review).
  4. Derivable means derived. The boundary carries only what a source system can know.
  5. Blobs ride sidecar: payloadUri + payloadSha256 into the run's staged object store.
  6. Rich text is portable markup (markdown or HTML), never editor-internal formats.
  7. One-per-parent satellites fold into the parent row; the importer re-normalises on write.
  8. A link is a table only when it carries data.
  9. Flat rows, never nested. No schema embeds its children (team does not carry members: [...]), because the canonical layer has to store and diff cleanly in BigQuery, and because nesting would make constructing a valid row synonymous with assembling a unit. Assembly is centralised in the import service alone, at read time (settled in review).
  10. The graph is annotated in the schema. Every schema marks its primary key with .meta({ primaryKey: true }) and — when it is a child in the unit forest — its single immediate parent with .meta({ ref: parentSchema }), per the hierarchical unit load walk. Codegen turns those into dbt tests (unique + not_null on the pk, not_null on the parent ref) and into the dump models' sort order: roots export ordered by primary key, children by (parent ref, primary key) — exactly the order the loader's forward-only cursors require. Any other externalRef column is an ordinary registry-resolved reference, satisfied by load order rather than file grouping. Nullable-parent tables (task, key_date) split at dump time: matter-scoped rows in the child file, firm-level rows in their own root file, so no child file ever carries a null parent key.

Every insert also writes an ImportedRecord row (externalRef → v3 id) into the owning service's own database, in the same transaction as the rows it describes: resolution, idempotency and audit in one table, in every service that receives imported data. The examples below include these rows. Derived rows (MatterTeamAccess, Content, TaskAssignment, chat participants) have no externalRef and get no registry entry.

// the source system's id for a record; branded per source system
const externalRef = z.string().min(1);

// rule 10 shorthands used below
const pk = externalRef.meta({ primaryKey: true });
const parent = (schema: ZodType) => externalRef.meta({ ref: schema });

The catalogue

#Canonical schemaInserts in v3Service
1identityIdentityidentity
2firm_memberFirmMemberidentity
3team, team_memberTeam, TeamMemberidentity
4matter, matter_participantMatter, MatterParticipant (+ MatterTeamAccess)matter
5contact, contact_method, contact_addressContact + Individual/Company, ContactMethod, ContactAddressidentity
6matter_contactMatterContactmatter
7matter_file_folder, matter_fileMatterFileFolder, File + MatterFilematter
8matter_noteMatterNote (+ Content)matter/content
9task, key_dateTask (+ TaskAssignment), KeyDatematter
10chat_messageMessage (into the derived Channel)messaging
11callCallmatter

1. identity

Everything downstream references people, so they land first, as Identity rows. Imported identities carry details only: no credential, no login, no invite. How imported people eventually log in is a separate workstream; the boundary doesn't participate in it.

Naming note from review (Jaime): strictly these are user-type identities, so the schema may be better named userImport (or userIdentityImport) if other identity kinds ever reach the boundary; the shape is unaffected either way.

export const identityImport = z.object({
  externalRef: pk,
  // lawyer / staff / client vocabulary from identity-service
  identityType: IdentityTypeSchema,
  firstName: z.string().min(1),
  lastName: z.string().min(1),
  contactEmail: z.string().email().nullable(),
  contactPhone: z.string().nullable(),
  createdAt: z.coerce.date(),
});
// canonical: identity
{
  "external_ref": "person_anna",
  "identity_type": "LAWYER",
  "first_name": "Anna",
  "last_name": "Reid",
  "contact_email": "anna.reid@smithlaw.co.uk",
  "contact_phone": "+447700900123",
  "created_at": "2019-04-01T08:00:00Z"
}
// inserted: Identity (identity-db)
{
  // freshly minted
  "id": "idn_anna",
  "type": "LAWYER",
  "firstName": "Anna",
  "lastName": "Reid",
  "contactEmail": "anna.reid@smithlaw.co.uk",
  "contactPhone": "+447700900123",
  "createdAt": "2019-04-01T08:00:00Z"
}
// inserted: ImportedRecord (identity-db, same transaction)
{
  "entityType": "Identity",
  "entityId": "idn_anna",
  "externalRef": "person_anna",
  "importRunId": "imprun_platform-v2_firm_9y2_1_20260815"
}

2. firm_member

Membership of the run's target firm, as a FirmMember. The firm exists before any run, created by hand, so no firm reference appears in staged data anywhere.

export const firmMemberImport = z.object({
  externalRef: pk,
  identityExternalRef: parent(identityImport),
  // ADMIN / LAWYER / SUPPORT vocabulary from identity-service
  role: FirmMemberRoleSchema,
  jobTitle: z.string().nullable(),
  createdAt: z.coerce.date(),
});
// canonical: firm_member
{
  "external_ref": "person_anna:member",
  "identity_external_ref": "person_anna",
  "role": "LAWYER",
  "job_title": "Senior Associate",
  "created_at": "2019-04-01T08:00:00Z"
}
// inserted: FirmMember (identity-db)
{
  "id": "fmem_a1",
  // registry: person_anna
  "identityId": "idn_anna",
  // run config
  "firmId": "firm_9y2",
  "role": "LAWYER",
  "jobTitle": "Senior Associate",
  "createdAt": "2019-04-01T08:00:00Z"
}
// inserted: ImportedRecord (identity-db, same transaction)
{
  "entityType": "FirmMember",
  "entityId": "fmem_a1",
  "externalRef": "person_anna:member",
  "importRunId": "imprun_platform-v2_firm_9y2_1_20260815"
}

3. team and team_member

Practice groups and their membership: Team and TeamMember.

export const teamImport = z.object({
  externalRef: pk,
  name: z.string().min(1),
  createdAt: z.coerce.date(),
});

export const teamMemberImport = z.object({
  externalRef: pk,
  teamExternalRef: externalRef,
  identityExternalRef: parent(identityImport),
  // OWNER | MEMBER
  role: TeamMemberRoleSchema,
  createdAt: z.coerce.date(),
});
// canonical: team
{
  "external_ref": "dept_prop",
  "name": "Property",
  "created_at": "2019-04-01T08:00:00Z"
}
// canonical: team_member
{
  "external_ref": "dept_prop:person_anna",
  "team_external_ref": "dept_prop",
  "identity_external_ref": "person_anna",
  "role": "OWNER",
  "created_at": "2019-04-01T08:00:00Z"
}
// inserted: Team (identity-db)
{
  "id": "team_5rw",
  // run config
  "firmId": "firm_9y2",
  "name": "Property",
  "createdAt": "2019-04-01T08:00:00Z"
}
// inserted: TeamMember (identity-db)
{
  "id": "tmem_p7",
  // registry: dept_prop
  "teamId": "team_5rw",
  // registry: person_anna
  "identityId": "idn_anna",
  "role": "OWNER",
  "createdAt": "2019-04-01T08:00:00Z"
}
// inserted: ImportedRecord × 2 (identity-db, same transactions)
[
  {
    "entityType": "Team",
    "entityId": "team_5rw",
    "externalRef": "dept_prop",
    "importRunId": "imprun_platform-v2_firm_9y2_1_20260815"
  },
  {
    "entityType": "TeamMember",
    "entityId": "tmem_p7",
    "externalRef": "dept_prop:person_anna",
    "importRunId": "imprun_platform-v2_firm_9y2_1_20260815"
  }
]

4. matter and matter_participant

The initial slice, defined and fully worked (v2 → canonical → v3) in the matter boundary schema doc; restated here for completeness of the catalogue. Targets Matter and MatterParticipant, with MatterTeamAccess derived.

export const matterImport = z.object({
  externalRef: pk,
  // 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),
  // OPENING | ACTIVE | COMPLETED | CANCELLED;
  // every source's status vocabulary collapses to these
  matterStatus: MatterStatusSchema,
  // the source's own origin/type classification, kept as data
  externalSource: z.string().nullable(),
  // US | GB; passed in per run
  market: MarketSchema,
  // static reference slug, not a run-minted v3 id;
  // GB sources: "gb-england-wales", set in the transform
  primaryJurisdictionId: JurisdictionIdSchema,
  createdAt: z.coerce.date(),
  lastActivityAt: z.coerce.date(),
  completedAt: z.coerce.date().nullable(),
});

export const matterParticipantImport = z.object({
  // synthetic refs allowed where the source has no row,
  // namespaced to stay stable across re-runs (case:<id>:owner)
  externalRef: pk,
  matterExternalRef: parent(matterImport),
  personExternalRef: externalRef,
  // CLIENT | FIRM; must agree with participantType
  side: MatterSideSchema,
  // LAWYER | TEAM_MEMBER | CLIENT
  participantType: MatterParticipantTypeSchema,
  isLead: z.boolean(),
  createdAt: z.coerce.date(),
});

Unit invariant: exactly one lead per side; completedAt present exactly when status is COMPLETED.

// canonical: matter
{
  "external_ref": "case_71",
  "human_readable_id": "16095",
  "title": "Purchase of 12 Acacia Avenue",
  "matter_status": "ACTIVE",
  "external_source": "byoc",
  "market": "GB",
  "primary_jurisdiction_id": "gb-england-wales",
  "created_at": "2024-03-02T09:14:00Z",
  "last_activity_at": "2026-07-29T16:02:00Z",
  "completed_at": null
}
// canonical: matter_participant
[
  {
    "external_ref": "cp_301",
    "matter_external_ref": "case_71",
    "person_external_ref": "person_anna",
    "side": "FIRM",
    "participant_type": "LAWYER",
    "is_lead": true,
    "created_at": "2024-03-02T09:14:00Z"
  },
  {
    "external_ref": "cp_302",
    "matter_external_ref": "case_71",
    "person_external_ref": "person_john",
    "side": "CLIENT",
    "participant_type": "CLIENT",
    "is_lead": true,
    "created_at": "2024-03-02T09:14:00Z"
  }
]
// inserted: Matter (matter-db)
{
  "id": "mat_k4p",
  "humanReadableId": "16095",
  "title": "Purchase of 12 Acacia Avenue",
  "status": "ACTIVE",
  "source": "IMPORT",
  // run config
  "firmId": "firm_9y2",
  // derived: the importing firm
  "firmOfRecordId": "firm_9y2",
  "market": "GB",
  "primaryJurisdictionId": "gb-england-wales",
  "createdAt": "2024-03-02T09:14:00Z",
  "lastActivityAt": "2026-07-29T16:02:00Z",
  "completedAt": null
}
// inserted: MatterParticipant × 2 (matter-db)
[
  {
    "id": "matpar_c2",
    "matterId": "mat_k4p",
    // registry: person_anna
    "identityId": "idn_anna",
    "side": "FIRM",
    "participantType": "LAWYER",
    "isLead": true,
    // stamped on firm-side rows
    "firmId": "firm_9y2"
  },
  {
    "id": "matpar_c3",
    "matterId": "mat_k4p",
    // registry: person_john
    "identityId": "idn_john",
    "side": "CLIENT",
    "participantType": "CLIENT",
    "isLead": true,
    "firmId": null
  }
]
// inserted: MatterTeamAccess (matter-db)
// derived from the lead lawyer's team, as the organic create path does
{
  "id": "mta_88",
  "matterId": "mat_k4p",
  "teamId": "team_5rw"
}
// inserted: ImportedRecord × 3 (matter-db, same transaction)
[
  {
    "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"
  }
]

5. contact, contact_method, contact_address

The firm's address book: Contact with its Individual/Company satellite, plus ContactMethod and ContactAddress. The satellite folds into one flat row (rule 7); the importer re-normalises by contactType.

export const contactImport = z.object({
  externalRef: pk,
  // INDIVIDUAL | COMPANY
  contactType: ContactTypeSchema,
  // individual fields (null unless INDIVIDUAL)
  firstName: z.string().nullable(),
  lastName: z.string().nullable(),
  title: z.string().nullable(),
  // company fields (null unless COMPANY)
  legalName: z.string().nullable(),
  tradingName: z.string().nullable(),
  registrationNumber: z.string().nullable(),
  // set when this contact is also a platform user;
  // resolves to an identity imported by schema 1
  personExternalRef: externalRef.nullable(),
  notes: z.string().nullable(),
  createdAt: z.coerce.date(),
});

export const contactMethodImport = z.object({
  externalRef: pk,
  contactExternalRef: parent(contactImport),
  // EMAIL | PHONE | ...
  methodType: ContactInfoTypeSchema,
  value: z.string().min(1),
  label: z.string().nullable(),
  isPrimary: z.boolean(),
});

export const contactAddressImport = z.object({
  externalRef: pk,
  contactExternalRef: parent(contactImport),
  lines: z.string().min(1),
  city: z.string().min(1),
  province: z.string().nullable(),
  postalCode: z.string().nullable(),
  country: z.string().min(1),
  label: z.string().nullable(),
  isPrimary: z.boolean(),
});
// canonical: contact (the seller on case_71)
{
  "external_ref": "party_kate",
  "contact_type": "INDIVIDUAL",
  "first_name": "Kate",
  "last_name": "Morris",
  "title": "Ms",
  "legal_name": null,
  "trading_name": null,
  "registration_number": null,
  "person_external_ref": null,
  "notes": null,
  "created_at": "2024-03-02T10:00:00Z"
}
// canonical: contact_method
{
  "external_ref": "party_kate:email",
  "contact_external_ref": "party_kate",
  "method_type": "EMAIL",
  "value": "kate.morris@example.com",
  "label": null,
  "is_primary": true
}
// inserted: Contact (identity-db)
{
  "id": "ctc_m3",
  // run config
  "firmId": "firm_9y2",
  "type": "INDIVIDUAL",
  "identityId": null,
  "notes": null,
  "createdAt": "2024-03-02T10:00:00Z"
}
// inserted: Individual (identity-db)
// re-normalised from the flat contact row
{
  "id": "ind_m3",
  "contactId": "ctc_m3",
  "firstName": "Kate",
  "lastName": "Morris",
  "title": "Ms"
}
// inserted: ContactMethod (identity-db)
{
  "id": "cmeth_e1",
  "contactId": "ctc_m3",
  "type": "EMAIL",
  "value": "kate.morris@example.com",
  "label": null,
  "isPrimary": true
}
// inserted: ImportedRecord × 2 (identity-db, same transaction)
// the folded Individual has no externalRef of its own
[
  {
    "entityType": "Contact",
    "entityId": "ctc_m3",
    "externalRef": "party_kate",
    "importRunId": "imprun_platform-v2_firm_9y2_1_20260815"
  },
  {
    "entityType": "ContactMethod",
    "entityId": "cmeth_e1",
    "externalRef": "party_kate:email",
    "importRunId": "imprun_platform-v2_firm_9y2_1_20260815"
  }
]

6. matter_contact

Who the matter is about, as MatterContact. Its own table because the link carries data (rule 8). MatterContactSlot (unfilled template roles) is v3 workflow, never imported.

export const matterContactImport = z.object({
  externalRef: pk,
  matterExternalRef: parent(matterImport),
  contactExternalRef: externalRef,
  // CLIENT | OTHER
  role: MatterContactRoleSchema,
  // the source's free-text role, e.g. "Seller"
  roleLabel: z.string().nullable(),
  notes: z.string().nullable(),
  createdAt: z.coerce.date(),
});
// canonical: matter_contact
{
  "external_ref": "case_71:party_kate",
  "matter_external_ref": "case_71",
  "contact_external_ref": "party_kate",
  "role": "OTHER",
  "role_label": "Seller",
  "notes": null,
  "created_at": "2024-03-02T10:00:00Z"
}
// inserted: MatterContact (matter-db)
{
  "id": "matcon_s9",
  // registry: case_71
  "matterId": "mat_k4p",
  // registry: party_kate
  "contactId": "ctc_m3",
  "role": "OTHER",
  "roleLabel": "Seller",
  "notes": null,
  "createdAt": "2024-03-02T10:00:00Z"
}
// inserted: ImportedRecord (matter-db, same transaction)
{
  "entityType": "MatterContact",
  "entityId": "matcon_s9",
  "externalRef": "case_71:party_kate",
  "importRunId": "imprun_platform-v2_firm_9y2_1_20260815"
}

7. matter_file_folder and matter_file

The paper trail: MatterFileFolder, matter-service's own File blob record, and MatterFile. The row is metadata; the bytes ride sidecar (rule 5). Editable source documents flatten to rendered files; recreating editable MatterDocuments is a later capability for sources with portable bodies.

export const matterFileFolderImport = z.object({
  externalRef: pk,
  matterExternalRef: parent(matterImport),
  title: z.string().min(1),
  order: z.number().int(),
});

export const matterFileImport = z.object({
  externalRef: pk,
  matterExternalRef: parent(matterImport),
  folderExternalRef: externalRef.nullable(),
  // staged object store, owned by the run snapshot;
  // scanned on arrival, checksum verified on copy
  payloadUri: z.string().min(1),
  payloadSha256: z.string().length(64),
  filename: z.string().min(1),
  fileSize: z.number().int().positive(),
  // ALL | FIRM_SIDE_ONLY
  visibility: MatterFileVisibilitySchema,
  uploaderPersonExternalRef: externalRef.nullable(),
  createdAt: z.coerce.date(),
});
// canonical: matter_file_folder
{
  "external_ref": "fold_corr",
  "matter_external_ref": "case_71",
  "title": "Correspondence",
  "order": 1
}
// canonical: matter_file
{
  "external_ref": "doc_882",
  "matter_external_ref": "case_71",
  "folder_external_ref": "fold_corr",
  "payload_uri": "s3://firm-migrations/firm_9y2/snap_0815/blobs/doc_882.pdf",
  "payload_sha256": "9f2c…64 hex chars…a11b",
  "filename": "draft-contract.pdf",
  "file_size": 481332,
  "visibility": "ALL",
  "uploader_person_external_ref": "person_anna",
  "created_at": "2024-03-14T11:20:00Z"
}
// inserted: MatterFileFolder (matter-db)
{
  "id": "mff_c1",
  "matterId": "mat_k4p",
  "title": "Correspondence",
  "order": 1
}
// inserted: File (matter-db)
// blob streamed from the sidecar into matter-service's store, born READY:
// no upload lifecycle, no scan choreography, no notifications
{
  "id": "file_7q",
  "status": "READY",
  "filename": "draft-contract.pdf",
  "size": 481332,
  "sha256": "9f2c…a11b"
}
// inserted: MatterFile (matter-db)
{
  "id": "matfile_d4",
  "matterId": "mat_k4p",
  // registry: fold_corr
  "folderId": "mff_c1",
  "fileId": "file_7q",
  "source": "IMPORT",
  "visibility": "ALL",
  // registry: person_anna
  "uploaderId": "idn_anna",
  "createdAt": "2024-03-14T11:20:00Z"
}
// inserted: ImportedRecord × 2 (matter-db, same transactions)
// one entry per staged row; the File blob record hangs off the MatterFile
[
  {
    "entityType": "MatterFileFolder",
    "entityId": "mff_c1",
    "externalRef": "fold_corr",
    "importRunId": "imprun_platform-v2_firm_9y2_1_20260815"
  },
  {
    "entityType": "MatterFile",
    "entityId": "matfile_d4",
    "externalRef": "doc_882",
    "importRunId": "imprun_platform-v2_firm_9y2_1_20260815"
  }
]

8. matter_note

A note on the matter: MatterNote, backed by a derived Content. Body is portable markup (rule 6); the importer converts it and creates the Content machinery itself. That conversion is versioned importer code, not a per-source transform problem.

export const matterNoteImport = z.object({
  externalRef: pk,
  matterExternalRef: parent(matterImport),
  name: z.string().nullable(),
  bodyMarkdown: z.string().min(1),
  authorPersonExternalRef: externalRef,
  createdAt: z.coerce.date(),
});
// canonical: matter_note
{
  "external_ref": "note_43",
  "matter_external_ref": "case_71",
  "name": "Call with seller's solicitor",
  "body_markdown": "Agreed completion **28 June**. Awaiting searches.",
  "author_person_external_ref": "person_anna",
  "created_at": "2024-05-10T15:45:00Z"
}
// inserted: Content (content-db)
// derived: the importer converts the markdown into an editable body
{
  "id": "cont_x2",
  "scope": "firm_9y2"
}
// inserted: MatterNote (matter-db)
{
  "id": "matnote_n8",
  "matterId": "mat_k4p",
  "name": "Call with seller's solicitor",
  "contentId": "cont_x2",
  // registry: person_anna
  "authorId": "idn_anna",
  "createdAt": "2024-05-10T15:45:00Z"
}
// inserted: ImportedRecord (matter-db, same transaction)
{
  "entityType": "MatterNote",
  "entityId": "matnote_n8",
  "externalRef": "note_43",
  "importRunId": "imprun_platform-v2_firm_9y2_1_20260815"
}

9. task and key_date

What needs doing: Task with its TaskAssignment folded into the task row (rule 7), and KeyDate. Both are matter-nullable, as in v3, because firm-level work exists. Key dates are a proposed schema: few external systems have a concept that maps to them, so expect this table to be empty for most sources; tasks reference them nullably either way.

export const keyDateImport = z.object({
  externalRef: pk,
  // nullable parent: matter-scoped rows dump as a child file, firm-level
  // rows as their own root file (rule 10), so neither carries a null key
  matterExternalRef: parent(matterImport).nullable(),
  name: z.string().min(1),
  dueAt: z.coerce.date(),
  createdAt: z.coerce.date(),
});

export const taskImport = z.object({
  externalRef: pk,
  // nullable parent: matter-scoped rows dump as a child file, firm-level
  // rows as their own root file (rule 10), so neither carries a null key
  matterExternalRef: parent(matterImport).nullable(),
  keyDateExternalRef: externalRef.nullable(),
  name: z.string().min(1),
  description: z.string().nullable(),
  assigneePersonExternalRef: externalRef.nullable(),
  dueAt: z.coerce.date().nullable(),
  completedAt: z.coerce.date().nullable(),
  createdAt: z.coerce.date(),
});
// canonical: key_date
{
  "external_ref": "kd_compl",
  "matter_external_ref": "case_71",
  "name": "Completion",
  "due_at": "2024-06-28T00:00:00Z",
  "created_at": "2024-05-10T15:50:00Z"
}
// canonical: task
{
  "external_ref": "todo_507",
  "matter_external_ref": "case_71",
  "key_date_external_ref": "kd_compl",
  "name": "Order searches",
  "description": null,
  "assignee_person_external_ref": "person_anna",
  "due_at": "2024-05-17T00:00:00Z",
  "completed_at": "2024-05-16T09:30:00Z",
  "created_at": "2024-05-10T15:50:00Z"
}
// inserted: KeyDate (matter-db)
{
  "id": "kdate_f2",
  "matterId": "mat_k4p",
  "name": "Completion",
  "dueAt": "2024-06-28T00:00:00Z",
  "createdAt": "2024-05-10T15:50:00Z"
}
// inserted: Task (matter-db)
{
  "id": "task_9m",
  "matterId": "mat_k4p",
  // registry: kd_compl
  "keyDateId": "kdate_f2",
  "name": "Order searches",
  "description": null,
  "dueAt": "2024-05-17T00:00:00Z",
  "completedAt": "2024-05-16T09:30:00Z",
  "createdAt": "2024-05-10T15:50:00Z"
}
// inserted: TaskAssignment (matter-db)
// re-normalised from the assignee ref
{
  "id": "taskass_a3",
  "taskId": "task_9m",
  "identityId": "idn_anna"
}
// inserted: ImportedRecord × 2 (matter-db, same transactions)
[
  {
    "entityType": "KeyDate",
    "entityId": "kdate_f2",
    "externalRef": "kd_compl",
    "importRunId": "imprun_platform-v2_firm_9y2_1_20260815"
  },
  {
    "entityType": "Task",
    "entityId": "task_9m",
    "externalRef": "todo_507",
    "importRunId": "imprun_platform-v2_firm_9y2_1_20260815"
  }
]

10. chat_message

Chat history lands as real messaging-service Message rows so the Messages tab shows continuity. The Channel is never staged: v3 creates one per matter at matter creation (channel id = matter id); the importer writes into it and derives ChannelParticipants from the distinct authors.

export const chatMessageImport = z.object({
  externalRef: pk,
  matterExternalRef: parent(matterImport),
  authorPersonExternalRef: externalRef,
  body: z.string().min(1),
  sentAt: z.coerce.date(),
});
// canonical: chat_message
{
  "external_ref": "msg_2210",
  "matter_external_ref": "case_71",
  "author_person_external_ref": "person_john",
  "body": "Any news on the searches?",
  "sent_at": "2024-05-20T08:12:00Z"
}
// inserted: Message (messaging-db)
{
  "id": "msg_h6",
  // channel id IS the matter id
  "channelId": "mat_k4p",
  // registry: person_john
  "authorId": "idn_john",
  "body": "Any news on the searches?",
  "sentAt": "2024-05-20T08:12:00Z"
}
// inserted: ChannelParticipant (messaging-db)
// derived: one per distinct author not yet in the channel
{
  "id": "chpar_j1",
  "channelId": "mat_k4p",
  "identityId": "idn_john",
  "status": "ACTIVE"
}
// inserted: ImportedRecord (messaging-db, same transaction)
{
  "entityType": "Message",
  "entityId": "msg_h6",
  "externalRef": "msg_2210",
  "importRunId": "imprun_platform-v2_firm_9y2_1_20260815"
}

11. call

The call record lands as matter-service's Call, the matter-facing record behind attendance notes. Telephony-service is live phone infrastructure, not history, so it is not a target. Recordings ride sidecar.

Expectation from review (Jaime): this table will be empty for most sources — practice management systems rarely export call history. It exists because v2 does hold platform calls (67 in dev data alone); low priority for anything else.

export const callImport = z.object({
  externalRef: pk,
  matterExternalRef: parent(matterImport),
  // INBOUND | OUTBOUND
  direction: CallDirectionSchema,
  // the platform-side participant, when known
  personExternalRef: externalRef.nullable(),
  // the other party as the source recorded them
  otherPartyLabel: z.string().nullable(),
  startedAt: z.coerce.date(),
  endedAt: z.coerce.date().nullable(),
  // recording, if any (rule 5)
  recordingPayloadUri: z.string().nullable(),
  recordingPayloadSha256: z.string().length(64).nullable(),
  // transcript or attendance note, plain text
  noteText: z.string().nullable(),
});
// canonical: call
{
  "external_ref": "call_118",
  "matter_external_ref": "case_71",
  "direction": "OUTBOUND",
  "person_external_ref": "person_anna",
  "other_party_label": "Seller's solicitor (Hartley & Co)",
  "started_at": "2024-05-10T15:00:00Z",
  "ended_at": "2024-05-10T15:22:00Z",
  "recording_payload_uri": null,
  "recording_payload_sha256": null,
  "note_text": "Discussed completion date; agreed 28 June."
}
// inserted: Call (matter-db)
{
  "id": "call_v5",
  "matterId": "mat_k4p",
  "direction": "OUTBOUND",
  // registry: person_anna
  "personId": "idn_anna",
  "otherPartyLabel": "Seller's solicitor (Hartley & Co)",
  "startedAt": "2024-05-10T15:00:00Z",
  "endedAt": "2024-05-10T15:22:00Z",
  "noteText": "Discussed completion date; agreed 28 June."
}
// inserted: ImportedRecord (matter-db, same transaction)
{
  "entityType": "Call",
  "entityId": "call_v5",
  "externalRef": "call_118",
  "importRunId": "imprun_platform-v2_firm_9y2_1_20260815"
}

Deliberately not in this catalogue

Flagged hard in review (Jaime, agreed): these omissions are deliberate but not comfortable — the catalogue expands ASAP, and the known-needed list is custom fields, emails, billed time / WIP, invoices / costs, and trust balances. The money items below are one slice with the money team; the rest are their own passes.