L1 Invariants¶
The L1 SPEC declares a small set of SHOULD-constraints that any
healthy ledger feed must satisfy. The L1 library
(common.l2.emit_schema) materializes each constraint as a
prefixed PostgreSQL view; rows in any of these views ARE the
constraint violations. Healthy = empty.
This page is the authoritative reference for what each
spec_example_* view returns, the SHOULD-constraint motivation and
what the matview surfaces against the canonical
demo seed.
How the views are layered¶
base tables
├── spec_example_transactions
└── spec_example_daily_balances
↓
Current* matviews (max-Entry-per-logical-key projection)
├── spec_example_current_transactions
└── spec_example_current_daily_balances
↓
Helper matviews (derived inputs the invariants read)
├── spec_example_computed_subledger_balance
├── spec_example_computed_ledger_balance
├── spec_example_effective_balances (sparse-day carry-forward)
├── spec_example_data_anchor (latest-activity singleton)
└── spec_example_transfer_parents (per-child parent set)
↓
L1 invariant matviews (the 12 SHOULD-constraint surfaces)
├── spec_example_drift
├── spec_example_ledger_drift
├── spec_example_overdraft
├── spec_example_expected_eod_balance_breach
├── spec_example_limit_breach
├── spec_example_balance_cadence_gap
├── spec_example_stuck_pending
├── spec_example_stuck_unbundled
├── spec_example_chain_parent_disagreement
├── spec_example_xor_group_violation
├── spec_example_fan_in_disagreement (JOINs _transfer_parents)
└── spec_example_multi_xor_violation
↓
Dashboard-shape matviews (UI convenience)
├── spec_example_drift_summary (drift + ledger_drift roll-up)
├── spec_example_daily_statement_summary
└── spec_example_l1_exceptions (UNION over the 12 L1 surfaces)
22 matviews in the L1 pipeline. refresh_matviews_sql(instance)
rebuilds these plus the two Investigation matviews
(spec_example_inv_pair_rolling_anomalies +
spec_example_inv_money_trail_edges) — 24 in all. Refresh
contract: every batch insert into the base tables MUST be followed
by that call to recompute every dependent matview in dependency
order. The refresh is deterministic — leaves first, helpers second,
L1 invariants third, dashboard-shape last.
The twelve L1 SHOULD-constraints¶
1. spec_example_drift — Sub-ledger drift¶
For every internal leaf Account, on EVERY business day — emitted or carried —
Drift(Account, BusinessDay)SHOULD equal 0.
Each leaf-account day where the effective stored balance disagrees with the cumulative net of every Posted Money record posted to that account through the BusinessDay's end. A balance entry is the source of the account's balance UNTIL a newer entry supersedes it, so days between emits carry the last claim forward and are checked too — a posting that lands on a quiet day moves the computed side while the carried claim stands still, and that gap is drift the same as on an emit day. The carried day's cutoff keeps the last loaded balance day's END time: an account whose business day ends 17:00 keeps a 17:00 cutover on every quiet day until the next loaded balance row (a leg posting after the carried cutover belongs to the next day's check). The disagreement is the drift; a non-zero value signals the feed diverged from the underlying ledger. The computed side sums from account origin (no opening-balance term) — see Schema v6's zero-start precondition for what that demands of a mid-history cutover.
Columns: account_id, account_name, account_role,
account_parent_role, business_day_start, business_day_end,
stored_balance, computed_balance, drift.
What to do: Diff the day's transactions for account_id
against the stored balance — the gap is missing or duplicated
postings on that account-day. Re-load the source feed for the
account-day and refresh matviews.
2. spec_example_ledger_drift — Parent-account roll-up drift¶
For every CurrentStoredBalance where
Account.Scope = InternalandIsParent(Account),LedgerDrift(Account, BusinessDay)SHOULD equal 0.
Each parent-account day where the stored balance disagrees with the sum of its child accounts' stored balances PLUS the parent's own direct Posted postings (a parent GL can carry activity of its own; those legs count toward its expected position alongside the child roll-up). Surfaces a child posting that didn't roll up correctly to its parent — or a direct parent posting the stored balance never absorbed.
Columns: same as _drift minus account_parent_role
(parents ARE the parents).
What to do: Sum the child accounts of account_id on
business_day_start and compare to the parent's stored balance.
The gap is a child posting that didn't propagate to the parent —
usually a missing FK in the feed. Fix the parent link upstream,
re-load, refresh.
3. spec_example_overdraft — Non-negative balance¶
For every internal Account, on every business day — emitted or carried — the effective balance SHOULD be ≥ 0.
Each internal account day where the effective stored balance is negative — including carried days: a sparse account whose last emitted balance was negative stays overdrawn on every quiet day until an emit says otherwise. External counterparties are excluded by construction (the asymmetry is intentional: banks may legitimately overdraft us; we MUST NOT overdraft them).
Columns: account_id, account_name, account_role,
account_parent_role, business_day_start, business_day_end,
stored_balance.
What to do: Trace account_id's posting sequence on
business_day_end to find the over-debit — usually a missing
inbound credit or an over-issued debit. Reconcile against the
source system and post a correction.
4. spec_example_expected_eod_balance_breach — Expected EOD¶
For every CurrentStoredBalance where
expected_eod_balanceis set,moneySHOULD equalexpected_eod_balance.
Each account day where the stored EOD balance differs from the L2-declared expected EOD balance. Surfaces accounts that didn't clean up to their expected zero / target by end-of-day.
Columns: account_id, account_name, account_role,
business_day_start, business_day_end, stored_balance,
expected_eod_balance, variance.
What to do: Compare stored_balance against
expected_eod_balance for the gap size — typically a delayed
posting that should have landed before EOD. Verify the
source-system posting time and re-time the posting if needed.
5. spec_example_limit_breach — Per-direction flow cap¶
For every CurrentStoredBalance where
Limitsis set, for every(Rail, limit, direction)inLimits, for every child Account whoseParent = this account, whendirection = OutboundOutboundFlow(child, rail, businessDay)SHOULD be ≤limit; whendirection = InboundInboundFlow(child, rail, businessDay)SHOULD be ≤limit.
Per-(account, day, rail_name, direction) cells where cumulative
flow exceeded the cap. Caps come from the L2 instance's
LimitSchedules and are inlined into the view as CASE branches at
schema-emit time. The matview emits one row per breach per
direction — the direction column distinguishes Outbound caps
(amount_direction = 'Debit' transactions) from Inbound caps
(amount_direction = 'Credit', typical AML inbound-cap pattern).
Both directions can apply to the same (parent, rail) pair via
two LimitSchedules; the dashboard renders both on the Limit
Breach sheet, distinguished by the Direction column.
Columns: account_id, account_name, account_role,
account_parent_role, business_day, rail_name, direction,
flow_total, cap.
What to do: Either the LimitSchedule cap is too low (raise it after confirming the day's volume is legitimate) or an upstream control failed. For Outbound breaches, audit the feed for over-sent volume — downstream beneficiaries may be undercredited until reconciled. For Inbound breaches, flag the source for review per the AML / KYC policy that motivated the cap (structuring, unexpected deposits, counterparty source diligence).
6. spec_example_stuck_pending — Per-rail pending aging¶
For every Rail with
max_pending_ageset, every Transaction on that rail SHOULD transitionPending → Postedbeforeposting + max_pending_age.
Transactions stuck in status='Pending' past their rail's
configured cap. Caps come from the per-Rail max_pending_age
field and are inlined as CASE branches keyed on rail_name. Rails
without an aging watch contribute no branch and are excluded.
Columns: transaction_id, account_id, account_name,
account_role, account_parent_role, transfer_id,
rail_name, amount_money, amount_direction,
posting, max_pending_age_seconds, age_seconds.
What to do: Either re-poke the source-system integration to
transition the transaction, or raise the rail's max_pending_age
if the cap is too aggressive for normal volume. Escalate to ops
when age_seconds is in days rather than hours.
7. spec_example_stuck_unbundled — Per-rail unbundled aging¶
For every Rail with
max_unbundled_ageset, every Posted leg on that rail SHOULD be picked up by an AggregatingRail (bundle_idset) beforeposting + max_unbundled_age.
Posted transactions whose bundle_id IS NULL past their rail's
cap. Per validator R8, max_unbundled_age is only meaningful on
rails appearing in some AggregatingRail's bundles_activity.
Columns: same shape as _stuck_pending with
max_unbundled_age_seconds instead of max_pending_age_seconds.
What to do: Verify the AggregatingRail's bundles_activity
still names this rail — the bundler may be silently
mis-configured. For high age_seconds, run the bundle process
manually and investigate why the regular cycle missed it.
8. spec_example_chain_parent_disagreement — Two-template chain Parent disagreement¶
For every two-template chain (chain.children resolves to a TransferTemplate), every leg_rail firing of one child Transfer SHOULD agree on which parent firing it descends from (first-firing-wins per gap doc §3).
A child Transfer where leg_rail firings claim different
transfer_parent_id values surfaces here. The pattern usually means
an ETL bug: stale parent reference, cross-cycle contamination or a
race where two parent firings both wrote into the same downstream
template Transfer.
The matview groups by (transfer_id, template_name) over
<prefix>_current_transactions and gates on
COUNT(DISTINCT transfer_parent_id) > 1. Filters keep rail-as-child
chains out (template_name IS NOT NULL) and exclude Failed legs
(metadata is unreliable on failures).
Columns: transfer_id, child_template_name, business_day
(MIN of conflicting leg postings), distinct_parent_count (the
cardinality, ≥2 = violation), parent_transfer_id_min /
parent_transfer_id_max (sample conflicting values for analyst drill
without re-running the GROUP BY).
What to do: Identify which leg_rails of the child template wrote
the conflicting transfer_parent_id values — usually the bug is
upstream of the matview (in the ETL adapter that assigns
transfer_parent_id from a stale reference). Compare the two parents
by drilling into their respective transfer_ids on the Transactions
sheet; one should be the correct first-firing parent and the other a
contamination from an unrelated cycle. Fix the ETL's parent-resolution
logic and re-run the affected child Transfer.
9. spec_example_xor_group_violation — Multi-mode template variant XOR violation¶
For every TransferTemplate that declares
leg_rail_xor_groups, for every group in that template, exactly ONE member of the group SHOULD fire per Transfer (the variants compete; the runtime picks one per cycle).
A Transfer where a group has 0 firings (missed — the template fired but no variant did, the cycle didn't close) or ≥2 firings (overlap — the runtime double-posted competing variants) surfaces here. The pattern usually means an ETL bug: a misrouted variant trigger, a re-post that didn't suppress the original or a race where two variants both wrote the closure.
The matview's CTE inlines (template_name, xor_group_index,
member_rail_name) rows from the L2 yaml's leg_rail_xor_groups
declarations, LEFT JOINs them against
<prefix>_current_transactions per (Transfer-of-template,
group-of-template, member-rail) and gates on
COUNT(tx.transfer_id) <> 1. When no template declares
leg_rail_xor_groups, the matview short-circuits with
WHERE 1=0 (parses cleanly, zero rows).
Columns: transfer_id, template_name, xor_group_index
(0-based), firing_count (0 = missed; ≥2 = overlap),
fired_rails (dialect-specific concat of the rail_names that
DID fire; empty string when firing_count=0), business_day
(MIN of the firing legs' postings, or NULL for missed firings).
What to do: Identify which template firing this is, then look at the upstream variant trigger. For missed firings (count=0): the template fired but no variant trigger ran — usually a routing bug where the runtime resolved to no variant. For overlaps (count≥2): two variant triggers both fired — usually a re-post that didn't suppress the original, or a race condition between two variant selection paths. Drill from the row to the Transactions sheet to see every leg of the Transfer, including the non-variant legs that DID fire (so you know which template firing the violation belongs to).
10. spec_example_fan_in_disagreement — Fan-in chain parent-set mismatch¶
For every chain child entry declaring
fan_in: true(per-child shape), every child Transfer's contributing parent set SHOULD match the entry'sexpected_parent_count(when set), or have cardinality ≥2 (when unset).
A child Transfer where the contributing parent set deviates from the chain's expected count surfaces here. The pattern usually means an ETL bug: a daily contribution never landed (the batch is short of contributors), or a stale / foreign parent reference claimed batch membership it shouldn't have (cross-batch contamination).
The matview's CTE inlines the L2 chain config rows
(chain_parent_name, child_template_name, expected_parent_count),
joins them against the upstream <prefix>_transfer_parents
matview's per-(child, parent) DISTINCT projection and emits a
row when:
expected_parent_count IS NULL AND parent_count < 2→disagreement_kind='orphan'(variable-batch flow's fallback — the chain declared no upper bound so the matview can only flag the degenerate single-parent case);expected_parent_count IS NOT NULL AND parent_count < expected→disagreement_kind='missing';expected_parent_count IS NOT NULL AND parent_count > expected→disagreement_kind='extra'.
When no chain child declares fan_in: true, the matview
short-circuits with WHERE 1=0 (parses cleanly across all 3
dialects, zero rows).
Columns: child_transfer_id, chain_parent_name,
child_template_name, parent_count (actual contributing
parents), expected_parent_count (NULL when chain leaves it
unset), disagreement_kind ('orphan' / 'missing' / 'extra'),
business_day (earliest contributing leg's posting day).
What to do: Identify which batch this is via the business_day
+ child_template_name. For 'missing': the upstream parent rail
should have fired N times but one (or more) firings never landed —
look at the parent rail's firing log for the batch period and find
the missing date(s). For 'extra': a parent firing claimed membership
in this batch it shouldn't have — could be a stale transfer_parent_id
metadata value (ETL pulled from a wrong source) or a duplicate
re-post. For 'orphan': only one parent contributed — either the
chain shouldn't be fan_in (it's actually 1:1) OR the operator
should set expected_parent_count so the matview can flag the
missing siblings.
11. spec_example_multi_xor_violation — Chain XOR alternation violation¶
For every multi-children chain (≥2 children) — after stripping per-child
fan_inentries — every parent firing SHOULD have exactly ONE non-fan_in child fire under it. Zero fires (none of the declared XOR alternatives followed the parent) and overlap (≥2 fired) both surface here.
A parent firing whose declared XOR-sibling set was not honored
surfaces with disagreement_kind ∈ ('missed', 'overlap'):
missed(count = 0): the chain.md contract "exactly one MUST fire" was dropped on the floor — the parent fired but no child followed.overlap(count ≥ 2): XOR alternation collapsed — two or more declared children fired under one parent.
The matview's CTE inlines (chain_parent_name, child_name) rows
for every multi-children chain after filtering out per-child
fan_in=True entries (their cardinality is
_fan_in_disagreement's job). For each parent
firing of a multi-XOR chain, the CTE LEFT JOINs against
<prefix>_current_transactions keyed on transfer_parent_id,
matches the firing's child name against the declared siblings,
counts distinct matches and emits a row when the count ≠ 1.
When no chain qualifies (no multi-children chain after stripping
fan_in entries), the body falls back to a typed-NULL placeholder
with WHERE 1=0 so the matview parses + plans cleanly on all 3
dialects and contributes zero rows.
Columns: parent_transfer_id, parent_rail_or_template_name,
child_count (actual count of declared XOR siblings that fired),
fired_children (comma-separated names, empty when count = 0),
disagreement_kind ('missed' / 'overlap'), business_day
(earliest contributing leg's posting day).
What to do: For 'missed': inspect the parent firing's
metadata for clues about which alternative the operator intended.
Either a child contribution failed to post (ETL bug on the child
rail) or the L2 declaration over-specifies the children list (the
intended alternative was never on the operator's menu). For
'overlap': two children fired for one parent — either the XOR
contract is wrong (the children aren't truly alternates and the
L2 should split into separate chain rows) or the seed-emit / ETL
double-fired on a single firing. The drill from L1 Exceptions
on a row navigates to Transactions filtered to the parent's
transfer_id — eyeball the child legs to see which alternatives
landed.
the matview should surface: 2 planted violations on
BulkAccrualSettlement (the demo chain with XOR
alternation across BulkAccrualSettleACH / BulkAccrualSettleWire):
missed (count=0, days_ago=6) + overlap (count=2, days_ago=5).
12. spec_example_balance_cadence_gap — Cadence-aware missing balance¶
For every Account whose
balance_cadenceisexplicit_daily, every business day in scope SHOULD have an emitteddaily_balancesrow. For Accounts onsparse, every business day with transaction postings SHOULD have an emitted balance row to reconcile against.
The matview surfaces missing-balance days under two firing modes
distinguished by the gap_kind column:
declared_daily_missing— the account declaredexplicit_dailycadence (every business day must report a balance row) and this business day has none. Fires regardless of activity.sparse_with_activity— the account is on thesparsedefault (no-emit on quiet days is fine), but postings happened today and no confirming balance row was emitted. The institution has activity it can't reconcile against a known close.
Columns: account_id, account_name, account_role,
account_parent_role, business_day_start, balance_cadence,
gap_day_net_flow, gap_day_leg_count, gap_kind.
What to do: For declared_daily_missing, check why the feed
skipped the day — typically a stalled batch or a missed cutover.
For sparse_with_activity, ask the source system to emit a
balance row for the activity day so the close-of-day reconcile can
land.
Diagnostic surface — Supersession Audit¶
spec_example_supersession_* is not a SHOULD-constraint — it's a
diagnostic view that surfaces logical keys with multiple entry
versions (the audit trail for TechnicalCorrection /
BundleAssignment / Inflight rewrites). Reads from BASE tables
(not Current) since Current hides superseded entries by
construction. See the dashboard for the visualization.
What to do: Diagnostic only — supersession is expected for
normal corrections. Investigate when entry_count is unusually
high for the row's age, or when the rewrite reason is missing.
Diff the entries to see what actually changed across versions.
Refresh + extend contracts¶
- Refresh:
refresh_matviews_sql(instance)returns a single SQL string in dependency order: base-table statistics first, then a REFRESH + statistics pair per matview — every object's stats land right after its own refresh, so no downstream matview ever plans against an unanalyzed upstream. Caller splits on;and executes per-statement (psycopg2'scursor.executecan't run multiple statements separated by;reliably). - Per-instance hot-path indexes: every L1 invariant matview
ships indexes on the dashboard's filter dropdowns
(
account_id,rail_name,business_day, etc) so the deployed dashboard's per-visual SELECTs hit indexed lookups. - Adding a new SHOULD-constraint: declare the underlying L1
primitive in the SPEC, add the matview to
common.l2.schema._L1_INVARIANT_VIEWS_TEMPLATE, register its drop in_L1_INVARIANT_DROP_NAMESand its refresh in thenameslist insiderefresh_matviews_sqlthen write a_render_<name>_bodyhelper if it needs L2 data inlined at schema-emit time (mirror of_render_xor_group_violation_body). Then surface it via a new dataset + sheet on the L1 dashboard.
See also¶
- Schema v6 — Data Feed Contract — the column contract for the two base tables.
- L1 Reconciliation Dashboard — the analyst's view of these invariants.
- Customization Handbook — L2-fed pattern — how to point the dashboard at your own L2 instance.