Skip to Content
PersistenceRepositories & Adapters

Repositories and the row/adapter contract

Persistence exposes the database through per-aggregate repository modules (packages/persistence/src/*-repository.ts) built on Kysely, and through a dedicated adapter layer (packages/persistence/src/adapters/) that is the only place storage row types are allowed to meet the fund-economics runtime’s canonical records. This page explains both, with real code from the repo.

The seam exists because of a real failure mode, ratified in ADR-0001 and ADR-0002 (docs/adrs/0001-canonical-record-contract.md, 0002-runtime-persistence-trace-separation.md): before it, calculation records, persistence rows, and trace bundles shared type names, so a field added for trace evidence could silently look like a storage contract. The fix is three named surfaces per calculation domain, living on deliberate sides of the package boundary:

SurfaceOwnerPurpose
<Domain>Applicationfund-economics runtimeThe canonical semantic record of one applied calculation decision (schema_version, application_hash, closed-union diagnostics, evidence_refs, posting_intent)
<Domain>ApplicationTracefund-economics runtimeTrace bundle wrapping applied canonical records + diagnostics + skips
<Domain>ApplicationRowpersistence (adapters only)A narrowed projection of canonical fields for durable storage and indexing

The import rules are structural, gated in CI: runtime source under packages/fund-economics/src/runtime/ may not import row types from @opsdna/persistence; non-adapter persistence source may not import *Application/*Trace types; only src/adapters/ bridges the two.

A real adapter

From packages/persistence/src/adapters/carry-rate-resolution-application-row-adapter.ts — note the as const satisfies trick: the projected-field list is checked by the compiler against the canonical record’s keys, so a renamed canonical field breaks the adapter at typecheck time:

export const CARRY_RATE_RESOLUTION_ROW_PROJECTED_FIELDS = [ "schema_version", "application_hash", "kind", "diagnostics", "evidence_refs", "posting_intent", "tier_id", "tier_order", "carry_rate_ref", "resolved_value", "source_ref", "approval_status", "lookup_chain", ] as const satisfies readonly (keyof CarryRateResolutionApplication)[] export type CarryRateResolutionApplicationRow = NarrowedCanonical< CarryRateResolutionApplication, typeof CARRY_RATE_RESOLUTION_ROW_PROJECTED_FIELDS > export function toCarryRateResolutionApplicationRow( canonical: CarryRateResolutionApplication, ): CarryRateResolutionApplicationRow { /* explicit field-by-field copy */ }

Each row also gets a runtime Schema.Struct decoder (CarryRateResolutionApplicationRowSchema) so data read back from Postgres is validated, with closed-union diagnostics (code: string is banned by ADR-0001 — every diagnostic code is a literal union member).

Plain repositories

Most tables are not fund-economics projections; they get an ordinary repository. packages/persistence/src/api-token-repository.ts is a good small template:

export interface ApiTokenRepository { readonly createApiToken: (args: { readonly tenantId: string readonly id: string readonly name: string readonly tokenHash: string // sha256 only — plaintext never stored // ... }) => Promise<{ readonly status: "created" }> readonly listApiTokens: (args: { readonly tenantId: string }) => Promise<readonly ApiTokenSummary[]> // ... }

Conventions visible there and across the package:

  • Every method is tenant-scoped and runs inside withTenantTransaction (src/tenant-context.ts), which sets the app.tenant_id session GUC that RLS policies key on — see Roles & RLS.
  • Table names come from generated constants (T in src/schema-table-constants.generated.ts), rendered from the schema-ownership manifest, not hand-typed strings.
  • Listing shapes are explicit interfaces that omit sensitive columns (e.g. ApiTokenSummary never includes token_hash).

The schema registry

packages/persistence/src/schema-registry.ts is the machine-readable manifest of every live table (ADR-0025 “L0”): its target schema and domain (entity, auth, ledger, pcap, crm, kpi, sharing, …), its RLS policy class (tenant_required | tenantless | operator_only), and its primary-key id family (canonical_hash | deterministic_v5 | surrogate_uuidv7 | natural_key | external). The Kysely database type, grant manifests, RLS coverage gates, and the browsable docs/schema-atlas.html are all rendered from it. If you add a table, it must land here.

Ids in the canonical_hash and deterministic_v5 families are part of the determinism freeze set — the Acceptance Lab rebuilds them byte-identically. Never flip one to a random uuid. When wiring fund structures, use the helpers in packages/persistence/src/fund-structure-service.ts (assignMancoToFund, linkFeederToMaster, …) instead of hand-rolled inserts; they encode the ADR-0018 routing rules fail-closed.

For the entity-level rules behind all of this (who owns what, which ids are deterministic), see the entity model.

Last updated on