Multi-ID Enrichment — Release Notes

2026-09-23 · stage01.rel.imat.us · enricher_id branch

Objective Success Criteria

Why this code is needed

Today the MPI matcher sees only the identifiers that arrive natively in a single CCD or HL7 message. When a patient has records at multiple facilities, each message carries only one facility's MRN. The engine falls back to demographic rules (name, DOB, address), which have a confirmed mis-merge risk at rule 9 and an untested high-volume risk at rule 16.

Multi-ID enrichment fills that gap: before the MPI call fires, it reads the other facility MRNs that Mirth has already stored in reliance_patient_ids and adds them to the FHIR payload. The engine can then anchor the match on an identifier instead of demographics.

This project is intentionally additive. The additional identifiers sourced from reliance_patient_ids are merged alongside — not in place of — the FMRN value that IMAT already extracts natively from each message. Both the IMAT-extracted FMRN and the supplemental IDs reach the matcher together, giving the engine more to work with without removing any existing signal. We have not decided to move away from the IMAT-pushed FMRN; the current release only adds to it.

This also enables cross-pipeline FMRN matching. When a contributor's HL7v2 message is received and indexed, Mirth captures that facility's MRN in reliance_patient_ids during onboarding. When a CCD for the same patient arrives later via a different pipeline, Multi-ID enrichment reads that already-stored FMRN and injects it into the CCD's FHIR payload — allowing the MPI to match on the facility identifier and ID value that came from the HL7 feed, even though the CCD pipeline never saw them directly. Without enrichment, the CCD would match only on demographics; with it, the FMRN captured upstream in the onboarding flow bridges the two pipelines, making matches more precise and consistent across feed types.

The module also introduces facility_set_organization as a first-class concept on each message, sourced from reliance_msg_facility_set_org. This field is intended to govern facility set creation and control — replacing the current situation where a single contributor can produce many different sending_facility permutations that fragment their records across multiple facility sets. For example, Assante had over 50 distinct sending_facility values; facility_set_organization will consolidate all of those under a single authoritative identifier, giving the system one stable handle to use for facility set assignment and downstream processing.

Stage evidence

Matching moved off demographic rules and onto identifier rules

Moved to an identifier rule (2 or 7) Unchanged Rule lookup missed (timing artifact)

2,139-message intersection of Run 1 (flag off) and Run 2 (flag on), stage01.rel.imat.us, 2026-09-18. Amber flows are confirmed successful matches where the rule lookup fired before store 1100 indexed the PI — corrected by the retry fix now deployed.

MetricRun 1 — flag off (baseline)Run 2 — flag on (Multi-ID)What it means
Messages compared2,139 (same messages in both runs)Apples-to-apples intersection
FMRNs pushed into matching payload02,628Multi-ID supplied identifiers the MPI never saw before
Messages where FMRN drove the match95  (3.8% of run)501  (22.4% of run)5× more messages matched via identifier rather than demographics
Messages matched by identifier rules (2 or 7)53  (2.1%)468  (20.9%)Strong identifier-anchored rules absorbed traffic from weaker demographic rules
Existing patients re-routed to a different persona—0 of 1,369No existing patient was incorrectly changed — safe to enable

Rule 16 matches (the highest-volume demographic rule at ~30% of messages) moved to rule 2 (149 messages). Rule 20 matches moved to rule 7 (51 messages). These are the two identifier-anchored rules that absorbed the upgrade volume. The pattern is consistent: each demographic rule has a fixed identifier-tier partner, pending confirmation of rule definitions with IMAT.

Harness tests F_04b (sparse-demographic patient consolidated via supplemental FMRN alone) and M_02b (primary FMRN miss resolved by cross-reference FMRN) both passed, confirming the mechanism independently of the pressure test population.

Production targets

MetricPhase 1 baselinePhase 2 target
IDs pushed / IDs available0 (flag off)≥ 95%
FMRN-utilized shareMeasure in Phase 1≥ 15%
Identifier rule (2 + 7) shareMeasure in Phase 1≥ 15%
Persona changes on pre-existing patients00
Match rate regressionPhase 1 baseline≤ Phase 1
Missing rule_fired on success≤ 1%≤ 1% (retry fix deployed)
Seconds per recordMeasure in Phase 1≤ 10% above Phase 1

