Skip to content

Library · Data Practice OS

Tracing rules from legacy code to modern code

Reference guide · Published · Source reviewed

The answer: for any line of modern code, Migration traceability answers two questions: why does this exist, and what proves it is right? Keep that chain unbroken and queryable by making the business rule the hub. Legacy modules, modern models, tests and decisions each link to rule ids and never to each other. The legacy-to-modern mapping is many-to-many, so a direct map goes stale or lies.

Source and status. Adapted from a draft design, Modernization traceability graph (Nov 2026). This page is proposed practice, not an adopted standard. The draft's reference prototype ("tracegraph": stdlib Python, SQLite, decorators read with ast) is not published, so it is described here only as a prototype, with no commands or install steps. The dbt mapping, the split timelines and the status-aware waiver are this practice's adaptations, and each is labelled Practice adaptation where it appears. The dbt path is itself proposed (ADR-003: dbt as the transformation standard).

Where this fits

This page extends step 5, Trace, of Extracting business rules. It uses the rule cards from that page and the rule of record from Where business rules run after migration. It complements data Reconciliation in Reconciliation, parallel run and cutover and does not replace it. The legacy topology (which program calls, reads and writes what) comes from the dependency inventory in Migration assessment.

Rule as hub

Why legacy and modern code never link directly:

  • One COBOL or CA Gen program carries many rules. One dbt model implements parts of many programs. A row that says "program X became model Y" is wrong in both directions as soon as either side splits or merges.
  • A rule-to-code link survives a rewrite. When a model is refactored, only its own implements edges change. The legacy side and the tests are untouched.
  • Gaps become visible. Every rule should have at least one legacy source and at least one modern implementation. If it doesn't, a query finds it.

So the object maps in Code conversion and Reconciliation, parallel run and cutover keep only data objects (tables, files). Logic routes through rules.

legacy_module ──implements──▶ rule ◀──implements── modern_module
                               │
          pinned_by ───────────┼─────────── verified_by
     (pinning test, legacy)    │      (equivalence test, modern)
                               │
                          decided_by ──▶ decision (accepted only)

Vocabulary

Six node kinds and nine edge kinds are enough. Keep the vocabulary stable. The storage underneath can change.

Node kind Source draft's id form (prototype) Practice adaptation for dbt and SQL
legacy_module legacy:src/BILL100.cbl (per file) Per program, procedure, SSIS package or CA Gen action block, as cited by the rule card
datastore ds:INVMAST Three-part catalog.schema.table on either platform; legacy stores by their native name
rule RULE-001 The domain-coded id from Extracting business rules, for example REG-FEE-014
modern_module modern:path.py::function (per function) The dbt manifest unique_id, model.<project>.<name>, with an optional .<column>
test test:path.py::test_x A dbt unit test or data test unique_id; Python tests keep the draft's form
decision ADR-007 A system-prefixed divergence record, for example REG-DEC-007 (see Deliberate divergence)
Edge kind From, to Meaning Usual source
implements legacy or modern, rule This code realises the rule Rule-card citations (legacy); dbt meta (modern)
pinned_by rule, test A Characterisation (pinning) test of legacy behaviour, quirks included Test meta or the pinning harness
verified_by rule, test An Equivalence test: old and new agree on these inputs Test meta
decided_by rule or modern, decision A decision explains a change or deliberate divergence Rule card or model meta
supersedes rule, rule (or modern, legacy) One rule or module replaces another Rule catalogue history
calls legacy, legacy Program-to-program call Dependency map
dispatches legacy, legacy Dynamic or table-driven invocation (job schedules, CICS transactions) Dependency map, scheduler exports
reads module, datastore Code reads the store Dependency map; manifest parent_map; platform lineage
writes module, datastore Code writes the store Dependency map; manifest child_map; platform lineage

The source draft limits calls, dispatches, reads and writes to legacy topology (legacy to legacy or datastore, from the dependency map). The examples given for dispatches (job schedules, CICS transactions) are this page's reading of the draft. Practice adaptation: reads and writes also cover modern modules, taken from the dbt manifest and platform lineage.

Granularity. The source draft uses legacy per file and modern per function, because the function is the unit a developer edits. Practice adaptation: for dbt, trace per model by default and per column only for P0 calculations. Hand-written SQL outside dbt (stored procedures, notebooks, scheduled scripts) is flagged as untraced until it carries a header tag naming its rule ids.

