Standalone mode — your ETL hook is not configured. Reset and Deploy-changes will only remove rows tagged metadata.source='training'. Any unmarked rows are presumed to be real customer data and will be preserved. To configure an ETL hook, edit cfg.app2.etl_hook in your config.yaml.

Training

Pick the violation plants you want to study in this session, click Apply, then use each card's Clean dashboard / Violation dashboard links to see the before/after. Session Start populates a recon_demo_sasquatch_v overlay that all the Violation views read from; your production recon_demo_sasquatch prefix is untouched.

How this page works
  1. Session Start — populates a fresh recon_demo_sasquatch_v overlay cloned from your production data. Do this first when you arrive on this page; your production prefix is untouched.
  2. Check the boxes next to violation kinds you want to study (e.g., drift, overdraft). Per-card primitives like "Days ago" tune what gets planted.
  3. Apply selection — plants the chosen kinds into the v overlay. Checked but not-yet-applied boxes survive Session Start (your pending picks are restored).
  4. Clean dashboard / Violation dashboard links on each card open the same dashboard side-by-side, one reading from base (no planted rows) and one from recon_demo_sasquatch_v (with the planted violation visible).
  5. Cleanup when you're done — drops the v overlay entirely. Reversible: click Session Start again to re-clone.
0/26 plants enabled
L1 Conservation(0/3 enabled)
drift

For every internal leaf Account, on EVERY business day — emitted or carried — Drift(Account, BusinessDay) SHOULD equal 0.

What to do about it
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.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
ledger_drift

For every CurrentStoredBalance where Account.Scope = Internal and IsParent(Account), LedgerDrift(Account, BusinessDay) SHOULD equal 0.

What to do about it
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.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
overdraft

For every internal Account, on every business day — emitted or carried — the effective balance SHOULD be ≥ 0.

What to do about it
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.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
L1 Cap(0/4 enabled)
— Outbound direction limit_breach_outbound

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.

What to do about it
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).
Clean dashboard → (Violation dashboard available after Session Start + Apply)
— Inbound direction limit_breach_inbound

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.

What to do about it
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).
Clean dashboard → (Violation dashboard available after Session Start + Apply)
balance_cadence_gap

For every Account whose balance_cadence is explicit_daily, every business day in scope SHOULD have an emitted daily_balances row.

What to do about it
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.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
expected_eod_balance_breach

For every CurrentStoredBalance where expected_eod_balance is set, money SHOULD equal expected_eod_balance.

What to do about it
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.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
L1 Aging(0/2 enabled)
stuck_pending

For every Rail with max_pending_age set, every Transaction on that rail SHOULD transition Pending → Posted before posting + max_pending_age.

What to do about it
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.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
stuck_unbundled

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.

What to do about it
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.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
L1 Chain coherence(0/7 enabled)
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).

What to do about it
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.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
— Missed-firing variant xor_group_missed

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).

What to do about it
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).
Clean dashboard → (Violation dashboard available after Session Start + Apply)
— Overlap-firing variant xor_group_overlap

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).

What to do about it
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).
Clean dashboard → (Violation dashboard available after Session Start + Apply)
— Missing-parent variant fan_in_missing_parent

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).

What to do about it
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.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
— Extra-parent variant fan_in_extra_parent

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).

What to do about it
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.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
— Missed-firing variant multi_xor_missed

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.

What to do about it
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.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
— Overlap-firing variant multi_xor_overlap

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.

What to do about it
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.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
L1 Audit(0/1 enabled)
supersession_audit

_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).

What to do about it
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.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
L2 Triage gaps(0/3 enabled)
phantom_rail

Each row is a posted transaction whose rail_name doesn't resolve to any declared Rail.name in the L2 yaml.

What to do about it
Either add the rail to the L2 yaml (rails: block) with the right source_role / destination_role — the card's CTA deep-links to the L2 editor's create-new form pre-filled with the offending name — OR fix the ETL hook to translate the legacy rail_name into the L2-declared canonical name. A row here means the L2 doesn't even know about a money path the bank is running; the L1 Limit Breach + Drift checks can't fire against undeclared rails, so this is a silent-blind-spot indicator.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
phantom_template

Each row is a posted transaction whose template_name doesn't resolve to any declared TransferTemplate.name.

