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:
| Surface | Owner | Purpose |
|---|---|---|
<Domain>Application | fund-economics runtime | The canonical semantic record of one applied calculation decision (schema_version, application_hash, closed-union diagnostics, evidence_refs, posting_intent) |
<Domain>ApplicationTrace | fund-economics runtime | Trace bundle wrapping applied canonical records + diagnostics + skips |
<Domain>ApplicationRow | persistence (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 theapp.tenant_idsession GUC that RLS policies key on — see Roles & RLS. - Table names come from generated constants (
Tinsrc/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.
ApiTokenSummarynever includestoken_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.