What Multi-ID does not fix

These exist with the flag off; Multi-ID reduces exposure to the first two by moving volume off demographic rules.

Worker Injection — where enrichment enters the normal process

The enrichment module wraps three points in mpi_and_emit_worker.py without changing the core MPI call. Every step degrades gracefully if its flag is off or a dependency is unavailable.

Mirth DB Write
Mirth writes to two tables:
• reliance_patient_ids — the additive facility MRNs extracted from the message
• reliance_msg_facility_set_org — the org identifier used to control facility set creation
→
Append IDs
enhanced_id module — opens the DB, checks whether supplemental IDs exist for this message, and if so passes them to build_fhir() to merge into the FHIR payload before matching
→
① Pre-match enrichment
supplemental.py
→
MPI Call
issue_mpi_call()
store 1100
→
② Post-match recording
outcome.py
→
③ Rollback capture
rollback.py
→
Finalize
Emit / index

GitLab: mpi_and_emit_v2 (enhancement_id)

① Pre-match enrichment — supplemental.py

Runs when ENABLE_MSG_METADATA_ENRICHMENT = True, before issue_mpi_call().

  1. Opens a warehouse connection (metadata_conn, separate from the main connection).
  2. Queries reliance_patient_ids by msg_id → a list of (assigning_authority, type, patient_id) rows.
  3. Filters by MSG_METADATA_ALLOWED_ID_TYPES — currently mrn / mr / fmrn → fmrn. SSN excluded by decision.
  4. Merges FMRNs into data_tags[FMRN_FIELD], deduping on (authority.upper(), id). A hard cap of 50 identifiers is enforced before the payload is passed to the MPI call — in practice most pipelines present 2–3 IDs; the cap prevents payload growth for edge-case contributors with large cross-reference lists.
  5. Queries reliance_msg_facility_set_org by msg_id → sets sending_org_id in the payload.

If either query fails, the error is logged and the process continues without the enriched identifiers — the MPI call still fires.

② Post-match recording — outcome.py

Runs after issue_mpi_call() returns, when ENABLE_MATCH_QUALITY_EVAL = True.

  1. Calls _fetch_pi_rule_with_retry(pi): polls store 1100 every 1 s for up to 10 s until rule_fired and match_type appear. Store 1100 indexes a PI asynchronously, so a single immediate read races the index.
  2. Calls evaluate_fmrn_utilization() to set fmrn_utilized and fmrn_available_unused.
  3. Upserts one row into warehouse.reliance_match_reconciliation with 38 fields covering identifiers present, rule fired, match type, timing and enrichment flags.

③ Rollback capture — rollback.py

Runs after a successful match, when ENABLE_ROLLBACK = True.

  1. Writes one row to utilities.rollback_pipeline: msg_id, pi (e.g. 52309187.PI), mpid, imat_dest, psuri.
  2. Reverse is via rollback_script.py --msg-id <id>, which strips the .PI suffix and calls extensions/mpi/transactions/delete.

What enrichment does NOT change

UnchangedWhy
Native FHIR field parsingEnrichment only appends identifiers; it never removes fields the original parse set
issue_mpi_call() itselfThe MPI call is identical; enrichment only changes the payload that goes in
reliance_patient_idsRead-only; Mirth owns writes to this table
Emit / indexing stepsDownstream is unaffected; enrichment is entirely pre-match

Settings & Flags

All three feature flags ship off. A flag change takes effect on the next batch, about 1 minute, with no worker restart.

FlagDefaultEffect when TruePhase on
ENABLE_MSG_METADATA_ENRICHMENTFalsePre-match FMRN injection from relational tablesPhase 2
ENABLE_MATCH_QUALITY_EVALFalsePost-match store 1100 lookup + reconciliation writePhase 1
ENABLE_ROLLBACKFalseCapture PI + source URI for per-message rollbackPhase 1

