Skip to content

Relabel repair, a duplicate check that reads the match list, ISO dates at intake, and two re-admission and approval fixes - #1514

Merged
abrichr merged 6 commits into
mainfrom
fix/relabel-repair-duplicate-list
Oct 25, 2026
Merged

abrichr merged 6 commits into
mainfrom
fix/relabel-repair-duplicate-list

Conversation

@abrichr

@abrichr abrichr commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Fixes that cut false stops and wrong writes and make recovery after an app change work under the production configuration (Regulated, problems_only, durable, pinned). They were found and measured on local OpenEMR; the evidence is in OpenAdaptAI/OpenAdapt#1513, which is stacked on this PR.

1. Relabel repair (repair/relabel.py)

A renamed save button stopped every item until a person re-taught the step. Self-repair refuses every failed identity check, because a renamed control usually can't be told from a different one.

propose() handles the one case that can be told apart:

  • It rebuilds the recorded structured identity from the observed one, with only the label swapped back.
  • It checks that against the recorded salted hash.
  • A match proves the control id, kind and form are unchanged.

It refuses:

  • input to the halted step or to any earlier save;
  • a non-structured or parameter-bearing identity;
  • a target found below the structural rung;
  • any mismatch.

build() changes only that step's identity hash, intent and OCR text.

readmit() then:

  1. re-qualifies the candidate with the parent's campaign on the test copy;
  2. asks a person once;
  3. signs lineage, when the control is not in the record check.

A save button's identity is part of the record check, so fixed_contract_refusal correctly refuses a lineage hop. In that case readmit() returns fresh_admission: the person's approval admits the certified copy, and the deployment re-pins. Qualified and managed runs stay refused.

2. no_duplicate_candidate postcondition

OpenEMR's duplicate check lists any chart that shares a first or last name or a date of birth. The recorded program required "No matches were found.", so it stopped on any listed chart. 9 of 15 such stops in the before run shared only a first name.

The new postcondition reads the listed rows from the DOM (PlaywrightBackend.element_texts).

  • It passes when the list has loaded and no row carries the run's value of any named parameter (here lname, DOB).
  • An empty list passes only with its "no matches" marker.
  • It fails closed when the list is unreadable or the value is missing.
  • A same-name patient with a mistyped date of birth still stops.
  • A date is compared as a date. Each date the row shows (Apr 20, 1944, 20.04.1944, 04/20/1944, ISO) is read as intake reads dates, so a chart that shares only the date of birth stops the item, even under another last name. A row date that reads either way counts when either reading matches.

Linear runs now keep their params for this check (_run_params). See docs/DUPLICATE_CHECK.md.

3. Two bugs the live runs found

  • Re-qualification was always refused in the process that qualified the parent. A case run's authorization id is its run id, and a process refuses an id it has already used. readmission.requalify now passes a fresh run_id_prefix. Replaying the same authorization is still refused (test_governed_authorization).
  • A switched-off problems_only gate never asked a person. The switch check (P1) runs at the save, after the last point a person can be asked. The gate now asks there while the switch log reads off and an approval store is wired, as each_item does.

4. Dates: converted at intake when the order can be told, refused before any step otherwise

A referral that wrote the date of birth as MM/DD/YYYY was typed into the form, and OpenEMR saved today's date. The record check caught it afterwards, but a wrong chart landed, and the problems_only switch turned off for the rest of the run.

A run now refuses before any step when a ParamKind.DATE value is not a canonical ISO date, as hosted dispatch already did (runner.inputs). The OpenEMR program declares DOB as a string; the soak's typed_dob option admits the version that declares it a date.

So that this doesn't just turn a wrong save into a stop, intake now converts a date to ISO when its order can be told. It keeps the original text and the conversion in the provenance file (raw_value, conversion). The order can be told when:

  • the date is ISO already;
  • the month is spelled out;
  • it is written YYYY/MM/DD;
  • a numeric date has a part over 12 (12/25/1970), or equal parts;
  • the source's order is declared (--date-format DOB=MM/DD/YYYY).

A numeric date that reads either way with no declared format (03/04/1970), and a date that doesn't exist (1970-02-30), go to a person. Intake used to read every numeric date as MM/DD/YYYY, which silently swapped day and month for a DD/MM source. The run-time ISO check stays as the last guard.

