L2 Triage Gaps¶
The Studio /etl/triage surface diffs the integrator's L2 YAML
declaration against what the live <prefix>_transactions table
actually carries and surfaces typed Gap records as decision cards.
Each row is one piece of L2-to-runtime divergence: a posted rail that
doesn't resolve to any declared Rail, a template the L2 doesn't
know about, a (parent_role, rail) combo with no LimitSchedule
covering it or a template-required metadata key missing on rows
tagged with that template. None of these break the ledger; they
break the L2-to-runtime correspondence the integrator's ETL hook is
supposed to maintain.
Healthy = empty triage. A non-empty card tells the integrator either to fix the ETL (so postings match the L2 declaration) or to update the L2 (so the declaration matches what the ETL actually emits).
How the data flows¶
<prefix>_transactions (live, append-only)
↓
common.l2.triage.detect_gaps (four checks, run at request time)
↓
Gap records (typed; rendered as accordion cards)
↓
/etl/triage (operator-facing decision surface)
Refresh contract: detect_gaps queries <prefix>_transactions
directly, NOT a matview — so the triage page reflects whatever has
been ETL'd up to the moment of request, no refresh_matviews_sql
required. Studio's /etl/run button (which DOES refresh matviews
after seed/replant) is the L1 dashboard's refresh path; triage is
independent of that step.
The four triage gap kinds¶
1. Unmatched rail_name¶
Each row is a posted transaction whose rail_name doesn't resolve
to any declared Rail.name in the L2 yaml. The check groups
<prefix>_transactions by rail_name, then filters to values not
in the L2's declared-rails set. The card shows the offending rail
name + posting count + a sample transaction_id so the operator
can grep their source ETL log.
Columns: rail_name, posting_count, sample_transaction_id,
declared_rails.
What to do: 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.
2. Unmatched template_name¶
Each row is a posted transaction whose template_name doesn't
resolve to any declared TransferTemplate.name. Same shape as
unmatched_rail but on the template axis. The card shows the
offending template name + posting count + sample transaction_id +
the declared-templates list for context.
Columns: template_name, posting_count,
sample_transaction_id, declared_templates.
What to do: 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.
3. Missing LimitSchedule¶
Each row is a (parent_role, rail_name) tuple that fired in the
live transactions but no LimitSchedule covers it in either
direction (debit / credit). The L1 Limit Breach matview renders
these as "no cap" — the operator has to decide whether that's
intentional (some rails really are uncapped) or whether a schedule
was supposed to exist.
Columns: parent_role, rail_name, firing_count,
existing_schedules_for_parent_role.
What to do: Either add a LimitSchedule row in the L2 yaml
covering the (parent_role, rail) tuple — the card's CTA
deep-links to the Limits editor — OR confirm the "no cap" posture
is intentional and document it (e.g. an internal-only sweep rail
the bank really does run uncapped). The card shows the existing
schedules for the same parent_role as context so the operator can
spot when "we have caps for the credit direction but never added
the debit side."
4. Missing required 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. The card shows the count of
missing-key postings + the total postings for that template + a
sample transaction_id.
Columns: template_name, metadata_key, missing_count,
template_total_rows, sample_transaction_id.
What to do: 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.
L2 Coverage gaps¶
The triage gaps above (the four GapKind literals) fire when the
runtime has data the L2 doesn't account for. The two coverage
sections below are the inverse — the L2 declares a rail or template
that the runtime never exercises. These surface on the /etl/run
Coverage panel (after a refresh) rather than /etl/triage, but
they share the same typed catalogue because they're the same
operator surface family: "L2-to-runtime alignment is off, here's
which side."
5. Uncovered rail¶
Each row is an L2-declared Rail.name with zero postings in
<prefix>_transactions. The Coverage Rails panel renders the rail
as ✗ (declared but no rows). Pulls from the same data slice as
the L2FT Dead Rails hygiene check — they're two surfaces on the
same underlying check.
Columns: rail_name, leg_shape, posting_count.
What to do: 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.
6. Uncovered template¶
Each row is an L2-declared TransferTemplate.name with zero
postings tagged with that template_name. Coverage Templates
panel renders the template as ✗. Inverse of unmatched_template —
that one means rows exist for an undeclared template; this means
the template exists but no rows cite it.
Columns: template_name, rail, posting_count.
What to do: 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.