Tuning settings

SettingDefaultEffect
MSG_METADATA_ALLOWED_ID_TYPES{"mrn":"fmrn","mr":"fmrn","fmrn":"fmrn"}Allowlist of Mirth ID types that reach the matcher. SSN excluded by decision.
PI_RULE_LOOKUP_TIMEOUT_S10Max seconds to poll store 1100 for rule_fired after a match
PI_RULE_LOOKUP_INTERVAL_S1Seconds between store 1100 poll attempts
ROLLBACK_DB_NAME"utilities"DB holding rollback_pipeline; must differ from warehouse
FHIR_FACILITY_ID_CAP50Maximum number of facility identifiers that can be appended to the FHIR payload per message. Hard cap enforced before issue_mpi_call(). In practice most pipelines present 2–3 IDs; the cap exists to prevent unbounded payload growth for edge-case contributors with large cross-reference lists.

Confirm flags after deploy

cd /opt/tools/mpi_and_emit_v2
python -c "
from enrichment.message_enrichment_pipeline import MessageEnricher
e = MessageEnricher()
print('metadata_enrichment_available:', e.metadata_enrichment_available)
print('match_quality_eval_available: ', e.match_quality_eval_available)
e.close()
"
# Phase 0 expected output:
# metadata_enrichment_available: False
# match_quality_eval_available:  False

Database Tables

The enrichment module reads Mirth-owned tables and writes to tables it owns. Mirth-owned tables are never modified; the service user holds SELECT only on them.

Mirth-owned (read-only to enrichment)

TableDBKey columnsWhat it stores
reliance_patient_idswarehouseid PK, msg_id FK+IDX, assigning_authority, type, patient_idOne row per identifier Mirth extracted from each message. Multiple rows per msg_id when a CCD carries multiple MRNs.
reliance_msg_facility_set_orgwarehousemsg_id PK+FK, facility_set_orgThe facility/org Mirth attributed to the sending organization for this message.
reliance_msg_changelogwarehousemsg_id PK+FK, changelog, facility_set_org, filename_gen IDXMirth audit log. Not read by the enrichment code.
MessageStorewarehouseMsgId PK, MsgContent, MsgSource, MsgReceivedCore message store. Enrichment holds SELECT only. MsgContent can be NULLed only by rollback with --purge-raw-content, which requires a separate UPDATE grant withheld in production.

Enrichment-owned (read/write)

TableDBKey columnsWhat it stores
reliance_match_reconciliationwarehousemsg_id PK+FK, 37 further columnsOne row per processed message: rule_fired, match_type, identifiers present (had_fmrn/ssn/dob/ln/fn/mi/ph/street_zip), ids_available, ids_pushed, fmrn_utilized, mpid_reassigned, pipeline timing, error_detail. The source of all match quality reports.
reliance_persona_quality_flagswarehousempid PK (composite with observed_at)Persona-level snapshots written by the backfill: multi_ssn_persona, persona_shrank, high_facility_concentration. One row per persona per backfill run.
rollback_pipelineutilitiesid PK AUTO_INCREMENT, msg_id UNIQUE, pi IDXOne row per matched message: pi, mpid, imat_dest, psuri, captured_at. rolled_back_at set to NOW() when rollback_script.py successfully reverses it.

Stage-only (not deployed to production)

TableDBWhat it stores
pressure_test_snapshotutilitiesCopies of reconciliation rows per pressure-test run label, so the comparison survives the rollback between runs.

Migration status

MigrationTarget DBStatus
009_persona_quality_flags.sqlwarehouseApplied on stage
010_rollback_pipeline.sqlutilities (also creates DB)Applied on stage
011_pressure_test_snapshot.sqlutilitiesStage only — do not apply to production

Entity Relationship Diagram

Full schema including service user access lines. Orange dashed = SELECT only (stage also UPDATE/DELETE); teal = SELECT·INSERT·UPDATE·DELETE; blue = SELECT·INSERT·UPDATE·DELETE.

MessageStore ERD — Multi-ID Full Schema

Users that interact with the code