Measured (#1513, local OpenEMR, synthetic data, cost model x1)

before after
Verified items per operator-minute 0.033 0.327
Entering every item by hand 0.333 0.333
False stops per 100 items 70.4 16.4
Silent wrong 0 0 of 30
  • Recovery from the relabel took 8.3 operator minutes and 896 s from the stop to the item done.
  • Two malformed dates of birth were saved wrong and caught by the record check; section 4 is the fix. It isn't in the measured after run.

Tests

  • New test_repair_relabel.py (10): proof; refusals (another control, another form, a field, unstructured, geometry, a step that may have written); the candidate changes only the label; a failed re-qualification asks nobody; a decline admits nothing; a fresh admission.
  • New test_no_duplicate_candidate.py (17): first-name-only hits proceed; a last name or a date of birth in any format stops; an ambiguous row date counts on either reading; unrelated or absent dates proceed; it fails closed.
  • New test_intake_dates.py (20): 12/25/1970 converts; 03/04/1970 halts without a declared format and converts with one (either order); 1970-02-30 and other invalid dates halt; the conversion is recorded as provenance; an ambiguous date needs a person. test_provenance.py's fixture date changed from the ambiguous 03/07/1991 to 03/27/1991.
  • New typed-date tests in test_program_ir_phase1.py (6): five malformed values refuse before anything is typed; an ISO date runs.
  • Two new tests in test_approval_mode_replayer.py (switched off: approve then save; no answer, no save).
  • A new run-id test in test_repair_readmission.py.
  • Locally:
    • the approval-mode, self-repair, replayer, readback, local-edit, layout-drift and capabilities-map suites pass;
    • test_layout_drift::test_real_render[kw3-font_size_changed] fails the same way on main (local font rendering);
    • ruff and mypy pass.

🤖 Generated with Claude Code

abrichr and others added 5 commits October 24, 2026 14:45
…t, fresh run ids for re-qualification

- repair/relabel.py: when a save button is renamed, prove from the recorded
  identity hash that only its label changed (same control id, kind and form),
  build a candidate that changes only that label, re-qualify it on the test
  copy, ask a person once, then re-admit by lineage, or, for a control in the
  record check, return a fresh admission for the deployment to pin.
- no_duplicate_candidate postcondition: an app's possible-duplicate list must
  have loaded and name no row with the run's value of any identity parameter
  (for example the last name or date of birth). It fails closed on an unreadable
  list, a list that hasn't loaded, or a missing value. The browser backend reads
  the rows from the DOM (element_texts).
- readmission.requalify names each re-qualification's case runs afresh. A case
  run's authorization id is its run id, and a process refuses an id it has
  already used, so re-qualifying in the process that qualified the parent was
  always refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… save

The switch check runs at the save, after the last point a person can be
asked, so once the switch turned off every later item stopped unasked and
was entered by hand. The gate now asks at that last point while the switch
log reads off, as each_item does, when an approval store is wired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and the switched-off gate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…date before any step

A referral that wrote the date of birth as MM/DD/YYYY was typed into the form
and saved as the app's guess. The record check caught it afterwards, but a
wrong chart landed and the problems_only switch turned off for the rest of
the run. A run now refuses before any step when a ParamKind.DATE value is not
a canonical ISO date, as hosted dispatch already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and records the conversion

A date of birth arrives however the sender writes dates. Intake now converts
it to ISO, with the original text and the conversion kept in the provenance
file, when the order can be told: ISO already, a spelled-out month,
YYYY/MM/DD, a numeric date with a part over 12 or equal parts, or a
numeric date whose order is declared (--date-format DOB=MM/DD/YYYY).
A numeric date that reads either way with no declared format (03/04/1970)
and a date that doesn't exist now go to a person; intake used to read every
numeric date as MM/DD/YYYY. The run's ISO check stays as the last guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abrichr abrichr changed the title Relabel repair, a duplicate check that reads the match list, and two re-admission and approval fixes Relabel repair, a duplicate check that reads the match list, ISO dates at intake, and two re-admission and approval fixes Oct 24, 2026
… the row shows

A chart that shares only the date of birth (a married-name change) must
stop the item. The rows showed the date in the app's own format, so a word
match against the run's ISO date missed 'Apr 20, 1944' or '20.04.1944'. Each
date in a row is now read the way intake reads dates and compared as a
date; a row date that reads either way counts when either reading matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abrichr
abrichr enabled auto-merge October 24, 2026 23:33
@abrichr
abrichr added this pull request to the merge queue Oct 25, 2026
abrichr added a commit that referenced this pull request Oct 25, 2026
…e only)

Qualification fault injection, consequential delivery, effect checks and
read-back, the API tier, acting by kind, settling and confirming move
verbatim into mixins (107 units; verify_moved_code finds no edits).
replayer.py goes from 18,300 to 10,926 lines.

Left in place because open pull requests change them: identity checks
(#1518, #1504), _resolve_step (#1504), postconditions and step evidence
(#1516, #1514), the starting-state settle and qualification boundary
(#1449), and the run and step drivers. Two methods that name Replayer
stay too.

Two tests patched module globals the moved code now reads from its new
module, and patch them there as well: resolve (scroll readiness is in
replay.acting, interstitials in replay.settling) and _DIALOG_WAIT_S
(replay.acting).

Checked: strict mypy on runtime/core, runtime/replay and replayer.py;
package-wide mypy; both import contracts; 3,149 tests across the 146
files that import the replayer plus the real-browser e2e files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abrichr
abrichr enabled auto-merge October 25, 2026 05:40
@abrichr
abrichr merged commit a4b1ecc into main Oct 25, 2026
39 checks passed
@abrichr
abrichr deleted the fix/relabel-repair-duplicate-list branch October 25, 2026 05:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant