diff --git a/packages/flow/openadapt_flow/risk.py b/packages/flow/openadapt_flow/risk.py index 55e574311..d128bd0ce 100644 --- a/packages/flow/openadapt_flow/risk.py +++ b/packages/flow/openadapt_flow/risk.py @@ -610,6 +610,41 @@ def opener_explanation(confirm_id: str) -> str: return f"opens a confirmation dialog; the save is the next click ({confirm_id})" +_OPENER_EXPLANATION_RE = re.compile( + r"^opens a confirmation dialog; the save is the next click " + r"\(([A-Za-z0-9][A-Za-z0-9_.-]{0,63})\)$" +) + + +def is_sealed_confirmation_opener(step: Step) -> bool: + """A confirmation-dialog opener the compiler sealed with dialog evidence. + + Policy re-derives risk from labels, so a write-shaped opener label such as + "Create New Patient" would otherwise become irreversible again and demand + a system-of-record effect on a click that saves nothing. Both halves of the + seal are required: the explanation that names the confirm step, and a + postcondition that the dialog opened (``text_present`` or + ``no_duplicate_candidate``). Without the postcondition the run would treat + a missing dialog as a successful save. + """ + + if step.action not in _POINTER_ACTIONS: + return False + if step.effects or step.api_binding is not None: + return False + if _OPENER_EXPLANATION_RE.fullmatch(step.risk_explanation or "") is None: + return False + return any( + ( + p.kind is PostconditionKind.TEXT_PRESENT + and isinstance(p.text, str) + and bool(p.text.strip()) + ) + or p.kind is PostconditionKind.NO_DUPLICATE_CANDIDATE + for p in step.expect + ) + + def compiled_confirmation_pairs( steps: Iterable[Step], ) -> tuple[tuple[str, str], ...]: @@ -842,6 +877,12 @@ def infer_step_risk( (step.risk_explanation or LOCAL_EDIT_PREFIX)[:512], (action_evidence, "pattern:local_edit"), ) + if write_keyword is not None and is_sealed_confirmation_opener(step): + return RiskInference( + "reversible", + (step.risk_explanation or "")[:512], + (action_evidence, "pattern:confirmation_opener"), + ) if write_keyword is not None and step.action in { ActionKind.CLICK, ActionKind.DOUBLE_CLICK, diff --git a/packages/flow/tests/test_confirmation_opener.py b/packages/flow/tests/test_confirmation_opener.py index ff4bc3535..48aca7e5f 100644 --- a/packages/flow/tests/test_confirmation_opener.py +++ b/packages/flow/tests/test_confirmation_opener.py @@ -4,8 +4,21 @@ import pytest -from openadapt_flow.ir import ActionKind, Anchor, Step, StructuralLocator -from openadapt_flow.risk import confirmation_opener_step_ids, infer_step_risk +from openadapt_flow.ir import ( + ActionKind, + Anchor, + Postcondition, + PostconditionKind, + Step, + StructuralLocator, +) +from openadapt_flow.policy import effective_step_risk, step_tags +from openadapt_flow.risk import ( + confirmation_opener_step_ids, + infer_step_risk, + is_sealed_confirmation_opener, + opener_explanation, +) def _click(step_id: str, label: str, selector: str = "button") -> Step: @@ -71,3 +84,50 @@ def test_an_opener_with_its_own_effect_or_binding_stays_the_save(): def test_two_confirms_in_a_row_keep_the_first(): steps = [_click("a", "Confirm"), _click("b", "Confirm")] assert confirmation_opener_step_ids(steps) == () + + +def _seal_opener(opener: Step, confirm_id: str, dialog_text: str) -> Step: + opener.risk = "reversible" + opener.risk_explanation = opener_explanation(confirm_id) + opener.risk_review_required = False + opener.expect = [ + Postcondition(kind=PostconditionKind.TEXT_PRESENT, text=dialog_text) + ] + return opener + + +def test_sealed_opener_stays_reversible_under_policy_rederive(): + """Policy re-runs classify_step_risk; sealed opener evidence must win.""" + + opener = _seal_opener( + _click("step_039", "Create New Patient", "#create"), + "step_040", + "Confirm Create New Patient", + ) + assert is_sealed_confirmation_opener(opener) + assert infer_step_risk(opener).risk == "reversible" + assert effective_step_risk(opener) == "reversible" + assert "write" not in step_tags(opener) + + +def test_explanation_alone_is_not_a_seal(): + """Without a dialog postcondition, write-shaped labels stay irreversible.""" + + opener = _click("step_039", "Create New Patient", "#create") + opener.risk = "reversible" + opener.risk_explanation = opener_explanation("step_040") + assert not is_sealed_confirmation_opener(opener) + assert infer_step_risk(opener).risk == "irreversible" + assert effective_step_risk(opener) == "irreversible" + assert "write" in step_tags(opener) + + +def test_sealed_opener_with_own_effect_stays_a_write(): + opener = _seal_opener( + _click("a", "Create New Patient"), + "b", + "Confirm", + ) + opener.effects = [object()] # type: ignore[list-item] + assert not is_sealed_confirmation_opener(opener) + assert infer_step_risk(opener).risk == "irreversible"