Three actors touch the system: the Mirth service that populates the source tables, the reliance DB user that the worker and rollback run as, and the operator who runs rollback_script.py manually.

Mirth (populates source tables)

ActionTables written
CCD / HL7 channel processingreliance_patient_ids, reliance_msg_facility_set_org, reliance_msg_changelog

Enrichment reads these tables but never writes to them. Every prod channel must populate them before enrichment is enabled — verify with the SQL in the Production Prerequisites section of the main doc.

reliance@% (worker, backfill, rollback)

TableGrantReason
warehouse.MessageStoreSELECTRead MsgSource, MsgReceived for reconciliation
warehouse.reliance_patient_idsSELECTRead-only; Mirth owns writes
warehouse.reliance_msg_facility_set_orgSELECTRead-only; Mirth owns writes
warehouse.reliance_msg_changelogSELECTRead-only; Mirth owns writes
warehouse.reliance_match_reconciliationSELECT, INSERT, UPDATE, DELETEEnrichment writes and rollback deletes
warehouse.reliance_persona_quality_flagsSELECT, INSERT, UPDATE, DELETEBackfill writes persona snapshots
utilities.rollback_pipelineSELECT, INSERT, UPDATE, DELETEWorker inserts; rollback_script marks complete
Withheld in production on purpose: UPDATE on MessageStore and DELETE on reliance_patient_ids. Without them, --purge-raw-content and --delete-mirth-ids fail closed. These grants exist on stage for testing; they must not be applied to production.

Operator (rollback)

Runs rollback_script.py manually, always with --msg-id <id> or an explicit list. Never run bare in production — it would process every pending row in rollback_pipeline.

Also needs a RelianceApiCall session with access to:

Credentials file: /opt/search/appliance5/conf/.mariadb_connection.json (prod) or .mariadb_connection_local.json (stage), containing the reliance user and password. Must be chmod 600.

Release Plan

Four phases, one flag per phase, each started only when the previous gate passes. A flag change takes effect within about one minute with no restart.

PhaseEnrichmentQuality EvalRollbackDurationExit gate
0: Deploy dark Off Off Off Day 0–1 Import check prints False False; no new worker errors; seconds per record unchanged over 24 h
1: Measure baseline Off On On Days 1–14 ≥ 99% of messages have a reconciliation row; missing-rule rows ≤ 1%; rollback proven on one test message; ≥ 2 backfill snapshots per active persona; baselines recorded
2: Enable Multi-ID On On On From day 15 All objective targets met at 24 h, 72 h, 7 days; any breach triggers flag rollback (Level 1)
3: Steady state On On On — until sign-off From day 22 Weekly match quality report reviewed; match rate increasing, splits decreasing, and stronger identifier-rule matching confirmed. Only then: turn ENABLE_ROLLBACK = False and drop utilities.rollback_pipeline table. Keep capture on if any doubt remains — it can always be re-enabled.

PI recording — stays on until results are confirmed

When ENABLE_ROLLBACK = True, every matched message gets a row in utilities.rollback_pipeline recording its PI, MPID and source URI. This is the audit trail that enables a Level 4 data rollback if a wrong match is confirmed.

The PI value stored is the raw form from the MPI response (e.g. 52309187.PI). rollback_script.py strips the .PI suffix before calling the delete endpoint — confirmed working on stage after the fix in this release.

PI recording remains on through all phases until the team is satisfied with results. The weekly match quality report must show: match rate increasing, splits not increasing, and identifier-rule share growing as expected. Only after these criteria are confirmed should rollback capture be turned off:

1. Set ENABLE_ROLLBACK = False in settings.py.
2. Verify no new rows are being written to utilities.rollback_pipeline.
3. Drop the utilities.rollback_pipeline table (and optionally the utilities DB if no other tables remain).

If results are ambiguous or a new concern arises later, rollback capture can be re-enabled at any time by setting ENABLE_ROLLBACK = True and re-running migration 010_rollback_pipeline.sql.

Split / merge reporting