Rule ids. Keep the existing domain-coded scheme from Extracting business rules (REG-FEE-014) as the only scheme, in place of the draft's RULE-NNN, which is the prototype's key and maps 1:1.

Priority. The source draft uses a priority field with P0, but does not define the levels. The P0/P1/P2 definitions are this practice's proposal (agree them with the rule catalogue owner): P0 is fees, eligibility, sanctions and decisions about people, P1 is other calculations and policy, and P2 is validation and plumbing.

Practice adaptation: only approved cards count. Only approved rule cards count as fully traced (Rule authority). A proposed card can be linked, but the "Rule not approved" query below lists it until it is approved.

Every claim carries evidence

Each edge is a claim, and every claim records:

  • evidence: file:lines, a commit SHA, a pull request, or a query or job run id
  • a recorded date: when the claim was asserted
  • an optional end date: when the claim was retracted or superseded

Superseded claims are kept, not deleted. That keeps "what did we believe at cutover?" answerable.

Three timelines. Do not conflate them.

Timeline Fields Means Where
Business validity effective_from / effective_to on the rule When the policy applies Effective dating, Where business rules run after migration
Row validity valid_from / valid_to on SCD2 rows When a data row was current Medallion layers and modelling
Record time recorded date and end date on the claim When we asserted or retracted the link This page

Practice adaptation, after Fowler's bitemporal history (2021): the draft's draft names the claim's end date valid_to, but setting it is a record-time event. Call the fields recorded_from / recorded_to. Add a separate valid time only if "what ran in production at cutover" has to be answered separately from "what did we believe at cutover".

Pinning and equivalence tests

  • A pinning test is a Characterisation (pinning) test. It runs on the legacy side and records actual behaviour, quirks included. Feathers: "document your system's actual behavior, not check for the behavior you wish your system had" (Characterization testing, 2016).
  • An Equivalence test runs on the modern side. It checks that old and new agree on the same inputs, using the pinned fixtures.

dbt mapping (practice adaptation). The equivalence test is a dbt unit test whose given rows come from the pinning fixture and whose expect rows come from the legacy output. Tag it with meta.verifies and test_kind: equivalence.

  • SQL sources (EDW procedures, views). Pinning can be the Given/When/Then case run against the legacy procedure or view, with the result saved as the fixture.
  • CA Gen or COBOL. Pinning needs a legacy runner (a test region or batch harness) that can replay the inputs and capture outputs. Budget for it early. It is often the long pole.

Equivalence is not reconciliation. Reconciliation proves outputs on live data. Equivalence proves one rule on chosen inputs, including edge cases live data may not contain this month. You need both. In the migration handbook and templates, "equivalence test" means only the per-rule test (Equivalence test); elsewhere "equivalence" may mean reconciliation in general.

Cost. dbt unit tests are GA from v1.8, but by default each one still sends a query to the warehouse, so they use compute in CI. Local unit-test execution is experimental (DBT_ENGINE_EXPERIMENTAL_LOCAL_UNIT_TESTS, dbt v2.0; verify, Unit tests, updated 14 Sep 2026). dbt Labs recommends running unit tests in dev and CI only, not in production builds.

Deliberate divergence

When the modern rule is meant to differ, an accepted decision record waives the equivalence requirement. The pinning test is kept, so the old behaviour stays documented. Never leave an equivalence test failing silently.

Practice adaptation: the waiver is status-aware.

  • Only accepted waives. proposed, under review, rejected or deferred records do not.
  • A superseded or deprecated record re-opens the requirement unless its replacement also waives it.
  • Statuses follow Decision register and the Nygard format (Documenting architecture decisions, 2011; adr.github.io): numbers are never reused, and reversed decisions are kept.

Divergence records are per system and per rule. They live in the system's migration repository with a system-prefixed id (for example REG-DEC-007), not in docs/decisions, so they never collide with practice ADR numbering. They line up with "Expected differences" in Reconciliation, parallel run and cutover: a deliberate rule change is an expected difference only when its record is accepted.

Questions the graph answers

These are the gap queries in the source draft, plus "Rule not approved", which this practice adds because only approved cards count as traced. Each becomes a countable line in Traceability gap report and rule trace card.

