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
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.
| Metric | Run 1 — flag off (baseline) | Run 2 — flag on (Multi-ID) | What it means |
|---|---|---|---|
| Messages compared | 2,139 (same messages in both runs) | Apples-to-apples intersection | |
| FMRNs pushed into matching payload | 0 | 2,628 | Multi-ID supplied identifiers the MPI never saw before |
| Messages where FMRN drove the match | 95 (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,369 | No 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
| Metric | Phase 1 baseline | Phase 2 target |
|---|---|---|
| IDs pushed / IDs available | 0 (flag off) | ≥ 95% |
| FMRN-utilized share | Measure in Phase 1 | ≥ 15% |
| Identifier rule (2 + 7) share | Measure in Phase 1 | ≥ 15% |
| Persona changes on pre-existing patients | 0 | 0 |
| Match rate regression | Phase 1 baseline | ≤ Phase 1 |
| Missing rule_fired on success | ≤ 1% | ≤ 1% (retry fix deployed) |
| Seconds per record | Measure in Phase 1 | ≤ 10% above Phase 1 |
What Multi-ID does not fix
- Weak-demographic merges when no FMRN exists — harness F_02b confirmed a mis-merge on name, DOB, and phone under rule 9.
- Silent SSN conflicts — harness D_01b merged an SSN match with a DOB more than a year off with no visible alert.
- MI extraction gap on SF2 CCD path — R_01, F_01a, M_05a all landed one tier low, likely MI not reaching the payload.
- HL7 FMRN recognition — X_01a with full identifiers landed on rule 7, suggesting the FMRN is not recognized on the HL7 path.
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.
•
reliance_patient_ids — the additive facility MRNs extracted from the message•
reliance_msg_facility_set_org — the org identifier used to control facility set creationenhanced_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 matchingsupplemental.pyissue_mpi_call()store 1100
outcome.pyrollback.pyGitLab: mpi_and_emit_v2 (enhancement_id)
① Pre-match enrichment — supplemental.py
Runs when ENABLE_MSG_METADATA_ENRICHMENT = True, before issue_mpi_call().
- Opens a warehouse connection (
metadata_conn, separate from the main connection). - Queries
reliance_patient_idsbymsg_id→ a list of(assigning_authority, type, patient_id)rows. - Filters by
MSG_METADATA_ALLOWED_ID_TYPES— currentlymrn / mr / fmrn → fmrn. SSN excluded by decision. - 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. - Queries
reliance_msg_facility_set_orgbymsg_id→ setssending_org_idin 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.
- Calls
_fetch_pi_rule_with_retry(pi): polls store 1100 every 1 s for up to 10 s untilrule_firedandmatch_typeappear. Store 1100 indexes a PI asynchronously, so a single immediate read races the index. - Calls
evaluate_fmrn_utilization()to setfmrn_utilizedandfmrn_available_unused. - Upserts one row into
warehouse.reliance_match_reconciliationwith 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.
- Writes one row to
utilities.rollback_pipeline: msg_id, pi (e.g.52309187.PI), mpid, imat_dest, psuri. - Reverse is via
rollback_script.py --msg-id <id>, which strips the.PIsuffix and callsextensions/mpi/transactions/delete.
What enrichment does NOT change
| Unchanged | Why |
|---|---|
| Native FHIR field parsing | Enrichment only appends identifiers; it never removes fields the original parse set |
issue_mpi_call() itself | The MPI call is identical; enrichment only changes the payload that goes in |
reliance_patient_ids | Read-only; Mirth owns writes to this table |
| Emit / indexing steps | Downstream 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.
| Flag | Default | Effect when True | Phase on |
|---|---|---|---|
ENABLE_MSG_METADATA_ENRICHMENT | False | Pre-match FMRN injection from relational tables | Phase 2 |
ENABLE_MATCH_QUALITY_EVAL | False | Post-match store 1100 lookup + reconciliation write | Phase 1 |
ENABLE_ROLLBACK | False | Capture PI + source URI for per-message rollback | Phase 1 |
Tuning settings
| Setting | Default | Effect |
|---|---|---|
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_S | 10 | Max seconds to poll store 1100 for rule_fired after a match |
PI_RULE_LOOKUP_INTERVAL_S | 1 | Seconds between store 1100 poll attempts |
ROLLBACK_DB_NAME | "utilities" | DB holding rollback_pipeline; must differ from warehouse |
FHIR_FACILITY_ID_CAP | 50 | Maximum 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)
| Table | DB | Key columns | What it stores |
|---|---|---|---|
reliance_patient_ids | warehouse | id PK, msg_id FK+IDX, assigning_authority, type, patient_id | One row per identifier Mirth extracted from each message. Multiple rows per msg_id when a CCD carries multiple MRNs. |
reliance_msg_facility_set_org | warehouse | msg_id PK+FK, facility_set_org | The facility/org Mirth attributed to the sending organization for this message. |
reliance_msg_changelog | warehouse | msg_id PK+FK, changelog, facility_set_org, filename_gen IDX | Mirth audit log. Not read by the enrichment code. |
MessageStore | warehouse | MsgId PK, MsgContent, MsgSource, MsgReceived | Core 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)
| Table | DB | Key columns | What it stores |
|---|---|---|---|
reliance_match_reconciliation | warehouse | msg_id PK+FK, 37 further columns | One 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_flags | warehouse | mpid 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_pipeline | utilities | id PK AUTO_INCREMENT, msg_id UNIQUE, pi IDX | One 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)
| Table | DB | What it stores |
|---|---|---|
pressure_test_snapshot | utilities | Copies of reconciliation rows per pressure-test run label, so the comparison survives the rollback between runs. |
Migration status
| Migration | Target DB | Status |
|---|---|---|
009_persona_quality_flags.sql | warehouse | Applied on stage |
010_rollback_pipeline.sql | utilities (also creates DB) | Applied on stage |
011_pressure_test_snapshot.sql | utilities | Stage 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.
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)
| Action | Tables written |
|---|---|
| CCD / HL7 channel processing | reliance_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)
| Table | Grant | Reason |
|---|---|---|
warehouse.MessageStore | SELECT | Read MsgSource, MsgReceived for reconciliation |
warehouse.reliance_patient_ids | SELECT | Read-only; Mirth owns writes |
warehouse.reliance_msg_facility_set_org | SELECT | Read-only; Mirth owns writes |
warehouse.reliance_msg_changelog | SELECT | Read-only; Mirth owns writes |
warehouse.reliance_match_reconciliation | SELECT, INSERT, UPDATE, DELETE | Enrichment writes and rollback deletes |
warehouse.reliance_persona_quality_flags | SELECT, INSERT, UPDATE, DELETE | Backfill writes persona snapshots |
utilities.rollback_pipeline | SELECT, INSERT, UPDATE, DELETE | Worker inserts; rollback_script marks complete |
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:
extensions/mpi/transactions/delete— to remove PI records from store 1100/cache/deleterecord— to remove source records from all search stores- Read access to store 1100 and store 1101 (to verify cleanup)
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.
| Phase | Enrichment | Quality Eval | Rollback | Duration | Exit 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.
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
| Level | Action | Takes effect | Use when |
|---|---|---|---|
| 1: Flag rollback | ENABLE_MSG_METADATA_ENRICHMENT = False | ~1 min | Any Phase 2 gate breach |
| 2: Full disable | All three flags False | ~1 min | Worker errors or slowdown from the release's own lookups |
| 3: Code revert | Redeploy tagged pre-release worker | Minutes | Level 2 does not restore normal behavior |
| 4: Data rollback | rollback_script.py --msg-id <id> then re-feed | Per message | Confirmed 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.
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
| Date | MPIDs processed | Duration (approx) |
|---|---|---|
| 2026-09-14 | 253 | ~5 min |
| 2026-09-15 | 2,529 | ~48 min |
| 2026-09-17 | 2,728 | ~38 min |
| 2026-09-21 | 2,116 | ~67 min |
| 2026-09-23 | 582 | ~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
| # | Item | Why it blocks |
|---|---|---|
| 1 | IMAT review and approval of the enricher_id branch | Changes the MPI submission path |
| 2 | Migrations 009 and 010 applied; prod reliance_match_reconciliation matches the stage schema (38 columns) | Reconciliation, persona and rollback writes fail without them |
| 3 | Every prod channel populates reliance_patient_ids and reliance_msg_facility_set_org | Without rows, enrichment silently adds nothing |
| 4 | reliance@'%' grants applied and prod credentials file verified as chmod 600 | Connections fail; every path degrades to off |
| 5 | Source deployed from enricher_id branch (not compiled zip); __pycache__ cleared; import check passes under prod interpreter | Stale compiled files or wrong Python can run old code silently |
| 6 | run_persona_backfill.py restored on disk; cron wrapped in flock | Phase 1 baselines depend on it; the stage cron is failing now |
| 7 | Production guard in rollback_script.py: refuse to run bare without --msg-id or an explicit list outside stage | Bare mode processes every pending row — up to --limit live patient records deleted |
| 8 | Rollback 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 |
| 9 | Persona-safety check on the pressure test intersection returns changed = 0 | Key safety claim currently rests on sampled rows |
| 10 | All three flags confirmed off in prod settings.py | Deploy must change nothing until Phase 1 |
Open Questions
Items marked with a status reflect answers received so far.
| Question | Status | Impact |
|---|---|---|
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? | • CR timing: What is the expected turnaround from CR submission to approval?
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.
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.
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
| Rule | Fields | Role in Multi-ID |
|---|---|---|
| 2 | FMRN · LN · FN · MI · DOB · gender · street_zip | Primary upgrade target — absorbs rule 16 transitions (149 messages). Full-strength identifier match with address. |
| 7 | FMRN · LN · FN · DOB · gender · street_zip | Secondary upgrade target — absorbs rule 20 transitions (51 messages). Same as rule 2 without MI. |
| 14 | FMRN · LN · FN · MI · DOB · gender | FMRN-anchored without address. Harness R_02 confirmed. |
| 18 | FMRN · LN · FN · DOB · gender | Primary supplemental-FMRN consolidation rule. Harness F_04b, M_02b confirmed. |
| 16 | LN · FN · MI · DOB · gender · street_zip | Demographic fallback — ~30% of Run 1 messages. Multi-ID promotes to rule 2. |
| 20 | LN · FN · DOB · gender · PH · street_zip | Demographic fallback — Multi-ID promotes to rule 7. |
| 34 | LN · FN · DOB · gender · street_zip | Demographic fallback — same as 16 without MI. |
| 37 | LN · FN · DOB · PH | Mis-merge confirmed — F_02b: two distinct patients merged. Candidate for tightening. |
| 9 | FMRN · LN · FN · DOB · gender · PH | FMRN-anchored with phone. Fired in M_05b. FMRN present — not the same weakness as rule 37. |
| 6 | FMRN · LN · FN · DOB · gender · SSN | Fired uniformly across M_06–M_12. Likely test-environment contamination, not a rule defect. |
| 0 | Exact 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) | Fields | To (FMRN-anchored) | Fields added | Messages |
|---|---|---|---|---|
| Rule 16 | LN FN MI DOB gender street_zip | Rule 2 | + FMRN | 149 |
| Rule 20 | LN FN DOB gender PH street_zip | Rule 7 | + FMRN (− PH) | 51 |
| Rule 0 | Exact re-match | Rule 7 | Payload changed by FMRN injection | 52 |
| Rule 0 | Exact re-match | Rule 2 | Payload changed by FMRN injection | 52 |
| Rule 17 | LN FN MI DOB gender PH | Rule 2 | + FMRN (− PH + street_zip) | 21 |
| Rule 37 | LN FN DOB PH | Rule 7 | + FMRN + gender + street_zip | 20 |
| Rule 34 | LN FN DOB gender street_zip | Rule 7 | + FMRN | 9 |
Full rule listing
| Rule | FMRN | Fields 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
| Item | Detail |
|---|---|
| Generated by | python -m enrichment.generate_match_quality_report — run manually or scheduled |
| Reviewer | To be assigned — open item for the team before Phase 1 |
| Cadence | Weekly from Phase 1 onward; daily gate query run by the deploy owner during Phase 2 ramp |
| What to look for | Identifier 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 off | Reviewer 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:
| Signal | Where to look | Who acts |
|---|---|---|
High ids_available = 0 rate for a channel | Gate query: SUM(ids_available = 0) grouped by sending_facility in reliance_match_reconciliation | Reliance data ops — investigate whether the Mirth channel is writing to reliance_patient_ids |
| FMRN-utilized rate below 15% after Phase 2 enable | Gate query: fmrn_utilized_pct column | Reliance data ops — check which facilities are contributing messages without supplemental IDs |
Blank assigning_authority or patient_id in reliance_patient_ids | SELECT * FROM reliance_patient_ids WHERE patient_id IS NULL OR patient_id = '' LIMIT 50 | Mirth channel owner — blank values mean the CCD parser didn't extract the ID field; fix the channel mapping |
Wrong type value not in the allowlist | SELECT type, COUNT(*) FROM reliance_patient_ids GROUP BY type ORDER BY COUNT(*) DESC | Reliance 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
- Reviewer identifies an issue in the weekly report or gate query — e.g. a facility showing
ids_available = 0for all its messages, or a spike inmpid_reassigned. - 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 inreliance_match_reconciliation). - 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_idscorrectly on the next run. - 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.
- Verify: Check the next weekly report to confirm the issue is resolved and metrics have returned to baseline.
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.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
| Category | Rule | Flag reason code |
|---|---|---|
| Blocked type | ID type is ssn, ss, or social — SSN excluded from enrichment by decision | blocked_type:ssn |
| Blank value | patient_id is NULL, empty, or whitespace only | blank_patient_id |
| Blank authority | assigning_authority is NULL or empty — no facility to attribute the ID to | blank_assigning_authority |
| Invalid chars in facility | assigning_authority contains . / ? * \ | < > { } [ ] ^ ~ ` | invalid_chars_in_authority |
| Invalid chars in ID | patient_id contains the same special character set | invalid_chars_in_id |
| All zeros | patient_id is 000, 0000, etc. | unlikely_id_pattern |
| Repeated characters | 5+ of the same character e.g. 99999, AAAAA | unlikely_id_pattern |
| Single character | patient_id is exactly 1 character | unlikely_id_pattern |
| Exceeds column length | patient_id is over 50 characters (exceeds the varchar(50) DB column) | unlikely_id_pattern |
| Placeholder value | patient_id matches null, none, unknown, test, n/a, na, temp (case-insensitive) | unlikely_id_pattern |
| Starts with special character | patient_id first character is not alphanumeric | unlikely_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
- Reviewer runs the flag query weekly. Any
flag_reason+sending_facilitycombination withunreviewed > 0is a candidate for investigation. - Classify the flag:
blank_patient_idorblank_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_authorityorinvalid_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.
- Interface analyst investigates the sending facility's CCD structure and Mirth channel mapping, corrects the extraction, and notifies the reviewer.
- 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.
- 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_PATTERNSlist inextraction.pyand 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
}
Maintenance ownership — to be assigned
| Responsibility | Assigned to |
|---|---|
| Run weekly match quality report and ID quality flag review | TBD |
| Review report and approve Phase 3 rollback-capture shutdown | TBD |
| First responder for ID quality issues — interface analyst contact | TBD per sending facility |
First responder for MSG_METADATA_ALLOWED_ID_TYPES or validation rule changes | TBD — Reliance data ops |
| Escalation contact at IMAT for rule or store 1100 issues | TBD — 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
reliance_patient_idsreliance_msg_facility_set_orgreliance_msg_changelogextract_patient_ids()extraction.pyvalidate_facility_set_org()extraction.pyid_quality_flags<YYYYMMDD>.logJSON lines, one per flagged row
run_notify.py5pm 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.
| Check | Flag reason | Alert? |
|---|---|---|
ID type not in MSG_METADATA_ALLOWED_ID_TYPES | not_in_allowlist | Silent skip |
patient_id is NULL, empty or whitespace | blank_patient_id | Flagged |
assigning_authority is NULL or empty | blank_assigning_authority | Flagged |
Authority contains ? * / \ | < > { } [ ] ^ ~ ` | invalid_chars_in_authority | Flagged |
| ID value contains same special character set | invalid_chars_in_id | Flagged |
All zeros (000, 0000) | unlikely_id_pattern:all_zeros | Flagged |
5+ repeated characters (AAAAA, 99999) | unlikely_id_pattern:repeated_characters | Flagged |
| Single character | unlikely_id_pattern:single_character | Flagged |
| Over 50 characters | unlikely_id_pattern:exceeds_max_length_50 | Flagged |
Placeholder value (null, none, unknown, test, n/a, temp) | unlikely_id_pattern:placeholder_value | Flagged |
| First character is not alphanumeric | unlikely_id_pattern:starts_with_special_char | Flagged |
FMRN-typed row but value matches SSN pattern (NNN-NN-NNNN) | ssn_shaped_fmrn | Flagged |
② 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.
| Check | Flag reason | Test result |
|---|---|---|
patient_ids rows present but no facility_set_org row found | patient_ids_present_but_no_facility_set_org | ✓ PASS |
| Value is NULL, empty or whitespace | blank_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 = False | No 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.
| Behaviour | Detail |
|---|---|
| Active file | id_quality_flags20261010.log — date-stamped, created only when a warning fires |
| No issues that day | No file created — empty days leave no trace |
| Rotation | At midnight, previous day's file is zipped to id_quality_flags20261009.zip if non-empty, deleted if empty |
| Retention | 28 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
- Email arrives at 5pm with a per-channel summary table and the full log attached. The
channel_namein each row identifies exactly which Mirth channel to investigate. - Interface analyst opens the log and finds the
flag_reasonfor their channel:blank_patient_id/blank_assigning_authority→ Mirth channel not populating the field — fix the CCD extraction mappinginvalid_chars_in_*→ Channel sending malformed value — add a transformation rule in Mirthdigits_only_facility_set_org/patient_ids_present_but_no_facility_set_org→reliance_msg_facility_set_orgmapping broken — fix the channel's org assignmentssn_shaped_fmrn→ Channel sending SSN values under an MRN type code — fix the type mapping
- Interface analyst fixes the Mirth channel and notifies the reviewer.
- Reviewer confirms the next day's log no longer contains that channel/reason combination.
- New edge case found — add a new pattern to
_UNLIKELY_ID_PATTERNSinextraction.pyand document it in this table.
Files added by this feature
| File | Purpose |
|---|---|
enrichment/msg_metadata_enrichment/extraction.py | Added _DailyFlagHandler, _flag(), _flag_facility(), validate_facility_set_org(), fetch_mirth_channel() |
enrichment/filter/run_notify.py | Daily email notifier — reads log, summarises by channel, sends via EmailHandler |
server_config/server_config.py | ENHANCEMENT_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.
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 →
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;