previews / firm-migrations / human-readable ids

Platform · Firm migrations

Human-readable ids for imported entities

One decision to approve or veto: the matter reference becomes a per-firm-unique string, so an imported matter can keep the reference its firm has spent years writing on letters.

Status: Draft for team review
Ask: approve or veto one decision

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.

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.

TL;DR. Firms cite their matter references in correspondence with clients and courts. If a firm has spent years writing "25/16095" on letters, an imported matter must show "25/16095", not a fresh number from our sequence, or the firm's own paper trail stops matching their system. The cheapest sound way to do that is to make the reference itself flexible: a string column, unique per firm, defaulting to the existing sequence for native matters. Imported references become first class, and richer formats later (year-prefixed schemes, seeded starting numbers) become format decisions rather than schema changes.

What is visible today

An audit across the apps found that the matter reference is effectively the only user-visible identifier in matter scope:

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:

Options considered

OptionWhy it was attractiveWhy it lost
Coerce imported matters into the existing int sequenceZero schema changeBreaks 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 displayReferencePurely additive, no type-change riskTwo 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 directlyStill 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 numberThe 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:

The recipe for other entities

Any top-level entity that needs a user-visible identifier follows the same pattern:

  1. The identifier is a string column on the entity.
  2. Native values default from a per-entity Postgres sequence, cast to text.
  3. Imports supply their own values.
  4. Uniqueness is scoped to the owning aggregate (the firm, usually), not global.
  5. 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.