Query What it finds When to run it Gate
Not migrated In-scope rule, of any status, with no modern implements edge (count approved and proposed separately) Every PR on a migration branch Wave exit: 0
Migrated on faith Rule implemented, but no verified_by and no accepted decision Every PR Wave exit: 0
P0 with no pinning test P0 rule with no pinned_by Before conversion starts Conversion entry: 0
Dead legacy Legacy module with no implements edge, cross-checked against the dead-end flag in the dependency map (Migration assessment): dead code, plumbing, or an undocumented rule about to be lost (see the REG-STAT-003 example in Reconciliation, parallel run and cutover) After extraction, and before decommissioning Each one triaged
Blast radius What a failing test touches: rule, then legacy and modern code, 3 hops each way, as far as dbt exposures When a test fails Informs the fix
Can I delete this legacy program? Its rules, and the modern code that claims and verifies each Before decommissioning Every rule claimed and verified or waived
Before cutting over table X The writers of X, their rules, and each rule's verification Before cutover All verified or waived
Audit chain Every implements, pinned_by, verified_by and decided_by edge for a rule, with evidence, readable by an auditor On request n/a
Undecided suspected defects Rule cards with a suspected defect and no decided_by Weekly during extraction Wave exit: 0
Rule not approved (practice adaptation) Proposed rule card with any implements or verified_by edge Every PR Wave exit: 0

The audit chain is build-time provenance. A runtime decision record's rules_applied[] cites the same rule id (Decision records and provenance), so an auditor can go from one decision about a person to the rule, then to the code and the test that proves it.

Blast radius past the warehouse needs exposures. YAML exposures are in the manifest, but automatically created exposures live in dbt's metadata system, not YAML, so a parse-only reader misses them (Exposures, updated 3 Nov 2026).

Where the data comes from

Side Source
Rules Rule cards from extraction (Extracting business rules)
Legacy topology (calls, dispatches, reads, writes) The dependency map from Migration assessment
Legacy implements Rule-card source citations
Modern implements, verified_by, decided_by dbt meta (below), or the draft's decorator style for Python modules
Modern declared reads / writes Manifest parent_map / child_map
Modern observed reads / writes Platform lineage or OpenLineage (below)

Practice adaptation: rule ids in dbt meta. This is the dbt equivalent of the draft's @implements and @verifies decorators.

models:
  - name: int_registration_fee
    config:
      meta:
        implements: [REG-FEE-020]
    columns:
      - name: concession_applied
        config:
          meta:
            implements: [REG-FEE-014]     # P0: traced per column
            decided_by: REG-DEC-007

unit_tests:
  - name: reg_fee_014_concession_equivalence
    model: int_registration_fee
    config:
      meta:
        verifies: [REG-FEE-014]
        test_kind: equivalence            # or: pinning
  • meta takes arbitrary key-values on models, columns, sources, snapshots, data tests and unit tests (v1.8+). It sits under config: from v1.10 (backported to 1.9) and is compiled into manifest.json (meta, updated 3 Nov 2026).
  • Read it from manifest.json produced by dbt parse, which does not connect to the warehouse (parse, 16 Sep 2026). The trace graph needs no credentials and no compute.
  • Pin the manifest schema version in the reader. It is v12 for dbt 1.8 to 2.0 (manifest.json, updated 8 Sep 2026; verify for the version you run).
  • Use folder-level +meta defaults only when every model in the folder really implements the rule. Otherwise the default makes false claims.

Observed reads and writes come from the platform or from a neutral feed:

  • Databricks: Unity Catalog lineage system tables, system.access.table_lineage and column_lineage, with entity and run ids as evidence. They keep a rolling 395 days, hold only the events where lineage can be inferred, and must be enabled (verify, Lineage system tables, updated 4 Nov 2026). Known gaps include renames, MERGE/UPDATE/DELETE through temporary tables, global temp views, and rolled-back transactions that still emit lineage (Data lineage, updated 4 Nov 2026).
  • Snowflake: Snowflake Horizon Catalog ACCOUNT_USAGE.ACCESS_HISTORY (with objects_modified for writes, query id as evidence) and Data Lineage / GET_LINEAGE. Both need Enterprise Edition, keep one year, and ACCESS_HISTORY lags up to 3 hours. Lineage does not show deleted tables (verify, ACCESS_HISTORY, Data lineage, fetched 6 Nov 2026). OBJECT_DEPENDENCIES covers view definitions but not table loads (verify, OBJECT_DEPENDENCIES).
  • Neutral: OpenLineage models jobs, runs and datasets, and static job events can describe legacy batch programs that never run on the new platform (object model). Its dbt integration reads run artefacts or structured logs, so it needs a run (verify compatibility, dbt integration, docs v1.55.0).