What to do about it
Either declare the template in the L2 yaml (transfer_templates: block) with its rail + transfer_key fields — the card's CTA deep-links to the create-new form — OR fix the ETL hook to stop tagging postings with the unrecognized template name. Postings without a known template can't participate in L1 Conservation bucketing — they show up under "(no template)" which is a per-row tell that the integrator's mapping has drifted.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
missing_metadata_key

Each row is a (template_name, metadata_key) pair where the template declares the key as required (via its transfer_key fields) but at least one posting tagged with that template landed without the key in its metadata JSON.

(No operator-tunable parameters — the L2 declaration determines the planted scenario.)

What to do about it
Either fix the ETL hook to emit the missing key on every posting tagged with that template — the card's CTA deep-links to the template editor showing which keys are required — OR drop the key from the template's transfer_key list in the L2 yaml if upstream genuinely doesn't carry it. A missing required key means L1 Conservation can't bucket the affected rows, which silently distorts the L1 Drift dashboard for the operator's customer segments downstream of that template.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
L2 Coverage gaps(0/2 enabled)
uncovered_rail

Each row is an L2-declared Rail.name with zero postings in <prefix>_transactions.

(No operator-tunable parameters — the L2 declaration determines the planted scenario.)

What to do about it
Either the declared rail is genuinely unused — retire it from the L2 yaml's rails: block — OR the ETL hook is misrouting postings against it (check rail_name casing + spelling against the L2 declaration). A long-uncovered rail is L2 noise: it shows up in dropdowns + handbook prose but never has data, so operators downstream lose trust in the L2 as a faithful map of the runtime.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
uncovered_template

Each row is an L2-declared TransferTemplate.name with zero postings tagged with that template_name.

(No operator-tunable parameters — the L2 declaration determines the planted scenario.)

What to do about it
Either the template is no longer in use — retire it from the L2 yaml's transfer_templates: block — OR the ETL hook stopped tagging postings with it (check the template name + the template's rail field against what the ETL emits). Uncovered templates particularly hurt L1 Conservation: a template with no data means template_metadata_coverage can't bucket anything, which leaves rows landing under "(no template)" with no analyst recourse.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
L2FT Hygiene(0/4 enabled)
chain_orphan

Each row is a declared Required chain edge (parent → child) where the parent rail fired in the window but no matched child firing followed within the SLA.

What to do about it
Either fix the ETL so the child rail fires when the parent does (the L2 says it should), or retire the chain edge from the L2 YAML if the parent-child causality no longer holds. Each row names the parent + child rail — drill to the L2FT Chains sheet for the firing-count history.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
dead_bundles_activity

Each row is an (aggregating_rail, bundle_target) pair the L2 declared via Rail.bundles_activity that the runtime never matched — no posting carries rail_name = bundle_target.

(No operator-tunable parameters — the L2 declaration determines the planted scenario.)

What to do about it
Either the aggregating rail's bundling actually includes the named target rail (check the ETL's bundle_id writes — each authorization that should roll into the aggregating settlement must carry bundle_id), or the L2's bundles_activity over-claims what the rail bundles. Drop the false claim from the L2.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
dead_metadata

Each row is a (rail, metadata_key) pair the L2 declared via Rail.metadata_keys that no posting carries a non-null value for in the window.

(No operator-tunable parameters — the L2 declaration determines the planted scenario.)

What to do about it
Either the ETL needs to start writing the key into transactions.metadata (the L2 says this rail should carry it), or the L2 declares a key that's not actually used — drop it from the rail's metadata_keys block. Live metadata is what the L2FT cascade dropdowns drive off; a dead declaration shows an empty dropdown to operators who expected it to narrow.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
dead_limit_schedule

Each row is a (parent_role, rail_name) LimitSchedule cell with zero outbound debit flow against it in the window.

(No operator-tunable parameters — the L2 declaration determines the planted scenario.)

What to do about it
Either the rail genuinely doesn't carry outbound debit flow from this role (retire the LimitSchedule entry), or the ETL routes the flow through a different (role, rail) cell than the L2 expects (verify the account_parent_role denormalization on every Debit row). A dead LimitSchedule means the L1 limit-breach check has nothing to catch — silent for that combination.
Clean dashboard → (Violation dashboard available after Session Start + Apply)
no changes pending Click Session Start first to populate the v overlay.