How to wire point-in-time reference data (M4)¶
When you need this: A reference table (e.g.
customer) carries a history of attribute changes (risk ratings, KYC tier, segment). You want anaggregation_windowrule to evaluate each transaction against the reference value in force at that transaction's booking date — not the latest row. Without this, a customer whose risk rating went low → high mid-window is scored on the latest value for all their transactions, which overstates the historical alert count.Prereqs: A reference contract with
valid_from/valid_totimestamp columns.aml validatesucceeds on the base spec.Time: ~15 min to declare and verify. Closes the Pillar-3 SCD-2 gap.
The M4 point-in-time mechanism works in two steps: (1) declare the reference DataContract as effective_dated; (2) add an enrich block to the aggregation_window rule. The SQL generator joins the reference contract against the pre-filtered source subquery (aliased src) on the key AND the validity window:
-- auto-generated by the SQL generator (joined against the
-- window/filter-narrowed source subquery, aliased `src`):
JOIN customer
ON src.customer_id = customer.customer_id
AND customer.valid_from <= src.booked_at
AND (customer.valid_to IS NULL OR src.booked_at < customer.valid_to)
AND (customer.risk_rating = 'high') -- each enrich.where predicate, parenthesized
The reference is joined by its own contract name (customer), not aliased ref; the source side is the already-narrowed subquery alias src. So a transaction booked at 2026-03-01 joins the customer row whose [valid_from, valid_to) interval covers 2026-03-01 — not the latest row.
Steps¶
1 · Declare the reference contract as effective_dated¶
data_contracts:
- id: customer
source: raw.customers_history # SCD-2 table with valid_from / valid_to
effective_dated:
valid_from: valid_from # column name in the source table
valid_to: valid_to # nullable — NULL means "current"
columns:
- { name: customer_id, type: string, nullable: false }
- { name: risk_rating, type: string }
- { name: valid_from, type: timestamp, nullable: false }
- { name: valid_to, type: timestamp, nullable: true }
valid_to: null on a row means "this row is current and has no end date." The engine treats NULL valid_to as open-ended — booked_at < valid_to becomes valid_to IS NULL OR booked_at < valid_to.
2 · Add the enrich block to the rule¶
rules:
- id: high_risk_burst
name: High-risk customer burst
severity: high
regulation_refs:
- citation: FinCEN SAR Advisory 2020-A001
description: SAR-filing obligations for high-risk customers
logic:
type: aggregation_window
source: txn
group_by: [customer_id]
window: 30d
having:
count: { gte: 2 }
enrich:
contract: customer # must be declared effective_dated above
key: customer_id # join key — use `key`, NOT `on` (YAML 1.1 coerces `on:` to boolean true)
where:
- "customer.risk_rating = 'high'" # raw SQL predicates on the joined contract columns
escalate_to: aml_queue
evidence:
- alert_amount_breakdown
Important: use enrich.key, not enrich.on. YAML 1.1 coerces a bare on: key to boolean true, which the spec parser rejects.
3 · Validate¶
Expected: ✓ Spec is valid. If the enrich points at a contract that lacks an effective_dated block, validation fails with rule 'high_risk_burst' enriches 'customer' which is not effective_dated (declare valid_from/valid_to on it) — add the effective_dated block to the data contract.
4 · Run against effective-dated reference rows and verify the count¶
The as-of join only does anything when the reference contract actually carries valid_from / valid_to history. The built-in synthetic generator does not emit effective-dated customer rows (each synthetic customer has a single risk_rating, no validity window), so to prove point-in-time behaviour you supply an SCD-2 reference table yourself.
The canonical worked example is tests/test_point_in_time.py::test_point_in_time_join_resolves_contemporaneous_row: it builds a small in-memory customer table with two rows for C0001 (risk_rating: low valid until 2026-06-05, then risk_rating: high open-ended) plus four transactions — two booked while low, two while high. The rule fires only on the 2 high-period transactions, not all four:
That count == 2 (instead of 4) is the proof the as-of join resolved the contemporaneous row rather than the latest one. To reproduce outside the test, point your spec's customer contract at an SCD-2 source (CSV / Parquet / DuckDB) whose rows carry valid_from / valid_to, e.g.:
customer_id,risk_rating,valid_from,valid_to
C0001,low,2026-01-01 00:00:00,2026-06-05 00:00:00
C0001,high,2026-06-05 00:00:00,
then aml run my_aml.yaml --data-source csv --data-dir <dir> and confirm the alert's count reflects only the in-window high-risk transactions.
5 · Check the North Star Coverage page¶
Navigate to North Star Coverage (page 43) in the dashboard. Pillar 3 (Point-in-time correctness) reads COVERED and its evidence panel describes the M4 SCD-2 effective_dated + enrich mechanism you just used. (This is a static framework-capability assessment — the status is not recomputed per spec, so it does not change based on whether your spec declares an enrich block.)
Verify it worked¶
Two checks, in order of strength.
(a) Inspect the generated SQL — this is the most direct proof the as-of join was wired:
aml run my_aml.yaml --data-source csv --data-dir <dir>
grep -A3 "JOIN customer" .artifacts/run-*/rules/high_risk_burst.sql
You should see the join against the source alias src on customer.valid_from <= src.booked_at and the valid_to window — that clause is emitted only when the enrich block resolved.
(b) Check the alert count — the as-of join changes which rows aggregate, so the alert's count is the as-of count, not the latest-row count:
With the SCD-2 fixture above, count is 2 (high-period transactions only); a latest-row join would report 4. The difference is the behaviour you're verifying.
Note: do not use the alert's
matched_row_idsas proof of the as-of join. Those row ids are looked up from the source transaction table alone (engine/runner.pyre-queriesSELECT rowid FROM <source> WHERE customer_id = ? AND booked_at BETWEEN window_start AND window_end) — they reflect the window, not the reference join, so they can be non-empty even if the enrichment did nothing. Likewise, the Lineage Explorer (page 32) renders a genericsource → contract → table → rule → alert → caseMermaid graph; it does not show per-reference as-of intervals.
Common problems¶
| Symptom | Cause | Fix |
|---|---|---|
rule '<id>' enriches '<contract>' which is not effective_dated (declare valid_from/valid_to on it) |
Missing effective_dated block on the contract |
Add effective_dated: { valid_from: <col>, valid_to: <col> } to the data_contract |
rule '<id>' enrich key '<col>' is not a column of '<contract>' (or ... of source '<src>') |
enrich.key names a column absent from the ref or source contract |
Use a join key declared on both the source and reference contracts |
KeyError: 'on' or enrich.on: true in YAML |
Used on: instead of key: |
Change to enrich.key: <column_name> — YAML 1.1 coerces bare on: to boolean true |
| Alert count inflated (same as without enrich) | where predicate not filtering correctly |
Check the predicate uses the contract name: "customer.risk_rating = 'high'", not "risk_rating = 'high'" |
| Transactions silently dropped from the count | SCD-2 table has gaps — some booking dates have no covering reference row | The as-of join is an INNER join: a transaction with no [valid_from, valid_to) row matching its booked_at is excluded (no error is raised). Ensure the SCD-2 table has gap-free coverage, with valid_to: null on the current row |
Next steps¶
- The as-of join also works with non-customer reference tables (e.g. product codes, country lists). Any
effective_datedcontract can be enriched into anyaggregation_windowrule —enrichis defined only onaggregation_windowlogic, not oncustom_sql,python_ref,list_match, ornetwork_patternrules. - See add-a-rule.md for the full rule authoring flow.
- See walk-lineage.md to trace an alert back to its source rows (note: lineage shows the source→rule→alert→case chain, not the reference as-of interval).