The backfill job runs every 2 hours and captures persona-level stats including persona_shrank (a possible split) and multi_ssn_persona (a possible erroneous merge). These feed the weekly report generated by generate_match_quality_report.py.

A split signal: distinct MPIDs per day rising while messages per persona falls. A merge signal: multi_ssn_persona rate above the Phase 1 baseline, or mpid_reassigned appearing for a message that previously matched correctly.

Rollback plan summary

LevelActionTakes effectUse when
1: Flag rollbackENABLE_MSG_METADATA_ENRICHMENT = False~1 minAny Phase 2 gate breach
2: Full disableAll three flags False~1 minWorker errors or slowdown from the release's own lookups
3: Code revertRedeploy tagged pre-release workerMinutesLevel 2 does not restore normal behavior
4: Data rollbackrollback_script.py --msg-id <id> then re-feedPer messageConfirmed wrong match caused by an enriched identifier

Level 1 — one-liner

sed -i.bak 's/^ENABLE_MSG_METADATA_ENRICHMENT *= *True/ENABLE_MSG_METADATA_ENRICHMENT = False/' \
  /opt/tools/mpi_and_emit_v2/settings.py && grep ENABLE_MSG settings.py

Gate query (run daily from Phase 1)

SELECT DATE(processed_at)                                                    AS day,
       COUNT(*)                                                               AS messages,
       ROUND(100*SUM(pi IS NOT NULL)/COUNT(*), 2)                             AS matched_pct,
       ROUND(100*SUM(pi IS NOT NULL AND rule_fired IS NULL)/COUNT(*), 2)      AS missing_rule_pct,
       ROUND(100*SUM(rule_fired IN (2,7))/COUNT(*), 2)                        AS identifier_rule_pct,
       ROUND(100*SUM(fmrn_utilized = 1)/COUNT(*), 2)                          AS fmrn_utilized_pct,
       ROUND(100*SUM(mpid_reassigned = 1)/COUNT(*), 3)                        AS reassigned_pct
FROM warehouse.reliance_match_reconciliation
WHERE processed_at >= CURDATE() - INTERVAL 14 DAY
GROUP BY DATE(processed_at) ORDER BY day;

Cron Updates

One cron addition (backfill) and two stage-only crons to remove before production deploy.

Add: persona backfill (every 2 hours)

0 */2 * * * root flock -n /var/lock/persona_backfill.lock \
  /opt/tools/miniconda3/envs/python3_12/bin/python \
  /opt/tools/mpi_and_emit_v2/enrichment/run_persona_backfill.py \
  --lookback-hours 3 \
  >> /opt/tools/mpi_and_emit_v2/enrichment/match_quality_eval/persona_backfill.log 2>&1

The flock -n wrapper is required. Stage runs show one persona-builder pass can take 17–67 minutes for 1,000–2,700 MPIDs, so overlap is possible without it. If the lock is already held, the new invocation exits silently and the next scheduled run takes over.

Important: run_persona_backfill.py is missing from the stage enrichment/ directory as of the last log entry — the last three scheduled cron runs failed with "No such file or directory". Confirm the file is present on disk before deploying the cron.

Remove: stage-only pressure test crons

These must not appear in production crontabs:

# Remove these lines from crontab on stage before promoting to prod:
* * * * * /usr/bin/python3 /opt/tools/sql_query/sql_query.py stage_ccd_preasure_prod_tests 1000 >> ...
* * * * * /usr/bin/python3 /opt/tools/sql_query/sql_query.py stage_hl7_preasure_prod_tests 1000 >> ...

Backfill health check

grep -E "can't open|failed|Traceback|Backfill complete" \
  /opt/tools/mpi_and_emit_v2/enrichment/match_quality_eval/persona_backfill.log | tail -20

Every successful run ends with Backfill complete: N processed, 0 failed. One run on 2026-09-16 failed 339 of 1,802 (19%) — cause unknown, all other runs showed 0 failures. Monitor this on Phase 1 day 1.

Expected throughput

DateMPIDs processedDuration (approx)
2026-09-14253~5 min
2026-09-152,529~48 min
2026-09-172,728~38 min
2026-09-212,116~67 min
2026-09-23582~52 min