Lineage supplies topology edges (reads, writes) and never replaces implements edges. Platform lineage is incomplete by design, so a missing observed edge is a prompt to investigate, not proof that nothing happened.

Keeping it current

  1. Extract rules before any rewrite. No rule id, no hub.
  2. Map the topology from the dependency inventory.
  3. Every PR that touches a rule carries its rule id in meta or code.
  4. A decision record for each divergence, accepted before the equivalence requirement is waived.
  5. supersedes edges and a recorded end date when rules split or merge. Never delete the old claim.

A rule id referenced in code but missing from the catalogue is a finding, never silently created. It is the same principle as this site's build, which fails on an unknown term.

Strict report in CI (practice adaptation). On migration branches, run dbt parse and a strict trace report (CI/CD and environments). Fail on a rule claimed without a test or an accepted decision. Scan the full manifest every run, because meta-only changes do not trigger state:modified, so slim CI selection would miss them (dbt meta docs).

Snapshot platform lineage on a schedule into the trace store, because retention ages it out and Snowflake lineage drops deleted tables (see above). That is exactly the evidence "dead legacy" and "what was true at cutover" need.

Storage

  • Two tables, nodes and edges, on the platform you already run, or SQLite for a single programme (as in the draft's prototype). A join table is a graph that needs no new system (When a knowledge graph earns its place).
  • In-memory analysis (for example networkx MultiDiGraph, which allows parallel edges such as several dated versions of one claim; docs) or a graph database (for example Neo4j via LOAD CSV; docs) only at portfolio scale. These are examples, not recommendations (verify fit and licensing before use).
  • Keep the vocabulary stable across storage, so the gap queries survive a move.

Worked example

REG-FEE-014, the pensioner concession rule from Extracting business rules, traced end to end:

  1. Legacy. usp_calc_rego_fee lines 212-260 and the SSIS expression ConcFlag in REGO_DAILY each implements REG-FEE-014 (evidence: the rule card's citations, and the commit of the extracted legacy source).
  2. Rule card. P0 (a fee and a decision about a person), approved by Registration policy.
  3. Pinning. pin_reg_fee_014 runs 12 cases through the legacy procedure and saves the outputs as a fixture. One case shows the legacy refusing the concession when the card expires on the renewal date. That is flagged on the card as a suspected defect.
  4. Modern. model.rego.int_registration_fee.concession_applied carries meta.implements: [REG-FEE-014] (per column, because the rule is P0).
  5. Equivalence. reg_fee_014_concession_equivalence uses the pinned fixture, with meta.verifies: [REG-FEE-014] and test_kind: equivalence. 11 cases match the legacy output. The expiry-day case is meant to differ (step 6).
  6. Decision. REG-DEC-007 (accepted by the policy owner) adds a grace period: a card that expires on the renewal date still qualifies. As in the source draft, the waiver is at rule level: REG-FEE-014 carries decided_by REG-DEC-007 (accepted). The equivalence test's expect row for the expiry-day case is changed to the new behaviour and cites REG-DEC-007 in meta, so the test passes. The pinning fixture keeps the legacy output, so the old behaviour stays on record, and Reconciliation lists the difference as expected.

Two rows from the wave's gap report:

Query Item Finding Action
Migrated on faith REG-FEE-020 implements from int_registration_fee, no verified_by, no decision Add an equivalence test before wave exit
Dead legacy Legacy registrations view that filters out cancelled registrations No implements edge Triage: it carries an undocumented rule, found in reconciliation as REG-STAT-003 (Reconciliation, parallel run and cutover); add the card and trace it

Not when

  • Small lift-and-shift waves where SQL moves nearly unchanged. An object map plus Reconciliation costs less.
  • Before rule extraction has given every rule an id. The hub does not exist yet. Do Extracting business rules first.
  • Retire-only systems or archive-only data. Use datastore lineage and retention evidence instead.

Costs to accept before starting:

  • Tagging discipline and an owner for the rule catalogue. A stale rule id is worse than none, because it creates false confidence.
  • Platform lineage needs edition or enablement (Snowflake Enterprise Edition; Databricks system tables enabled).
  • CI compute for unit and data tests.

Next steps (draft)

Sources

Related: Extracting business rules, Where business rules run after migration, Reconciliation, parallel run and cutover, Traceability gap report and rule trace card, Modernisation and migration.

Source links: Data Practice reading list.