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.

Maintenance

Ongoing responsibilities after release: who reviews the reports, how often, and what happens when identifier data quality issues — including blanks — are found.

Weekly match quality report

ItemDetail
Generated bypython -m enrichment.generate_match_quality_report — run manually or scheduled
ReviewerTo be assigned — open item for the team before Phase 1
CadenceWeekly from Phase 1 onward; daily gate query run by the deploy owner during Phase 2 ramp
What to look forIdentifier rule (2+7) share trending up; match rate stable; mpid_reassigned rate flat; multi_ssn_persona not growing; persona_shrank not growing
Sign-off to turn rollback capture offReviewer confirms all targets met across at least 3 consecutive weekly reports

ID quality issues — blank or missing identifiers

When reliance_patient_ids has no rows for a message, enrichment adds nothing and the match falls back to whatever IMAT extracted natively. This is silent — no error is raised. The feedback loop to surface this:

SignalWhere to lookWho acts
High ids_available = 0 rate for a channelGate query: SUM(ids_available = 0) grouped by sending_facility in reliance_match_reconciliationReliance data ops — investigate whether the Mirth channel is writing to reliance_patient_ids
FMRN-utilized rate below 15% after Phase 2 enableGate query: fmrn_utilized_pct columnReliance data ops — check which facilities are contributing messages without supplemental IDs
Blank assigning_authority or patient_id in reliance_patient_idsSELECT * FROM reliance_patient_ids WHERE patient_id IS NULL OR patient_id = '' LIMIT 50Mirth channel owner — blank values mean the CCD parser didn't extract the ID field; fix the channel mapping
Wrong type value not in the allowlistSELECT type, COUNT(*) FROM reliance_patient_ids GROUP BY type ORDER BY COUNT(*) DESCReliance data ops — if a valid ID type is being excluded, add it to MSG_METADATA_ALLOWED_ID_TYPES after review

Feedback loop — from report finding to fix

  1. Reviewer identifies an issue in the weekly report or gate query — e.g. a facility showing ids_available = 0 for all its messages, or a spike in mpid_reassigned.
  2. Reviewer raises it with the Mirth channel owner (if the issue is blank/missing IDs in reliance_patient_ids) or with the rules owner (if the issue is a mis-merge pattern in reliance_match_reconciliation).
  3. Channel fix: Mirth channel owner corrects the ID extraction mapping and reprocesses affected messages if needed. New messages from that channel should start populating reliance_patient_ids correctly on the next run.
  4. Allowlist fix: If a valid ID type is present in the DB but excluded by MSG_METADATA_ALLOWED_ID_TYPES, Reliance data ops updates settings.py after confirming the type is safe to pass to the matcher. SSN requires a separate compliance decision before it can be added.
  5. Rollback if needed: If a confirmed wrong match is traced to an enriched identifier, trigger a Level 4 rollback for the affected messages (see the Release Plan tab) and re-feed after the fix is in place.
  6. Verify: Check the next weekly report to confirm the issue is resolved and metrics have returned to baseline.

ID quality flagging — rolling 3-month list

The enrichment module validates every row from reliance_patient_ids before injecting it into the FHIR payload. Rows that fail validation are skipped (not passed to the matcher) and written to warehouse.reliance_id_quality_flags with a reason code. This table retains 3 months of history; older rows are purged at the start of each backfill run.

Validation rules — what gets flagged and skipped