Production will likely see larger windows. Measure during Phase 1 and confirm the 2-hour interval is sufficient.

Blocking and Non-Blocking Items

Blocking — must complete before deploy

#ItemWhy it blocks
1IMAT review and approval of the enricher_id branchChanges the MPI submission path
2Migrations 009 and 010 applied; prod reliance_match_reconciliation matches the stage schema (38 columns)Reconciliation, persona and rollback writes fail without them
3Every prod channel populates reliance_patient_ids and reliance_msg_facility_set_orgWithout rows, enrichment silently adds nothing
4reliance@'%' grants applied and prod credentials file verified as chmod 600Connections fail; every path degrades to off
5Source deployed from enricher_id branch (not compiled zip); __pycache__ cleared; import check passes under prod interpreterStale compiled files or wrong Python can run old code silently
6run_persona_backfill.py restored on disk; cron wrapped in flockPhase 1 baselines depend on it; the stage cron is failing now
7Production guard in rollback_script.py: refuse to run bare without --msg-id or an explicit list outside stageBare mode processes every pending row — up to --limit live patient records deleted
8Rollback proven in production on one designated test message (dry run → real run → confirm PI gone from store 1100)The PI delete failed silently for weeks on stage before the fix; production proof required
9Persona-safety check on the pressure test intersection returns changed = 0Key safety claim currently rests on sampled rows
10All three flags confirmed off in prod settings.pyDeploy must change nothing until Phase 1

Open Questions

Items marked with a status reflect answers received so far.

• CR timing: What is the expected turnaround from CR submission to approval?
• Code promotion: Who has authority to merge and deploy the enricher_id branch to production — Reliance, IMAT IT, or a joint process?
• DB ownership: Once the migrations are applied, who is the designated owner for the utilities DB and the new warehouse tables — responsible for backups, schema changes, and long-term maintenance?
QuestionStatusImpact
Do we have the privileges to make the necessary database changes ourselves — adding new tables to warehouse and creating the utilities DB — or does this require IMAT IT approval and implementation? Open What needs to happen:

• Apply migration 009_persona_quality_flags.sql → adds warehouse.reliance_persona_quality_flags
• Apply migration 010_rollback_pipeline.sql → creates the utilities database and adds utilities.rollback_pipeline
• Apply reliance@'%' grants on both new tables (see the Users tab)

If we do not have DDL privileges on warehouse or the ability to create new databases, IMAT IT will need to run both migrations before Phase 0 can proceed. This is a hard blocker — the worker will fail to write reconciliation and rollback rows without these tables in place.
Who has authority to create the reliance@'%' service user and apply the required grants — can we do this ourselves, or does it require IMAT IT? Open We believe we may be able to apply the grants ourselves, but this depends on whether the account running the migration has GRANT OPTION on the relevant databases. Needs confirmation before Phase 0:

• Does our current DB admin account have GRANT OPTION on warehouse and utilities?
• If the reliance user does not already exist in production, do we have CREATE USER privilege, or must IMAT IT provision it?
• Who verifies the credentials file (.mariadb_connection.json) is correctly permissioned (chmod 600) in the production environment?
Open
What is the CR approval and release ownership process — if the change request is approved, who releases the GitLab code to production, and who is assigned as owner for DB generation and ongoing maintenance?

Rules Reference

Full rule set from production rules.xml, provided 2026-09-24. Rule 0 (exact re-match) is engine-level and does not appear in the file.

FMRN-anchored rules
Rules 1, 2, 4, 6, 7, 9, 11, 12, 14, 18, 21, 23, 27, 28, 29, 30, 31, 32, 33
FMRN is present — match is identifier-driven. Multi-ID promotes messages onto these rules.
Demographic-only rules (no FMRN)
Rules 3, 5, 8, 10, 13, 15, 16, 17, 19, 20, 22, 24, 25, 26, 34, 36, 37
No FMRN — match rests on name, DOB, demographics. Mis-merge risk at rules 9, 16, 37.

