From 7e263e054ff49e1de977ddecbf43553232080cc2 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 24 Oct 2026 14:45:31 -0400 Subject: [PATCH 1/6] fix(flow): relabel repair, a duplicate check that reads the match list, 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 --- packages/flow/docs/DUPLICATE_CHECK.md | 61 ++ packages/flow/docs/SELF_REPAIR.md | 50 ++ .../backends/playwright_backend.py | 20 + packages/flow/openadapt_flow/ir.py | 20 + .../flow/openadapt_flow/repair/readmission.py | 7 + .../flow/openadapt_flow/repair/relabel.py | 563 ++++++++++++++++++ .../flow/openadapt_flow/runtime/replayer.py | 53 ++ .../flow/tests/test_no_duplicate_candidate.py | 107 ++++ .../flow/tests/test_repair_readmission.py | 48 +- packages/flow/tests/test_repair_relabel.py | 389 ++++++++++++ 10 files changed, 1308 insertions(+), 10 deletions(-) create mode 100644 packages/flow/docs/DUPLICATE_CHECK.md create mode 100644 packages/flow/openadapt_flow/repair/relabel.py create mode 100644 packages/flow/tests/test_no_duplicate_candidate.py create mode 100644 packages/flow/tests/test_repair_relabel.py diff --git a/packages/flow/docs/DUPLICATE_CHECK.md b/packages/flow/docs/DUPLICATE_CHECK.md new file mode 100644 index 000000000..61e151467 --- /dev/null +++ b/packages/flow/docs/DUPLICATE_CHECK.md @@ -0,0 +1,61 @@ +# A duplicate check that reads the match list + +Many registration forms run a duplicate search before they save. OpenEMR's New +Patient form, for example, opens a list of existing charts that might be the +same person. A recorded program usually expects the empty list ("No matches +were found."), so any listed chart stops the item before the save. + +OpenEMR lists a chart that shares only a first name with the new patient. On +the lifecycle soak's synthetic worklist, most of those stops were false: the +listed chart was clearly someone else, and a person entered the item by hand. + +## The postcondition + +`no_duplicate_candidate` replaces the "empty list" check on the step that +opens the search: + +```json +{ + "kind": "no_duplicate_candidate", + "text": "No matches were found.", + "candidate_rows": "tr.oneresult", + "candidate_frame_path": ["#modalframe"], + "candidate_params": ["lname", "DOB"], + "timeout_s": 10.0 +} +``` + +It passes only when the list has loaded and names no real candidate: + +- A listed row is a **candidate** when it contains every word of this run's + value of any `candidate_params` parameter (case-insensitive). With the + example above, a chart with the same last name or the same date of birth + stops the item. A chart that shares only a first name doesn't. +- An **empty list** passes only when its "no matches" marker (`text`) is + shown, so a list that hasn't loaded yet never passes. +- It **fails closed**: an unreadable list (frame missing or ambiguous, or a + backend that can't read rows), a missing or empty parameter value, or any + candidate row all fail the step before anything is saved. + +The rule only lets an item continue past rows that share none of the identity +values. A same-name patient with a mistyped date of birth still stops, and a +person decides. The rows are read from the DOM (`element_texts` on the browser +backend), never from OCR. + +## Changing a program to use it + +The compiler doesn't propose this postcondition yet. Changing it is a program +change, so the program needs a fresh qualification and admission. The +lifecycle soak's `production_arm.with_duplicate_list_check` makes the change +on the OpenEMR referral program, and the soak admits that version afresh +(`"duplicate_check": true`). + +## Limits + +- The list must render its rows together, as OpenEMR's server-rendered list + does. A list that streams rows in one by one could pass on a partial list. +- A value is recognized only in the form the run typed it. If the app shows a + value in another form (a date as `09/09/1990` for `1990-09-09`), the check + misses that candidate and lets the item continue. Name only parameters the + list shows exactly as typed, and check this on the real list before + admitting the program. OpenEMR's list shows the date of birth as typed. diff --git a/packages/flow/docs/SELF_REPAIR.md b/packages/flow/docs/SELF_REPAIR.md index 543c3e86e..7e4521f74 100644 --- a/packages/flow/docs/SELF_REPAIR.md +++ b/packages/flow/docs/SELF_REPAIR.md @@ -404,6 +404,56 @@ self-repair runs without a named profile or under Demo, and the record check still runs. The Python API can run the Standard profile contract directly, as the tutorial does. +### A relabeled control: proof that only the label changed + +Self-repair never repairs a stop on a failed identity check, because a +renamed control usually can't be told from a different control. One case can +be told apart. When an app release renames a button, the ladder finds the same +control on the structural rung, and the run stops because the control's +structured identity (its id, kind, form and label) no longer matches. + +The recorded identity is a salted hash, but the bundle also keeps the recorded +label in plaintext (the step's intent and OCR text). +`openadapt_flow/repair/relabel.py` rebuilds the recorded identity from the +observed one with only the label swapped back. If that matches the recorded +hash, the id, kind and form are proven unchanged and only the label differs. +It refuses everything else: + +- a step that received input, or any earlier save that did (a step that may + have written is never retried); +- an identity check that failed outside the structured tier, or one that names + a record value rather than a control; +- a target found below the structural rung; +- an identity that doesn't match under any recorded label. + +The repair then follows the pinned-bundle path above: + +1. The candidate bundle changes only that step's identity hash, intent and + OCR text. +2. It is re-qualified with the parent's own campaign on the test copy, which + now runs the new release. Each re-qualification names its case runs + afresh, because a process refuses a run's authorization id once it has + been used, and the parent may have been qualified in the same process. +3. A person approves once, with the old and new label and the + re-qualification in front of them. +4. The certified copy runs next: + - For a control that is not part of the record check, it is re-admitted + with signed lineage, and the pin accepts it through the chain. + - A save button's identity is part of the record check, so no lineage hop + may carry it (`fixed_contract_refusal`). The person's approval is then a + fresh admission of the certified copy, and the deployment pins its new + digest. `readmit` returns `fresh_admission` and never re-pins by itself. + +The stopped item never wrote, so it runs again on the re-admitted version as a +retry; `problems_only` sends a retry to a person. Under a production +qualification admission or a managed dispatch the repair stays refused, as for +every other repair. + +On the local OpenEMR referral (proof-15's configuration), a relabeled save +button stopped every item until a person re-taught the step. The lifecycle +soak measures this path: see +the lifecycle soak (`benchmark/lifecycle`). + ## What is recorded | Where | What | diff --git a/packages/flow/openadapt_flow/backends/playwright_backend.py b/packages/flow/openadapt_flow/backends/playwright_backend.py index 8e95edacb..5778c218b 100644 --- a/packages/flow/openadapt_flow/backends/playwright_backend.py +++ b/packages/flow/openadapt_flow/backends/playwright_backend.py @@ -1691,6 +1691,26 @@ def focused_element_matches(self, selector: str) -> Optional[bool]: ) return result if isinstance(result, bool) else None + def element_texts( + self, selector: str, frame_path: tuple[str, ...] = () + ) -> Optional[list[str]]: + """The rendered text of every element matching ``selector``. + + Read in the frame ``frame_path`` names (outermost first). ``None`` + when the frame is missing or ambiguous, or the read fails; an empty + list means the frame is there and nothing matches. Read-only; never + raises. + """ + + try: + scope = self._resolve_scope(tuple(frame_path)) + if scope is None: + return None + texts = scope.locator(selector).all_inner_texts() + except Exception: # noqa: BLE001 - unreadable is None + return None + return [str(text) for text in texts] + def focused_select_label(self) -> Optional[str]: """The focused ``