Identifier policy
OpsDNA does not have one id scheme β it has four, chosen per logical id class (what the id is for), never inferred from the SQL column type. This page explains the classes, the uuid-v5 derivation rule for the entity spine, and the hash βfreeze setβ that makes some ids effectively immutable data. The normative sources are ADR-0006 (docs/adrs/0006-deterministic-vs-surrogate-ids.md), ADR-0007, and the identifier-policy section of DATA-MODEL.md.
The policy exists because two goods pull in opposite directions (ADR-0006): storage performance wants time-ordered random ids (UUIDv7) for B-tree insert locality, but reproducibility wants ids that are a deterministic function of the rowβs logical identity. Two systems make determinism non-negotiable here. First, the Acceptance Lab is a release gate that rebuilds persisted state from a fund spec and asserts it is byte-identical across pglite β Postgres and run-to-run β random ids would make every rebuild diff as 100% changed. Second, FundStatementsIR lineage puts ids on every numeric line (packages/fund-statements-ir/src/lineage.ts): the drill-through from a number to the rows that produced it is the audit deliverable, so the ids are data.
ADR-0007βs most important amendment corrected an early mistake: v1 said βv7 for the commercial spine.β Wrong β the Lab rebuilds the whole spine with zero randomUUID calls, so the spine is Class A (deterministic), and v7 is reserved for operational/append tables the Lab never touches.
The four schemes
is the row lineage-bearing / Lab-compared?
β
ββββββββββ yes ββββββββββ΄ββββββββ no βββββββββββ
β β
is the id a function of a owned by an external
canonical payload (content)? system (WorkOS etc.)?
β β β β
yes no β derived from a yes no
β stable natural key β β
content-hash text deterministic uuid v5 external uuid v7
(journal entries/lines, (the entity spine: text surrogate
PCAP allocations, org, fund, legal_entity (tenant root,
source events/docs, + extensions, partner jobs, outbox,
evidence, build*Id) account/class, books) audit events)| Scheme | Used for | Why |
|---|---|---|
content hash (text) | Journal entries/lines, committed PCAP allocations, source documents/events, evidence artifacts, rule bundles, build*Id outputs | The canonical payload determines the id β replay and idempotency for free (INSERT β¦ ON CONFLICT DO NOTHING is a no-op) |
| deterministic uuid v5 | The Lab-rebuilt spine: organization, fund, legal entity + its three extensions, partner account/class, vehicle ruleset versions, relationships, fee arrangements, ledger.book, ledger.account | Class A rows must rebuild byte-identically and stay stable IR lineage targets |
| uuid v7 surrogate | Tenant root, approval/workflow rows, sessions, jobs, audit events, outbox attempts, reference.organization | Class B: pure storage identity; v7βs insert locality actually pays on high-write append tables |
external text | WorkOS users, provider subject ids | Owned elsewhere |
The v5 derivation guardrail
// packages/canonical-json β deriveSpineId, used by every spine producer
id = uuidv5(ns, tenant_id + ':' + entity_kind + ':' + stable_key)The stable_key must be immutable and entity-kind-namespaced β derived from a canonical logical key, never from a mutable name, display_name, slug, domain, or WorkOS id. Once assigned, the id is frozen: if the natural key is later corrected, the id is not recomputed (value stability beats key-faithfulness). A real producer example from packages/persistence/src/fund-structure-service.ts:
const stableKey = `mfa:${payer.stableKey}:${args.effectiveFrom}`
const id = deriveSpineId("management_fee_arrangement", args.tenantId, stableKey)ledger.account has a documented variant: it namespaces under the already-globally-unique accounting_entity_id β uuidv5(ns, 'ledger_account:' + accounting_entity_id + ':' + account_stable_key) β with the stable key being canonical:<kind> for canonical accounts. The accountβs meaning (kind, display code, name) lives in mutable columns and is never in the id.
Related rule 8: no slug identity. URLs key on the uuid (via the prefixedUuidBrand codec in @opsdna/brands β bare uuid on the wire and in the DB); display_name is a plain label. *_stable_key columns are not slugs β they are the v5 derivation inputs and stay.
The freeze set
Because accounting_entity_id, book_id, and ledger.account_id are inputs to posting-output hashes and idempotency keys, changing them after data lands changes every journal hash β breaking deterministic replay (the ADR-0001 canonical-record contract). The full freeze set in DATA-MODEL.md includes tenantId, accounting_entity_id, book_id, account_id, *SourceEventId, sourceDocumentId, journalEntryId, decisionPacketId, ruleBundleHash, codeVersion and all build*Id content-addressed ids. A CI lint fails any new surrogate-id use inside hashCanonicalAuditValue / hashCanonicalValue / build*Id / idempotency-key construction. The one legitimate pre-GA re-key (slug β v5) was done as a single controlled hash-version bump + golden reseal (ADR-0007 Decision 3).
Pitfalls. (1) Never infer the logical scheme from the SQL type: ledger.account.account_id holds a uuid-v5 value in a legacy text column, and entity.partner_account_commitment.id is text but its contract is an operational surrogate. Do not retype or rename these freeze-set columns incidentally, and never coerce a physical text column into the uuid brand family because its producer happens to emit uuid-shaped values. (2) Donβt derive a stable key from anything mutable β a rename must never change an id. (3) Donβt put a Class-B random id on anything the Lab compares or IR lineage drills through; conversely donβt pay Class-A costs on high-write rows nobody references. (4) If you add a defensive unique index beside a Class-A id, it must cover the entire logical key β partial keys over-reject (the ledger_position_marks same-day-correction lesson, ADR-0006).
See /persistence for how the spine producers and row adapters use these ids, and /fund-accounting for the content-hash posting core they feed.