Matters are the focus because they are the entity that actually uses a human-readable id today. The recipe at the end applies to any entity with a user-visible number. The companion decision on tracking where imported rows came from is in the data provenance doc.
Matter.humanReadableId becomes a per-firm-unique string. Imported matters keep the reference their firm already used; native matters keep drawing numbers from the existing Postgres sequence, cast to text. Fast path: read the TL;DR and the options table.
What is visible today
An audit across the apps found that the matter reference is effectively the only user-visible identifier in matter scope:
Matter.humanReadableIdis a global autoincrement int. It appears at ~45 render sites across legal-os, admin-app, client-os and the Outlook add-in, in ~30 Knock notification payloads, in letterheadourRef, invoice PDFs and download filenames. The#prefix is applied ad hoc at each site; the raw value is what is stored.- It is never used in URLs. Routes key on the
mat_cuid throughout. - Files, notes, tasks, key dates and contacts expose no identifier of their own, only names and titles.
- Billing documents have their own visible numbers (
Invoice.humanReadableInvoiceId,BillingInvoice.number,Quote.reference). They get the same treatment when the money domain is imported.
So for the matter slice, the identifier question is a Matter question.
The design
/// The user-visible matter reference. Native matters take the next value
/// from the platform sequence; imported matters keep the reference their
/// firm already used (e.g. "25/16095").
humanReadableId String @default(dbgenerated("nextval('matter_human_readable_id_seq')::text"))
@@unique([firmId, humanReadableId])
Three mechanics fall out of this:
- Native generation keeps the Postgres sequence. The column stops being a Prisma
autoincrement, but its default still drawsnextval(...)::text. Concurrent matter creation stays atomic with no application-level locking, and there is no "query the max and add one" step, which could not work anyway once a firm's references are not numeric. - Uniqueness becomes per-firm. The current global
@uniqueonly makes sense for one shared sequence. Imported references are unique per firm at best: two migrated firms will both have a "26/001". One edge case is mandatory to handle: an imported numeric-looking reference ("12345") can collide with a value the native sequence reaches later in the same firm, so native creation retries on conflict with the next sequence value. Rare and cheap, but it must exist. - The three kinds of matter coexist in one column. Marketplace and new v3 matters carry sequence values (now strings), imported matters carry their source references, and future per-firm formats, including seeding a firm in above its old numbering ("start at 26/5000"), are ways of filling the same column. Who agrees a cut-in number with a firm is an operational question this design enables but does not answer.
Options considered
| Option | Why it was attractive | Why it lost |
|---|---|---|
| Coerce imported matters into the existing int sequence | Zero schema change | Breaks reference continuity outright: the firm's paper trail stops matching their system, which is the exact failure this decision exists to prevent |
Overlay field: keep the int, add externalReference String?, read models derive a displayReference | Purely additive, no type-change risk | Two identifier concepts forever. Every consumer must know which to show, imported matters are second class (their real reference lives in a side field), and the dual concept never goes away. Superseded once the type change proved mechanical |
| A smarter sequence (year-aware, seedable starting value) | Matches the "25/16095" shape directly | Still numeric at heart, so it cannot hold arbitrary formats from other source systems. The string column subsumes it: a year-prefixed scheme becomes one way of filling the column |
| String, per-firm unique, sequence default (chosen) | Imported references are first class; one identifier concept; generalises to any entity with a visible number | The conversion has real but mechanical breadth, listed below |
Later: per-firm reference formats
Not in scope now, but worth naming because the string column makes it cheap. A firm-level setting could define how each entity type's references are generated: a matter reference format on the firm, say. The default is the platform sequence, which is today's behaviour and what every existing firm keeps. Imported firms carry whatever format their references arrived in, and the two should not clash, since an imported firm's numbering is its own. A custom format is an interpolated string of tokens evaluated at creation time, a year token plus a counter token for the "year/number" schemes above, so richer formats stay a configuration change rather than a schema one. Creation still has to check for conflicts: a counter token means deriving "the max that exists plus one" for that format, and two simultaneous creations can land on the same value, so this reuses the retry-on-conflict path the design already requires. In practice collisions should stay rare, because these references sit on entities people create a few at a time, not at high frequency.
Conversion checklist
The int-to-string change is wide but each piece is mechanical:
- Render sites (~45). Almost all already do
String(matter.humanReadableId)or interpolate into#${…}; they get simpler. One decision to make: keep prefixing native numeric ids with#at render time, or stop prefixing now the value is self-describing. - Search paths that parse ints switch to string matching:
searchMatters.ts,buildMatterSearchWhere.ts,listMattersForAdmin.ts,matterContact/services/list.ts,listReviewRequests.ts. This is also what makes imported matters findable by the only reference their firm knows. - Typesense. The collection types
humanReadableIdasint32and admin search filters onhumanReadableId:=<int>. Retype to string, reindex. - Knock. ~30 workflows carry
matterHumanReadableId, stringified independently at ~15 emit sites. Payloads become the string; worth extracting one shared helper while touching them. - PDF freeze points carry the string through:
build-invoice-payload.tsfreezes the matter reference intomatterSnapshotsat publish, and the legacyInvoicePreviewtakes amatterNumberprop. The Legl payment email already calls.toString(). - Sorting. Tables default-sort by
humanReadableId, and strings sort lexicographically ("10" before "9"). Switch the default sort tocreatedAtor use a natural-sort collation. Decide during the conversion, not after a firm complains.
The recipe for other entities
Any top-level entity that needs a user-visible identifier follows the same pattern:
- The identifier is a string column on the entity.
- Native values default from a per-entity Postgres sequence, cast to text.
- Imports supply their own values.
- Uniqueness is scoped to the owning aggregate (the firm, usually), not global.
- Native generation retries on conflict with the next sequence value.
Billing documents are the known next customers: an imported invoice keeping the number its client originally saw matters for the same continuity reasons. Apply the recipe per entity when that domain's import is designed.