Skip to Content
Source PipelineDomain Seams

Domain Seams: Pairs That Look Like Duplicates

Two pairs of records in the source→ledger pipeline look redundant to a reader who meets only one side, and the “obvious simplification” — collapse them — would destroy a deliberate domain boundary. This page explains why each pair earns separate existence; ADR-0003 (docs/adrs/0003-source-to-ledger-pipeline-boundaries.md) exists precisely so this is never re-litigated as accidental redundancy. This page is the working summary; the ADR is authoritative. For the runtime that feeds these records, see Plaid sync.

Pair 1: operating_source_event vs ledger_source_event

Step ii answers “what is this, and is it approved?” — the human/semantic gate, with lifecycle_state: candidate | accepted | rejected | superseded, a fund-domain subject_ref, and provider refs in metadata. Step iii answers “given that, what posting instruction does the GL receive?” — pure accounting columns only: scope_id, event_type, event_status, amount_cents, currency, effective_date, dimension_value_ids, its own supersedes_source_event_id and idempotency_key.

ADR-0003 gives four reasons the ledger record earns separate existence:

  1. Multi-producer hub. Bank classification is one of several producers — fund-economics runtime, management-fee accrual, markup events, and manual journal entries all emit ledger_source_events into the same posting compilers. If a compiler read operating_source_event directly, the ledger would be permanently coupled to the bank pipeline.
  2. ii→iii is not 1:1. One accepted “$5M investment wire” may fan out into several posting instructions (investment + fee + FX), or several operating events may net into one.
  3. Independent correction identity. A correction inside the GL (superseded, reversed) must not mutate the semantic record, and vice versa.
  4. Dimensional normalization. Producing scope_id / amount_cents / dimension_value_ids in ledger-normalized form is step iii.

The one-way flow is enforced in code, not convention:

// packages/persistence/src/source-bank-event-promotion.ts // Only an ACCEPTED operating_source_event may become a ledger_source_event. // A bank row can never create one directly. canPromoteToLedgerSourceEvent(ose) assertCanPromoteToLedgerSourceEvent(ose)

@opsdna/ledger must not import bank/Plaid/classifier/OSE types; the promotion module is the only file allowed to know both sides. Note also the subject_ref rule: it is semantic payload preserved in the envelope, never ledger identity — the ledger resolves scope through typed columns (accounting_entity_id) and the promotion step, and must not join on subject_ref.

Two unrelated things called “SourceEvent”

Do not confuse either table with packages/source-events/src/source-event.ts — that is the content-addressed envelope (source-event:v1:<sha256> ids, review_state, registry-validated version-pinned payloads via decodeSourceEventAgainstRegistry). Separately, packages/ledger/src/source-event-types.ts is the ledger’s sealed posting vocabulary (SOURCE_EVENT_TYPES: capital_call_confirmed, fair_value_adjustment, carry_accrual, …), where adding a type requires bumping posting_rules_version. Same phrase, three layers.

Both express “X is supported by Y” and share the role/status vocabulary, but:

proof_linksledger_item_evidence_links
Domaingeneral provenance graph, cross-cuttingledger accounting output
Targetanything (target_kind + target_id)always a journal_line_id
Question”what evidence relates to this thing?""what backs this dollar amount on this GL line, and how sure are we?”
Extra columnsrole, match_statusamount_supported (MoneyValue), confidence

The decisive detail: ledger_item_evidence_links.proof_link_id is nullable and points at a proof_link. The relationship is composition — the ledger row wraps a general provenance edge and overlays accounting-specific fields, the SQL equivalent of inheritance by reference. Putting amount_supported/confidence on proof_links would pollute a domain-agnostic primitive with ledger semantics — the same “each domain owns its narrowed projection” discipline as ADRs 0001/0002.

Convention on the bank path: create both — the general proof_link (e.g. raw transaction version → journal line, role originating) and a ledger_item_evidence_link referencing it via proof_link_id with amount_supported/confidence.

The follow-on seam (ADR-0004)

Between classification and posting sits FollowOnDispatcher (packages/persistence/src/source-bank-event-promotion.ts), triggered at candidate creation (enrichment/matching) and OSE acceptance (act-on-intent). Its decisions mirror the composition discipline above:

  • D1 — classification ≠ match. “This is an investment” is the OSE lifecycle; “…matching XYZ Corp’s $5M commitment” is a separate proof_link with its own match_status (matched | partial_match | unmatched | conflicting | waived). “Right class, wrong match” is representable without un-accepting anything.
  • D2 — match-before-approval for UX (suggest the match at the approval gate) but the records stay decoupled.
  • D4 — reuse, don’t fork: paperwork prompts go through the existing suggestions + action_executions inbox; uploaded corroboration enters via source_documents and re-links through proof_links.
  • Wave 8 (OPS-585) implemented the accepted-OSE match follow-on as a durable, leased work record (pcap.capital_call_funding_proposal_dispatch) created in the same transaction as the candidate→accepted transition — workflow state only; it never creates receipts or GL settlement links.

Do not merge either pair. ADR-0003’s consequences section is explicit: a “convenience” refactor collapsing operating_source_event/ledger_source_event or proof_links/ledger_item_evidence_links must amend the ADR first. Likewise ADR-0004 pre-rejects “just put match_status on the OSE”. If a review shows a PR quietly joining the ledger to subject_ref or importing OSE types into @opsdna/ledger, flag it.

Related: how postings, fair-value marks, and the GL work on the far side of the seam → /fund-accounting.

Last updated on