How to wire a fund structure
This is the how-to for connecting a fund complex together: binding the ManCo that receives management fees, designating the GP entity that receives carry, and linking feeders into a master. All of it goes through the helpers in packages/persistence/src/fund-structure-service.ts — the executable form of ADR-0018 (docs/adrs/0018-entity-routing-and-fund-structure-graph.md). Hand-rolled INSERTs for fund structure are an explicit review smell.
Why helpers instead of SQL? ADR-0018’s core ruling is typed truth at the write, one fail-closed resolver for routing, projection at the read. Money-bearing edges each have their own typed shape and cardinality invariant (“one active fee arrangement per payer vehicle per window”, “exactly one gp_entity per fund”), and some of those invariants are three-table joins Postgres cannot express as constraints — so the helpers enforce them fail-closed, mint deterministic v5 ids, and are idempotent on re-run. A generic (from, to, kind, jsonb) edge table was explicitly rejected: unifying reads costs one projection; unifying writes costs the invariants, exactly where being wrong moves money.
The mental map:
"Link a ManCo to a fund" → assignMancoToFund (writes management_fee_arrangement)
"Designate the carry GP" → assignGpEntityToFund (writes the GP partner_account chain — NO edge table)
"Model master–feeder" → linkFeederToMaster (partner_account in the master)
"Document a reporting relationship" → recordEntityRelationship (legal_entity_relationship, reporting-grade)
"Who gets fees/carry right now?" → resolveFundEntityRouting
"Draw the whole complex" → selectEntityGraph ({nodes, edges}, tagged authored|typed|derived)Wiring a master–feeder fund
Bind the ManCo (fees)
import { assignMancoToFund } from "@opsdna/persistence"
await assignMancoToFund(db, {
tenantId,
fundId,
mancoLegalEntityId, // must be category 'operating_company'
effectiveFrom: "2026-01-01", // temporal window; effectiveTo omitted = open-ended
// Multi-vehicle funds MUST say which vehicles pay — an LPA fact, never guessed:
payerVehicleLegalEntityIds: [aiFeederId, qpFeederId],
})One arrangement per payer vehicle per effective window (backed by the migration-0283 EXCLUDE). A ManCo transition passes supersedePriorArrangements: true, which clips the prior window (effective_to = day before the new start, status superseded) — it never erases it, so a historical close as-of the old window still routes to the ManCo that was real then. Re-running the identical call is a clean no-op.
Designate the GP entity (carry)
const gp = await assignGpEntityToFund(db, {
tenantId,
fundId,
gpLegalEntity: { stableKey: "fund1_gp", displayName: "Fund I GP, LLC" }, // mint, or pass gpLegalEntityId
// default vehicle = the single master; default accountNumber = "GP-CARRY-001"
})
// gp.gpAccountingEntityId is null until the GP entity's books are provisioned —
// carry postings require them (ADR-0017 Phase 2).There is deliberately no carry edge table: the designation is the partner-account chain — vehicle → partner_account → investor → legal_entity(category='gp_entity') → its accounting_entity. A fund has exactly one gp_entity; assigning a second distinct GP fails with multiple_gp_entities. A GP that takes no carry is a partner-class decision (ADR-0011), never a second GP.
Link the feeders
await linkFeederToMaster(db, { tenantId, fundId, feederLegalEntityId: aiFeederId })The feeder’s legal entity gains an investor extension (idempotent) and a partner_account in the master — rule 2’s symmetric extensions, no special master/feeder table. Equity pickup is then a projection of that position over the master’s live PCAP (ADR-0017); there is nothing else to store. Only feeder/blocker roles qualify — a parallel vehicle invests alongside the master, not in it.
Read the routing back
const routing = await resolveFundEntityRouting(db, {
tenantId,
fundId, // or vehicleLegalEntityId / accountingEntityId
asOfDate: "2026-06-30",
require: { manco: true, mancoBooks: true, gpEntity: true, gpBooks: true },
})
// routing.manco.accountingEntityId → where fee postings go
// routing.gpEntity.accountingEntityId → where carry postings go
// routing.feederPositions → the equity-pickup setThe resolver is fail-closed on every ambiguity — two concurrent ManCos, two distinct GP entities, an unresolvable anchor — errors, never a pick. Close/orchestration paths must pass require so a missing destination is an error rather than a null, and must consume this resolver instead of caller-wired entity ids.
Reporting edges and the graph
recordEntityRelationship authors the ten reporting-grade kinds (manages, custodian_for, member_of_umbrella, …) on entity.legal_entity_relationship. It refuses general_partner_of and invests_in when the from-entity’s investor already holds a partner account in the to-vehicle (derived_edge_authoring_forbidden) — those edges are derived at read time, and authoring a copy would be a second identity for a money-routing fact. Authoring them stays legitimate for external structures with no partner account in our books. selectEntityGraph({ tenantId, fundId? }) returns the whole complex as {nodes, edges} with each edge tagged authored | typed | derived — a projection, never a second store.
All errors are FundStructureError with a stable code from FUND_STRUCTURE_ERROR_CODES (19 codes, from fund_not_found to routing_target_unresolved) and a message that says which rule fired and how to fix it.
Pitfalls. (1) Hand-rolled INSERTs into management_fee_arrangement, fund_vehicle, or partner_account bypass the fail-closed cardinality checks and deterministic id derivation — always use the helpers. (2) Do not author general_partner_of/invests_in rows for in-tenant structures; the helper will refuse, and working around it recreates the dual-identity bug ADR-0011 spent three PRs killing. (3) Multi-vehicle funds must name their fee-paying vehicles explicitly (ambiguous_payer_vehicles otherwise) — feeder-paid vs master-paid varies by LPA. (4) Helpers do not write audit events: API-layer write paths (the OPS-205 gate) own actor attribution in the same transaction. (5) Superseding is a transition, not a restatement — you can only clip an active arrangement that started before the new window (supersede_window_conflict otherwise).
Related: the layer cake for why these edges exist, Identifier policy for deriveSpineId, /persistence for withTenantTransaction and the spine producers, and /fund-accounting for the fee-mirror and carry postings this routing feeds.