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
implementsedges 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
acceptedwaives.proposed, under review,rejectedor deferred records do not. - A
supersededordeprecatedrecord 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
metatakes arbitrary key-values on models, columns, sources, snapshots, data tests and unit tests (v1.8+). It sits underconfig:from v1.10 (backported to 1.9) and is compiled intomanifest.json(meta, updated 3 Nov 2026).- Read it from
manifest.jsonproduced bydbt 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
+metadefaults 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_lineageandcolumn_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(withobjects_modifiedfor writes, query id as evidence) and Data Lineage /GET_LINEAGE. Both need Enterprise Edition, keep one year, andACCESS_HISTORYlags up to 3 hours. Lineage does not show deleted tables (verify, ACCESS_HISTORY, Data lineage, fetched 6 Nov 2026).OBJECT_DEPENDENCIEScovers 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
- Extract rules before any rewrite. No rule id, no hub.
- Map the topology from the dependency inventory.
- Every PR that touches a rule carries its rule id in
metaor code. - A decision record for each divergence, accepted before the equivalence requirement is waived.
supersedesedges 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,
nodesandedges, 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 viaLOAD 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:
- Legacy.
usp_calc_rego_feelines 212-260 and the SSIS expressionConcFlaginREGO_DAILYeachimplementsREG-FEE-014 (evidence: the rule card's citations, and the commit of the extracted legacy source). - Rule card. P0 (a fee and a decision about a person), approved by Registration policy.
- Pinning.
pin_reg_fee_014runs 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. - Modern.
model.rego.int_registration_fee.concession_appliedcarriesmeta.implements: [REG-FEE-014](per column, because the rule is P0). - Equivalence.
reg_fee_014_concession_equivalenceuses the pinned fixture, withmeta.verifies: [REG-FEE-014]andtest_kind: equivalence. 11 cases match the legacy output. The expiry-day case is meant to differ (step 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 carriesdecided_byREG-DEC-007 (accepted). The equivalence test'sexpectrow for the expiry-day case is changed to the new behaviour and cites REG-DEC-007 inmeta, 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)
- Record equivalence-test runs (pass or fail, and the date) on the
verified_byedge, so the audit chain shows the last green run. That ties to the results table in Reconciliation and cutover checklist. - An optional Mermaid trace diagram for PR descriptions (low priority).
- Use Traceability gap report and rule trace card for each wave.
Sources
- Modernization traceability graph, draft design, Nov 2026 (not an adopted standard).
- SEBoK, System Requirements Definition (last edited 23 Mar 2026): define a traceability relationship model at the start of the project.
- ISO/IEC/IEEE 29148:2018, requirements engineering. Context only: the clause content on traceability was not read (paywalled), and a revision is pending (verify).
- Feathers, Characterization testing (2016).
- Nygard, Documenting architecture decisions (2011), and adr.github.io.
- Fowler, Bitemporal history (2021).
- dbt, Databricks, Snowflake and OpenLineage docs as linked above.
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.