Skip to Content
ArchitectureDesign Principles

Design Principles

Each section of these docs describes a different subsystem, but the same handful of patterns recur in all of them. If you internalize these, most of the codebase’s “why is it built this way?” questions answer themselves — and most review feedback you’ll receive is one of these principles being defended.

Never two authored truths for one money fact

The oldest and most-repeated rule (ADR-0011 calls the violation the “dual identity smell”). Every money fact has exactly one canonical, authored home; everything else is a projection with recorded provenance. PCAP per-LP component balances are a projection over the GL, never a second ledger (ADR-0010/0017/0034). GP-ness is derived from legal_entity_category, not stored as a partner role. fund_terms_index is a derived projection of the terms payload, and engines are mechanically banned from reading it (ADR-0019). recordEntityRelationship outright refuses to author an edge that is derivable from a partner_account chain. When you find yourself adding a second table that restates a fact, stop — add a projection with source_ref/source_hash instead.

The determinism spine

Anything that participates in fund accounting must be replayable to the byte, because the audit story rests on “same inputs, same output” being a mechanically checkable claim rather than a promise:

Concretely:

  • Canonical-JSON hashing everywhere: application_hash, payload_hash, posting input_hash/output_hash, plan hashes — all computed over @opsdna/canonical-json encodings.
  • Deterministic ids: the Lab-rebuilt entity spine is uuid v5 over immutable stable keys; ledger accounts derive from canonical:<kind> stable keys; journal entries and allocations are content-hash ids. Surrogate uuid v7 is reserved for operational/append tables. See the identifier policy.
  • The freeze set: ids that feed hashes (tenantId, accounting_entity_id, book_id, *SourceEventId, …) are frozen by CI lint — change one and every downstream hash changes.
  • Byte-identical rebuild as the proof: the Acceptance Lab replays whole fund scenarios and diffs the output; pinned engine versions (LEDGER_CODE_VERSION, schema/hash version constants) make “same inputs, same bytes” a testable claim.

Application / Trace / Row, dispatched on kind

Fund-economics runtime emits <Domain>Application canonical records and <Domain>ApplicationTrace bundles; persistence owns narrowed <Domain>ApplicationRow projections with adapter pairs proving forward and reverse identity (ADR-0001/0002). The three surfaces live on different sides of the package boundary, enforced by scripts/check-package-boundaries.ts and boundary tests. Generic consumers dispatch on the kind literal of a discriminated union with never-default exhaustiveness — there is deliberately no shared supertype, so adding a domain forces every consumer to decide what to do with it. Diagnostics are closed per-domain unions; a stringly code: string field is banned. See Application Records.

Deterministic-id idempotency for replay safety

Side-effectful pipelines never rely on “hopefully this runs once.” They derive the output’s identity from the input, so replays converge: a bank transaction version promotes to exactly one operating source event id:

// the OSE id is a pure function of the transaction version — replays converge const operatingSourceEventId = `ose:bank:${version.id}`

saveCandidate answers already_saved, SQS redeliveries and re-driven promotions are no-ops, and follow-on dispatch records are idempotent via deterministic proof-link identity. If you’re writing a worker, your first design question is “what is the deterministic id of the thing I produce?” See Plaid Sync.

Fail closed, everywhere

Ambiguity is an error, not a guess. Tenant resolution with zero grants yields no_workspace; with two-plus grants it requires an explicit, grant-verified selection header (ADR-0033). resolveFundEntityRouting errors on multiple ManCos or GPs rather than picking one. Terms profiles reject shapes they don’t support (unsupported_for_profile) and executability is a firewall, not a warning. Unregistered source-claim types are rejected. An ambiguous malware-scan verdict leaves the document unviewable. Branded route refs reject wrong-kind ids at decode, before any DB read. Default-deny is the portal’s login posture. The pattern: enumerate the outcomes, and make “I’m not sure” a typed failure.

One LLM trust boundary

LLM outputs are constrained behind Effect/Zod schemas, registries, validators, and evals — and the runtime narrows model authority to a single seam: the query planner’s candidate extraction. The model proposes untrusted concept-resolution candidates; a deterministic resolver, a finite 14-operation algebra, plan validation, and the permission engine do everything after that. Planners cannot invent operations; domain growth happens through registries. Extraction pipelines follow the same shape: micro-extraction per clause with span-cited evidence, deterministic assembly in tested TypeScript — the model never composes the tree. Every LLM-facing surface has an eval suite (bun run evals:all). See AI & Evals and Query Runtime.

Money is a branded bigint, never a number

packages/ledger/src/money.ts defines Money as a branded bigint at scale 4 (ratios/quantities at scale 12):

// packages/ledger/src/money.ts declare const MoneyBrand: unique symbol declare const RatioBrand: unique symbol export type Money = bigint & { readonly [MoneyBrand]: void } export type Ratio = bigint & { readonly [RatioBrand]: void } // scale 12

There are no IEEE floats anywhere on a money path: multiplication rounds half-even from a scale-16 intermediate, and Money.divToParts does largest-remainder allocation whose parts sum exactly to the total. Arithmetic traces carry canonical-decimal projections so a re-hash proves equivalence. If you see amount: number in new code, it’s a bug by type construction. See Ledger Kernel.

The edge is stateless; the database is the boundary

The portal Worker holds no DATABASE_URL and enforces only UX-grade gates; the real authorization boundary is apps/api (JWT verification + tenant resolution) backed by Postgres FORCE RLS on tenant_id, with writes to sensitive tables funneled through SECURITY DEFINER functions owned by narrow resolver roles. Client-forwarded context (like the active-tenant header) is treated as an untrusted selection that must land inside a server-verified grant set. Two independent walls, so a bug in one is not a breach. See Portal Auth & Tenancy and Roles & RLS.

The classic ways to violate these principles by accident: storing a derivable fact in a second table instead of a projection (dual identity smell); putting a number on a money path; giving a worker output a random id so replays duplicate; letting an ambiguous case fall through to a default instead of a typed failure; and letting LLM output touch anything that isn’t schema-validated. Reviewers look for exactly these.

Corollaries you’ll meet in review

  • Persist material state transitions: audit events, trace bundles, outbox rows — if runtime behavior matters, it leaves a record.
  • Supersede, never erase: version chains (bank transactions, OSEs, ledger source events, terms versions) clip and point at replacements; posted facts are immutable.
  • Forward-only migrations: applied SQL is checksum-frozen; the active chain lives in post-baseline/. See Migrations.
  • Prefer the stronger path: CLAUDE.md’s standing instruction — don’t relax invariants or take brittle shortcuts to move faster; unwinding a weak shortcut costs more than doing it right.
Last updated on