Key rules for Multi-ID analysis

RuleFieldsRole in Multi-ID
2FMRN · LN · FN · MI · DOB · gender · street_zipPrimary upgrade target — absorbs rule 16 transitions (149 messages). Full-strength identifier match with address.
7FMRN · LN · FN · DOB · gender · street_zipSecondary upgrade target — absorbs rule 20 transitions (51 messages). Same as rule 2 without MI.
14FMRN · LN · FN · MI · DOB · genderFMRN-anchored without address. Harness R_02 confirmed.
18FMRN · LN · FN · DOB · genderPrimary supplemental-FMRN consolidation rule. Harness F_04b, M_02b confirmed.
16LN · FN · MI · DOB · gender · street_zipDemographic fallback — ~30% of Run 1 messages. Multi-ID promotes to rule 2.
20LN · FN · DOB · gender · PH · street_zipDemographic fallback — Multi-ID promotes to rule 7.
34LN · FN · DOB · gender · street_zipDemographic fallback — same as 16 without MI.
37LN · FN · DOB · PHMis-merge confirmed — F_02b: two distinct patients merged. Candidate for tightening.
9FMRN · LN · FN · DOB · gender · PHFMRN-anchored with phone. Fired in M_05b. FMRN present — not the same weakness as rule 37.
6FMRN · LN · FN · DOB · gender · SSNFired uniformly across M_06–M_12. Likely test-environment contamination, not a rule defect.
0Exact re-match (engine-level)Not in rules.xml. ~33% of Run 1 messages; drops to ~25% under Multi-ID as enriched payloads differ.

Rule transition map (pressure test)

From (no FMRN)FieldsTo (FMRN-anchored)Fields addedMessages
Rule 16LN FN MI DOB gender street_zipRule 2+ FMRN149
Rule 20LN FN DOB gender PH street_zipRule 7+ FMRN (− PH)51
Rule 0Exact re-matchRule 7Payload changed by FMRN injection52
Rule 0Exact re-matchRule 2Payload changed by FMRN injection52
Rule 17LN FN MI DOB gender PHRule 2+ FMRN (− PH + street_zip)21
Rule 37LN FN DOB PHRule 7+ FMRN + gender + street_zip20
Rule 34LN FN DOB gender street_zipRule 7+ FMRN9

Full rule listing

RuleFMRNFields matched
1✓FMRN LN FN MI DOB gender SSN
2✓FMRN LN FN MI DOB gender street_zip
3—LN FN MI DOB gender SSN street_zip
4✓FMRN LN FN MI DOB gender PH
5—LN FN MI DOB gender SSN PH
6✓FMRN LN FN DOB gender SSN
7✓FMRN LN FN DOB gender street_zip
8—LN FN DOB gender SSN street_zip
9✓FMRN LN FN DOB gender PH
10—LN FN DOB gender SSN PH
11✓FMRN LN FN DOB SSN
12✓FMRN LN FN DOB PH street_zip
13—LN FN DOB SSN PH street_zip
14✓FMRN LN FN MI DOB gender
15—LN FN MI DOB gender SSN
16—LN FN MI DOB gender street_zip
17—LN FN MI DOB gender PH
18✓FMRN LN FN DOB gender
19—LN FN DOB gender SSN
20—LN FN DOB gender PH street_zip
21✓FMRN LN DOB gender SSN
22—FN DOB gender SSN PH street_zip
23✓FMRN LN FN gender
24—FN DOB gender SSN street_zip
25—LN FN DOB SSN
26—LN FN DOB PH street_zip
27✓FMRN LN DOB gender PH street_zip
28✓FMRN LN DOB gender SSN PH
29✓FMRN LN FN SSN street_zip
30✓FMRN LN gender SSN street_zip
31✓FMRN LN FN gender SSN
32✓FMRN LN FN gender PH street_zip
33✓FMRN LN DOB gender
34—LN FN DOB gender street_zip
36—LN FN DOB street_zip
37—LN FN DOB PH

Note: rule 35 is absent from the provided rules.xml. Rule 0 is engine-level and does not appear in the file.