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:
- 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 readoperating_source_eventdirectly, the ledger would be permanently coupled to the bank pipeline. - 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.
- Independent correction identity. A correction inside the GL (
superseded,reversed) must not mutate the semantic record, and vice versa. - Dimensional normalization. Producing
scope_id/amount_cents/dimension_value_idsin 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.
Pair 2: proof_links vs ledger_item_evidence_links
Both express “X is supported by Y” and share the role/status vocabulary, but:
proof_links | ledger_item_evidence_links | |
|---|---|---|
| Domain | general provenance graph, cross-cutting | ledger accounting output |
| Target | anything (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 columns | role, match_status | amount_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_linkwith its ownmatch_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_executionsinbox; uploaded corroboration enters viasource_documentsand re-links throughproof_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.