Skip to content

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 = Internal and IsParent(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_balance is set, money SHOULD equal expected_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 Limits is set, for every (Rail, limit, direction) in Limits, for every child Account whose Parent = this account, when direction = Outbound OutboundFlow(child, rail, businessDay) SHOULD be ≤ limit; when direction = Inbound InboundFlow(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_age set, every Transaction on that rail SHOULD transition Pending → Posted before posting + 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_age set, every Posted leg on that rail SHOULD be picked up by an AggregatingRail (bundle_id set) before posting + 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's expected_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 < 2disagreement_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 < expecteddisagreement_kind='missing';
  • expected_parent_count IS NOT NULL AND parent_count > expecteddisagreement_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_in entries — 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_cadence is explicit_daily, every business day in scope SHOULD have an emitted daily_balances row. For Accounts on sparse, 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 declared explicit_daily cadence (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 the sparse default (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's cursor.execute can'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_NAMES and its refresh in the names list inside refresh_matviews_sql then write a _render_<name>_body helper 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