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 (M.1.5 — max-Entry-per-logical-key projection)
├── spec_example_current_transactions
└── spec_example_current_daily_balances
↓
Helper matviews (computed-balance derivation)
├── spec_example_computed_subledger_balance
└── spec_example_computed_ledger_balance
↓
L1 invariant matviews (the 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_stuck_pending (M.2b.8)
└── spec_example_stuck_unbundled (M.2b.9)
↓
Dashboard-shape matviews (UI convenience)
├── spec_example_daily_statement_summary
└── spec_example_todays_exceptions (UNION over the 5 baseline L1s)
13 matviews total. Refresh contract: every batch insert into the
base tables MUST be followed by refresh_matviews_sql(instance)
to recompute every dependent matview in dependency order. The
refresh is deterministic — leaves first, helpers second, L1
invariants third, dashboard-shape last.
The seven L1 SHOULD-constraints¶
1. spec_example_drift — Sub-ledger drift¶
For every CurrentStoredBalance where
Account.Scope = Internaland¬IsParent(Account),Drift(Account, BusinessDay)SHOULD equal 0.
Each leaf-account day where the stored balance disagrees with the cumulative net of every Posted Money record posted to that account through the BusinessDay's end. The disagreement is the drift; a non-zero value signals the feed diverged from the underlying ledger.
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. Surfaces a child posting that didn't roll up correctly to its parent.
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 CurrentStoredBalance,
moneySHOULD be ≥ 0.
Each internal account day where the stored balance is negative. 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. (Z.B
2026-05-15: keyed on rail_name — previously transfer_type.
AB.1 2026-05-19: added 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 (M.2b.8)¶
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 (M.2b.9)¶
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 (AB.2.3)¶
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
parent_transfer_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 parent_transfer_id values — usually the bug is
upstream of the matview (in the ETL adapter that assigns
parent_transfer_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 (AB.3.3)¶
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 (AB.4.7)¶
For every chain child entry declaring
fan_in: true(AB.6 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 parent_transfer_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 (AB.6.5)¶
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 per AB.5 coupling). 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 Today's 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 AB.6.5.spec demo chain with XOR
alternation across BulkAccrualSettleACH / BulkAccrualSettleWire):
missed (count=0, days_ago=6) + overlap (count=2, days_ago=5).
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 M.2b.12 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 with 26 statements (13 REFRESH + 13 ANALYZE) in dependency order. 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 it in_L1_INVARIANT_VIEWS_DROPS_TEMPLATE+refresh_matviews_sql, and write a_render_<name>_caseshelper if it needs L2 data inlined at schema-emit time (mirror of_render_pending_age_cases). 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.