CategoryRuleFlag reason code
Blocked typeID type is ssn, ss, or social — SSN excluded from enrichment by decisionblocked_type:ssn
Blank valuepatient_id is NULL, empty, or whitespace onlyblank_patient_id
Blank authorityassigning_authority is NULL or empty — no facility to attribute the ID toblank_assigning_authority
Invalid chars in facilityassigning_authority contains . / ? * \ | < > { } [ ] ^ ~ `invalid_chars_in_authority
Invalid chars in IDpatient_id contains the same special character setinvalid_chars_in_id
All zerospatient_id is 000, 0000, etc.unlikely_id_pattern
Repeated characters5+ of the same character e.g. 99999, AAAAAunlikely_id_pattern
Single characterpatient_id is exactly 1 characterunlikely_id_pattern
Exceeds column lengthpatient_id is over 50 characters (exceeds the varchar(50) DB column)unlikely_id_pattern
Placeholder valuepatient_id matches null, none, unknown, test, n/a, na, temp (case-insensitive)unlikely_id_pattern
Starts with special characterpatient_id first character is not alphanumericunlikely_id_pattern

Reviewing flagged IDs

Run this weekly alongside the match quality report to surface new patterns. Results grouped by flag reason and facility — new combinations that haven't appeared before are the ones to investigate first:

# All flags in the last 7 days, grouped by reason and facility
grep "$(date -d '7 days ago' +%Y-%m-%d)" \
  /opt/tools/mpi_and_emit_v2/enrichment/logs/id_quality_flags.log \
  | python3 -c "
import sys, json
from collections import Counter
counts = Counter()
for line in sys.stdin:
    r = json.loads(line)
    counts[(r['flag_reason'], r['sending_facility'])] += 1
for (reason, fac), n in counts.most_common():
    print(f'{n:>6}  {reason:<40}  {fac}')
"

To pull all entries for a specific message:

grep '"msg_id": 672018724' /opt/tools/mpi_and_emit_v2/enrichment/logs/id_quality_flags.log

Feedback loop — from flagged ID to interface analyst

  1. Reviewer runs the flag query weekly. Any flag_reason + sending_facility combination with unreviewed > 0 is a candidate for investigation.
  2. Classify the flag:
    • blank_patient_id or blank_assigning_authority → Mirth channel is not populating the field. Raise with the interface analyst for that sending facility — they need to fix the CCD extraction mapping.
    • invalid_chars_in_authority or invalid_chars_in_id → The facility is sending malformed identifiers. Raise with the interface analyst — may need a transformation rule in the Mirth channel.
    • unlikely_id_pattern → Could be a placeholder or test value. Raise with the interface analyst to confirm whether the value is intentional or a channel bug.
    • blocked_type:ssn → Expected; no action unless the SSN allowlist decision changes.
  3. Interface analyst investigates the sending facility's CCD structure and Mirth channel mapping, corrects the extraction, and notifies the reviewer.
  4. Mark as understood — once an issue is investigated and either fixed or accepted as expected behavior, add a note in the team's tracking system. The log file itself is append-only; no DB update needed.
  5. If a new edge case is found that the current rules don't cover — a new class of malformed ID — add it to the UNLIKELY_ID_PATTERNS list in extraction.py and document it in this table.

Log file — no new DB table needed

Flagged IDs are written as JSON lines to a log file. One line per skipped row, containing everything needed to investigate and route to the right interface analyst.

# Log location
/opt/tools/mpi_and_emit_v2/enrichment/logs/id_quality_flags.log

# Example line
{"ts":"2026-09-25T14:32:01","msg_id":672018724,"sending_facility":"ORG_ALPHA",
 "assigning_authority":"ORG_BETA","id_type":"mr","patient_id":"???001",
 "flag_reason":"invalid_chars_in_id"}

3-month rolling retention via logrotate — add to /etc/logrotate.d/id_quality_flags:

/opt/tools/mpi_and_emit_v2/enrichment/logs/id_quality_flags.log {
    daily
    rotate 90
    compress
    missingok
    notifempty
    copytruncate
}
No new migration or DB grant needed. The log file is created automatically on first write. The only infrastructure change is adding the logrotate config on the server.

Maintenance ownership — to be assigned

The following roles need to be named before Phase 1 starts. These are open items for the team.
ResponsibilityAssigned to
Run weekly match quality report and ID quality flag reviewTBD
Review report and approve Phase 3 rollback-capture shutdownTBD
First responder for ID quality issues — interface analyst contactTBD per sending facility
First responder for MSG_METADATA_ALLOWED_ID_TYPES or validation rule changesTBD — Reliance data ops
Escalation contact at IMAT for rule or store 1100 issuesTBD — IMAT point of contact

Feedback & Alerting

Two validation points catch data quality issues before they reach the MPI call. Both write to a single structured log file and send a daily email summary if issues were detected. Nothing is blocked — messages continue processing; only the alert fires.

Architecture

Mirth writes
reliance_patient_ids
reliance_msg_facility_set_org
reliance_msg_changelog
→
① patient_id validation
extract_patient_ids()
extraction.py
→
② facility_set_org validation
validate_facility_set_org()
extraction.py
→
Alert log
id_quality_flags<YYYYMMDD>.log
JSON lines, one per flagged row
→
Daily email
run_notify.py
5pm cron, log attached

① patient_id validation rules

Runs in extract_patient_ids() in extraction.py for every row from reliance_patient_ids. Rows that fail are skipped — not passed to the MPI payload — and written to the alert log.

CheckFlag reasonAlert?
ID type not in MSG_METADATA_ALLOWED_ID_TYPESnot_in_allowlistSilent skip
patient_id is NULL, empty or whitespaceblank_patient_idFlagged
assigning_authority is NULL or emptyblank_assigning_authorityFlagged
Authority contains ? * / \ | < > { } [ ] ^ ~ `invalid_chars_in_authorityFlagged
ID value contains same special character setinvalid_chars_in_idFlagged
All zeros (000, 0000)unlikely_id_pattern:all_zerosFlagged
5+ repeated characters (AAAAA, 99999)unlikely_id_pattern:repeated_charactersFlagged
Single characterunlikely_id_pattern:single_characterFlagged
Over 50 charactersunlikely_id_pattern:exceeds_max_length_50Flagged
Placeholder value (null, none, unknown, test, n/a, temp)unlikely_id_pattern:placeholder_valueFlagged
First character is not alphanumericunlikely_id_pattern:starts_with_special_charFlagged
FMRN-typed row but value matches SSN pattern (NNN-NN-NNNN)ssn_shaped_fmrnFlagged

② facility_set_org validation rules

Runs in supplemental.py after fetch_facility_set_org(). An invalid value is set to None so it never reaches extract_sending_org() or the MPI call.

CheckFlag reasonTest result
patient_ids rows present but no facility_set_org row foundpatient_ids_present_but_no_facility_set_org✓ PASS
Value is NULL, empty or whitespaceblank_facility_set_org✓ PASS
Value is digits only (123456)digits_only_facility_set_org✓ PASS
Placeholder value (unknown, test, none)placeholder_facility_set_org✓ PASS
Value contains ? * \ | < > { } [ ] ^ ~ `invalid_chars_in_facility_set_org✓ PASS
Valid value (Mosaic Medical, OCHIN)— passes through✓ PASS
ENABLE_MSG_METADATA_ENRICHMENT = FalseNo log written (no-op)✓ PASS

Alert log format

One JSON line per flagged row, written to enrichment/logs/id_quality_flags<YYYYMMDD>.log. Channel info is looked up from reliance_msg_changelog by msg_id.

# patient_id issue
{"ts":"2026-10-10T03:36:59+00:00","msg_id":672031651,
 "channel_id":"49f72119-4f3e-42a8-8d6b-904fa6505006",
 "channel_name":"OCHIN - HL7v2 - 30009","mirth_instance":"zen_gemini",
 "assigning_authority":"Mosaic Medical","id_type":"mr",
 "patient_id":"???001","flag_reason":"invalid_chars_in_id"}

# facility_set_org issue
{"ts":"2026-10-10T03:36:59+00:00","msg_id":672031652,
 "channel_id":"49f72119-4f3e-42a8-8d6b-904fa6505006",
 "channel_name":"OCHIN - HL7v2 - 30009","mirth_instance":"zen_gemini",
 "failure_point":"facility_set_org","value":"123456",
 "flag_reason":"digits_only_facility_set_org"}

Log rotation

Handled entirely in extraction.py via _DailyFlagHandler — no logrotate config needed.

BehaviourDetail
Active fileid_quality_flags20261010.log — date-stamped, created only when a warning fires
No issues that dayNo file created — empty days leave no trace
RotationAt midnight, previous day's file is zipped to id_quality_flags20261009.zip if non-empty, deleted if empty
Retention28 daily zip archives kept (4 weeks); older archives purged automatically

Daily email alert

Run by cron at 5pm via enrichment/filter/run_notify.py. Only sends if issues were flagged that day — no email on clean days.

# Cron entry
0 17 * * * root /opt/tools/miniconda3/envs/python3_12/bin/python \
    /opt/tools/mpi_and_emit_v2/enrichment/filter/run_notify.py \
    >> /opt/tools/mpi_and_emit_v2/enrichment/logs/notify.log 2>&1

# Manual dry run
cd /opt/tools/mpi_and_emit_v2
python -m enrichment.filter.run_notify --dry-run

# Check a specific date
python -m enrichment.filter.run_notify --date 2026-10-09

Recipients are configured in server_config/server_config.py (stage only — not promoted to production):

ENHANCEMENT_ALERT_RECIPIENTS = [
    "interface-analyst@reliancehie.org",
    "data-ops@reliancehie.org",
]

Feedback loop — flag to fix

  1. Email arrives at 5pm with a per-channel summary table and the full log attached. The channel_name in each row identifies exactly which Mirth channel to investigate.
  2. Interface analyst opens the log and finds the flag_reason for their channel:
    • blank_patient_id / blank_assigning_authority → Mirth channel not populating the field — fix the CCD extraction mapping
    • invalid_chars_in_* → Channel sending malformed value — add a transformation rule in Mirth
    • digits_only_facility_set_org / patient_ids_present_but_no_facility_set_org → reliance_msg_facility_set_org mapping broken — fix the channel's org assignment
    • ssn_shaped_fmrn → Channel sending SSN values under an MRN type code — fix the type mapping
  3. Interface analyst fixes the Mirth channel and notifies the reviewer.
  4. Reviewer confirms the next day's log no longer contains that channel/reason combination.
  5. New edge case found — add a new pattern to _UNLIKELY_ID_PATTERNS in extraction.py and document it in this table.

Files added by this feature

FilePurpose
enrichment/msg_metadata_enrichment/extraction.pyAdded _DailyFlagHandler, _flag(), _flag_facility(), validate_facility_set_org(), fetch_mirth_channel()
enrichment/filter/run_notify.pyDaily email notifier — reads log, summarises by channel, sends via EmailHandler
server_config/server_config.pyENHANCEMENT_ALERT_RECIPIENTS — stage only, not promoted to prod

Match Quality Report — Weekly Review

Generated weekly. Example below uses prototype data — real output queries reliance_match_reconciliation and reliance_persona_quality_flags. Each chart answers one decision question.

Report 1 — Longitudinal
Time series with 7d / 14d / 30d / All selector. Phase 2 boundary drawn automatically. Six charts: match rate, rule shift, utilization, pipeline, splits, demographic rules.

Open longitudinal report →
Report 2 — Baseline vs Enrichment
Fixed baseline window earmarked by date range. Side-by-side metric comparison with delta arrows. Facility breakdown showing identifier rule share before and after enrichment.

Open baseline comparison report →

Both reports are generated by generate_match_quality_report.py and written as self-contained HTML files to enrichment/logs/reports/. The longitudinal report link above is a prototype — the generated file uses live DB data.

How to generate

cd /opt/tools/mpi_and_emit_v2
python -m enrichment.generate_match_quality_report \
    --start 2026-10-03 --end 2026-10-10 \
    --output enrichment/logs/report_20261010.txt

Example report printout

Multi-ID Match Quality Report — 2026-10-03 to 2026-10-10
Generated: 2026-10-10 17:00:02
═══════════════════════════════════════════════════════

MATCH RATE
  Messages processed       :  14,310
  Matched (PI assigned)    :   99.8%  ✓  baseline 99.8%
  Limbo rate               :    0.2%  ✓  baseline  0.2%
  Missing rule_fired       :    0.0%  ✓

ENRICHMENT SIGNAL
  IDs pushed / available   :   98.2%  ✓  target ≥ 95%
  FMRN utilized            :   22.4%  ✓  target ≥ 15%
  Identifier rules (2+7)   :   25.5%  ✓  baseline 2.9%

SPLIT SIGNALS
  persona_shrank rate      :    0.3%  ✓  baseline 0.3%
  mpid_reassigned rate     :    0.01% ✓  baseline 0.01%
  Distinct MPIDs this week :  48,210  ↓  from 48,440 (consolidating ✓)

MERGE SIGNALS
  multi_ssn_persona rate   :    0.05% ✓  baseline 0.05%
  Rule 37 volume           :    3.1%  ↓  from 4.2% ✓

PIPELINE SPLIT
  CCD — identifier rule %  :   28.3%
  HL7 — identifier rule %  :    6.1%  ⚠  lower — HL7 FMRN gap known

OVERALL STATUS: ✓ ALL TARGETS MET — no action required

Gate query — run daily

SELECT DATE(processed_at) AS day, COUNT(*) AS messages,
       ROUND(100*SUM(pi IS NOT NULL)/COUNT(*),2)                          AS matched_pct,
       ROUND(100*SUM(limbo_reason IS NOT NULL)/COUNT(*),2)                AS limbo_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(ids_pushed)/NULLIF(SUM(ids_available),0),1)          AS ids_pushed_pct,
       ROUND(100*SUM(mpid_reassigned=1)/COUNT(*),3)                       AS reassigned_pct,
       ROUND(100*SUM(rule_fired=37)/COUNT(*),2)                           AS rule37_pct
FROM warehouse.reliance_match_reconciliation
WHERE processed_at >= CURDATE() - INTERVAL 14 DAY
GROUP BY DATE(processed_at) ORDER BY day;