Skip to Content
Entity ModelIdentifier policy

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)
SchemeUsed forWhy
content hash (text)Journal entries/lines, committed PCAP allocations, source documents/events, evidence artifacts, rule bundles, build*Id outputsThe canonical payload determines the id β†’ replay and idempotency for free (INSERT … ON CONFLICT DO NOTHING is a no-op)
deterministic uuid v5The Lab-rebuilt spine: organization, fund, legal entity + its three extensions, partner account/class, vehicle ruleset versions, relationships, fee arrangements, ledger.book, ledger.accountClass A rows must rebuild byte-identically and stay stable IR lineage targets
uuid v7 surrogateTenant root, approval/workflow rows, sessions, jobs, audit events, outbox attempts, reference.organizationClass B: pure storage identity; v7’s insert locality actually pays on high-write append tables
external textWorkOS users, provider subject idsOwned 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.

Last updated on