diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 20e8806f6..4376ed1a6 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -182,6 +182,7 @@ improvising or retrying when something is off. | Capture a recording's network traffic and compile a clean save into an API step, with the same record check | In review ([#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269)) | Synthetic patients on a local OpenEMR test copy | network capture, `ApiBinding` | | Fall back from the API step to page structure when the API is unavailable, before anything is sent | In review ([#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269)) | Synthetic | [#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269) | | Approve an API step's exact request under per-item approval: one digest binds the request bytes, the record and the record check, and a changed request is not sent | In review ([#1355](https://github.com/OpenAdaptAI/OpenAdapt/pull/1355)) | Synthetic | [API_TIER.md](packages/flow/docs/API_TIER.md#under-an-approval-mode) | +| Run an API step in a durable run: checkpoint the span after the save is sent, never resend or redo a save that may have landed, and reconcile it by reading the record back | In review ([#1358](https://github.com/OpenAdaptAI/OpenAdapt/pull/1358)) | Synthetic | [API_TIER.md](packages/flow/docs/API_TIER.md#in-a-durable-run) | | Run a worklist on several workers at once, one signed-in session each, each item claimed once, with a stop on any worker stopping all | In review ([#1296](https://github.com/OpenAdaptAI/OpenAdapt/pull/1296)) | Synthetic patients on a local OpenEMR test copy | `--workers`, [THROUGHPUT.md](packages/flow/docs/THROUGHPUT.md) | | Read a save back while the next item runs (pipelined read-back, REST, FHIR, GraphQL or SQL record checks) | In review ([#1296](https://github.com/OpenAdaptAI/OpenAdapt/pull/1296)) | Synthetic | `--readback-window`, [THROUGHPUT.md](packages/flow/docs/THROUGHPUT.md) | | A faster speed profile that keeps every check (one insert per plain text field, shorter page-quiet wait) | In review ([#1296](https://github.com/OpenAdaptAI/OpenAdapt/pull/1296)) | Synthetic | `runtime.speed: fast`, [THROUGHPUT.md](packages/flow/docs/THROUGHPUT.md) | diff --git a/llms-full.txt b/llms-full.txt index a2cf209b6..9e97777a5 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -538,6 +538,7 @@ improvising or retrying when something is off. | Capture a recording's network traffic and compile a clean save into an API step, with the same record check | In review ([#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269)) | Synthetic patients on a local OpenEMR test copy | network capture, `ApiBinding` | | Fall back from the API step to page structure when the API is unavailable, before anything is sent | In review ([#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269)) | Synthetic | [#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269) | | Approve an API step's exact request under per-item approval: one digest binds the request bytes, the record and the record check, and a changed request is not sent | In review ([#1355](https://github.com/OpenAdaptAI/OpenAdapt/pull/1355)) | Synthetic | [API_TIER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/API_TIER.md#under-an-approval-mode) | +| Run an API step in a durable run: checkpoint the span after the save is sent, never resend or redo a save that may have landed, and reconcile it by reading the record back | In review ([#1358](https://github.com/OpenAdaptAI/OpenAdapt/pull/1358)) | Synthetic | [API_TIER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/API_TIER.md#in-a-durable-run) | | Run a worklist on several workers at once, one signed-in session each, each item claimed once, with a stop on any worker stopping all | In review ([#1296](https://github.com/OpenAdaptAI/OpenAdapt/pull/1296)) | Synthetic patients on a local OpenEMR test copy | `--workers`, [THROUGHPUT.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/THROUGHPUT.md) | | Read a save back while the next item runs (pipelined read-back, REST, FHIR, GraphQL or SQL record checks) | In review ([#1296](https://github.com/OpenAdaptAI/OpenAdapt/pull/1296)) | Synthetic | `--readback-window`, [THROUGHPUT.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/THROUGHPUT.md) | | A faster speed profile that keeps every check (one insert per plain text field, shorter page-quiet wait) | In review ([#1296](https://github.com/OpenAdaptAI/OpenAdapt/pull/1296)) | Synthetic | `runtime.speed: fast`, [THROUGHPUT.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/THROUGHPUT.md) | diff --git a/packages/flow/docs/API_TIER.md b/packages/flow/docs/API_TIER.md index 560c78a0a..c245cdc4c 100644 --- a/packages/flow/docs/API_TIER.md +++ b/packages/flow/docs/API_TIER.md @@ -133,6 +133,38 @@ An application that issues a new session value on every form read can't be approved this way: the second read changes the digest, and the item stops before saving. +## In a durable run + +A durable run (`--durable`) uses the API step too. The checkpoints and the +resume rules keep the no-double-write rule. (Standard and Regulated runs are +durable and governed; the governed exclusion below still applies to them.) + +- **Before the span**, the checkpoint is the last step before the steps the + API step covers. +- **After the span**, the covered steps (`api_folded`) and the save (`api`) + are checkpointed together, once the save was sent. If the save wasn't + sent (for example, the record check's pre-state couldn't be read, or an + approval stopped the item), nothing in the span is checkpointed. A resume + then runs the whole API step again from the start: it prepares the + request again, asks again under an approval mode, and sends it once. +- **After a send that may have landed** (a lost response, a rejected + response, or a save the record check didn't confirm), the pause is at the + save and the run reads `RECONCILIATION_REQUIRED` when the outcome is + unknown. Resume never sends that save again and never redoes it through + the GUI on an empty form. An ordinary resume is refused, as for any + uncertain delivery. A resume that authorizes one uncertain retry stops at + the save with that reason. The attended `reconcile` action reads the record + back with the record check and finishes the run when the record is there, + without sending anything. +- A run that fell back to the GUI steps and stopped at the GUI save resumes + that GUI save, as before. + +Reconcile reads the record as it is now. A record check that compares with +the state before the save (`exact_new_set`, `count_new_only` or a +collateral-loss guard) can't be proven that way after the fact, so the pause +stays for a person to check the record. The same limit applies to a GUI +save. + ## When it doesn't run The API step runs only in a plain linear run. It doesn't run, and the GUI @@ -140,9 +172,11 @@ steps run instead, when: - an approval mode is on and the API actuator can't prepare the request before sending it (a deployment's own actuator without `prepare`); -- the run is governed by qualification, is durable, or is resumed; +- the run is governed by qualification; - the step isn't admitted, or the program's steps, on-screen checks or the save's record check changed since it was proposed or admitted; +- the save checks the right record on screen and the API step names no + right-record contract; - there's no live browser session (`--backend web` only). `Replayer(api_steps=False)` keeps a run on the GUI. diff --git a/packages/flow/openadapt_flow/action_evidence.py b/packages/flow/openadapt_flow/action_evidence.py index 5a7239b9f..0d5491ad6 100644 --- a/packages/flow/openadapt_flow/action_evidence.py +++ b/packages/flow/openadapt_flow/action_evidence.py @@ -19,6 +19,9 @@ {"uia", "dom", "guarded_coordinate", "guarded_keyboard", "remote_guarded"} ) HUMAN_ATTENDED_ACTUATIONS = frozenset({"human_attended", "human_attended_skip"}) +#: A GUI step that an admitted API step ran in place of: nothing was +#: delivered for it; the API step's save carries the delivery evidence. +API_FOLDED_ACTUATION = "api_folded" _DELIVERY_OPERATIONS: dict[tuple[str, ActionKind], set[tuple[str, bool]]] = { @@ -308,7 +311,8 @@ def action_evidence_error( or result.input_verified is not None or result.starting_state_settled not in {None, True} or result.fresh_actuation_events - or result.actuation not in {None, "human_attended_skip"} + or result.actuation + not in {None, "human_attended_skip", API_FOLDED_ACTUATION} ): return "skipped action contains delivery evidence" return None diff --git a/packages/flow/openadapt_flow/runtime/replayer.py b/packages/flow/openadapt_flow/runtime/replayer.py index 02b3e546a..c3f59a856 100644 --- a/packages/flow/openadapt_flow/runtime/replayer.py +++ b/packages/flow/openadapt_flow/runtime/replayer.py @@ -2155,6 +2155,25 @@ def acknowledge_delivery(self_nonlocal) -> None: continue if step_index <= span_skip_to: continue + if resume_from is not None and step_index == resume_from: + inside = self._resume_inside_web_form_span( + workflow, step_index, report.results + ) + if inside is not None: + # The folded steps are checkpointed only after the + # save was sent, so this save may already have + # landed. It is never sent again and never redone + # through the GUI on an empty form. + self._record_linear_result( + report, + workflow, + inside, + step_index=step_index, + step=step, + params=params, + durable_run=durable_run, + ) + break # An admitted API step (docs/API_TIER.md) runs in place of the # GUI steps it stands for. When it can't, the reason lands on # the first of those steps and the GUI steps run as recorded. @@ -2194,15 +2213,25 @@ def acknowledge_delivery(self_nonlocal) -> None: if why_not is not None: result.api_fallback = why_not batch = [result] - for result in batch: + # An API span's folded steps are checkpointed only once its + # save was sent: a resume then starts at the save, which can + # only be reconciled, never redone through the GUI. A save + # that wasn't sent leaves the resume point before the span, + # so a resume runs the whole span again. + save_result = batch[-1] + checkpoint_folded = save_result.ok or save_result.delivery_attempted + for offset, result in enumerate(batch): + folded = offset < len(batch) - 1 stop_run = self._record_linear_result( report, workflow, result, - step_index=step_index, - step=step, + step_index=step_index + offset, + step=workflow.steps[step_index + offset], params=params, - durable_run=durable_run, + durable_run=( + None if folded and not checkpoint_folded else durable_run + ), ) if stop_run: break @@ -2403,8 +2432,6 @@ def _web_form_span_at( ids = [s.id for s in workflow.steps[step_index : end_index + 1]] if not self.api_steps: reason = "API steps are off for this run" - elif durable: - reason = "a durable or resumed run uses the GUI steps" elif getattr(self, "_surface_router", None) is not None: reason = "a run across several windows or tabs uses the GUI steps" elif self._approval_gated() and not callable( @@ -2421,6 +2448,13 @@ def _web_form_span_at( reason = "a governed run uses its qualified GUI path" elif not binding.admitted(): reason = "the API step is not admitted (openadapt-flow api admit)" + elif save.identity_armed and not binding.identity: + # The GUI save checks the right record on screen. The API step + # must check it too, so it needs a right-record contract. + reason = ( + "the save checks the right record on screen, and the API " + "step names no right-record contract" + ) elif list(binding.effects) != list(save.effects): reason = ( "the save step's record check differs from the one admitted " @@ -2444,6 +2478,54 @@ def _web_form_span_at( return end_index, save, reason return None + def _resume_inside_web_form_span( + self, workflow: Workflow, step_index: int, prior: list[StepResult] + ) -> Optional[StepResult]: + """A halt result when a resume would start inside an API step's span. + + Returns None when ``step_index`` isn't a covered step after the first, + or when the steps before it ran on the GUI (``prior`` holds the + checkpointed results): then the GUI save resumes as usual. + """ + + step = workflow.steps[step_index] + if not prior or prior[-1].actuation != "api_folded": + return None + for save in workflow.steps[step_index:]: + binding = save.api_binding + if ( + binding is None + or binding.kind != "web_form" + or binding.web_form is None + ): + continue + covered = list(binding.web_form.replaces_steps) + if step.id in covered[1:]: + return StepResult( + step_id=step.id, + intent=step.intent, + ok=False, + safety_halt=True, + failure_category=( + "governed_refusal" + if self.governed_authorization is not None + else "safety_halt" + ), + risk=step.risk, + risk_explanation=step.risk_explanation, + risk_review_required=step.risk_review_required, + delivery_attempted=False, + error=( + f"Step '{step.id}' ({step.intent}): resume would start " + f"inside the API step for '{save.id}', whose save was " + "sent and may have landed. Resume never sends it again " + "and never redoes it through the GUI. Reconcile it " + "(the attended reconcile action reads the record back), " + "or start the item again" + ), + ) + return None + def _run_web_form_span( self, workflow: Workflow, diff --git a/packages/flow/tests/test_api_step_durable.py b/packages/flow/tests/test_api_step_durable.py new file mode 100644 index 000000000..0cdaf4fe6 --- /dev/null +++ b/packages/flow/tests/test_api_step_durable.py @@ -0,0 +1,297 @@ +"""An API step in a durable run (docs/API_TIER.md, "In a durable run"). + +- the checkpoint before the span is the last step before it; the span's + steps are checkpointed together after the save was sent; +- a save that may have landed is never sent again by resume, and never + redone through the GUI: an ordinary resume is refused, a resume that + authorizes one uncertain retry still stops, and the attended reconcile + action reads the record back and finishes the run without sending; +- a save that wasn't sent leaves the resume point before the span, so a + resume runs the API step again from the start. + +The system of record is the MockMed fault server; the values are made up. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +import requests + +from openadapt_flow.compiler.api_proposal import admit_api_step +from openadapt_flow.ir import Workflow +from openadapt_flow.mockmed.fault_server import serve as fault_serve +from openadapt_flow.runtime.actuators import ( + ActuationStatus, + ApiActuationResult, + ApiHaltKind, +) +from openadapt_flow.runtime.durable import ( + ApprovalRequired, + issue_resume_approval, + resume, +) +from openadapt_flow.runtime.durable.attended import ( + AttendedActionRequest, + AttendedActionStore, + BoundAttendedExecutor, + execute_attended_action, +) +from openadapt_flow.runtime.durable.checkpoint import CheckpointStore +from openadapt_flow.runtime.durable.program_checkpoint import bundle_version +from openadapt_flow.runtime.effects import RestRecordVerifier +from openadapt_flow.runtime.replayer import Replayer +from tests.test_api_tier_web_form import ( + TARGET, + EnterWritesBackend, + ScriptedActuator, + _span_workflow, +) +from tests.test_execution_profiles import _sealed +from tests.test_replayer import FakeVision + + +class SentThenLost(ScriptedActuator): + """The save lands in the system of record; its response is lost.""" + + def actuate(self, binding, params): + self.calls += 1 + requests.post( + f"{self.sor_url}/api/encounter", + json={**TARGET, "note": "api"}, + timeout=5, + ) + return ApiActuationResult( + status=ActuationStatus.HALT, + halt_kind=ApiHaltKind.DELIVERY_UNCERTAIN, + reason="the save request failed after it was sent; it is not retried", + ) + + +class AcceptedButNothingSaved(ScriptedActuator): + """The server answers success, but the record check finds nothing.""" + + def actuate(self, binding, params): + self.calls += 1 + return ApiActuationResult( + status=ActuationStatus.ACTUATED, reason="POST /save.php -> 200" + ) + + +def _absolute_check_workflow() -> Workflow: + """The span program with a record check that needs no before-snapshot. + + The attended reconcile action reads the record as it is now, so it can + only prove a check that doesn't compare with the state before the save. + """ + + wf = _span_workflow(admitted=False) + save = wf.steps[-1] + effect = save.effects[0].model_copy(update={"forbid_collateral_loss": False}) + binding = save.api_binding.model_copy(update={"effects": [effect]}) + save = save.model_copy(update={"effects": [effect], "api_binding": binding}) + wf = wf.model_copy(update={"steps": [*wf.steps[:-1], save]}) + wf, _ = admit_api_step(wf, admitted_by="reviewer") + return wf + + +class SecondReadFails(RestRecordVerifier): + """Reads the record check's pre-state once, then can't reach it.""" + + def __init__(self, base_url: str) -> None: + super().__init__(base_url) + self.reads = 0 + + def capture_pre_state(self, context: Any = None): + self.reads += 1 + if self.reads > 1: + raise ConnectionError("system of record unreachable") + return super().capture_pre_state(context) + + +class Fixture: + def __init__(self, tmp_path, workflow: Workflow | None = None) -> None: + self.url, self.db, self.stop = fault_serve() + self.url = self.url.rstrip("/") + self.workflow, self.bundle = _sealed( + tmp_path, workflow or _span_workflow(), encrypted=False + ) + self.run_dir = tmp_path / "run" + + def replayer( + self, actuator: Any, *, verifier_url: str | None = None, verifier: Any = None + ) -> Replayer: + self.backend = EnterWritesBackend(self.url) + return Replayer( + self.backend, + vision=FakeVision(), + effect_verifier=verifier or RestRecordVerifier(verifier_url or self.url), + web_form_actuator=actuator, + poll_interval_s=0.01, + durable=True, + ) + + def run(self, actuator: Any, **kw: Any): + return self.replayer(actuator, **kw).run( + self.workflow, bundle_dir=self.bundle, run_dir=self.run_dir + ) + + def store(self) -> CheckpointStore: + return CheckpointStore(self.run_dir) + + def records(self) -> list[dict[str, Any]]: + return self.db.snapshot()["records"] + + def approval(self, **extra: Any): + store = self.store() + manifest, pending = store.read_manifest(), store.read_pending() + assert manifest is not None and pending is not None + return issue_resume_approval( + pending, + approver="operator@example.com", + resolution="checked the record", + bundle_version=bundle_version(self.bundle), + run_id=manifest.run_id, + workflow_name=manifest.workflow_name, + run_dir=self.run_dir, + **extra, + ) + + +@pytest.fixture +def fx(tmp_path): + fixture = Fixture(tmp_path) + try: + yield fixture + finally: + fixture.stop() + + +def test_a_durable_run_uses_the_api_step_and_checkpoints_after_it(fx): + actuator = ScriptedActuator(fx.url, ActuationStatus.ACTUATED) + report = fx.run(actuator) + + assert report.success is True, [r.error for r in report.results] + assert actuator.calls == 1 and fx.backend.actions == [] + checkpoints = fx.store().checkpoints() + assert [c.actuation for c in checkpoints] == ["api_folded", "api"] + assert [c.step_index for c in checkpoints] == [0, 1] + assert checkpoints[-1].effect_verified is True + assert [r["note"] for r in fx.records()] == ["api"] + + +def test_an_uncertain_send_is_never_resent_by_resume(fx): + first = SentThenLost(fx.url, ActuationStatus.HALT) + report = fx.run(first) + + assert first.calls == 1 and fx.backend.actions == [] + assert report.transaction_outcome == "RECONCILIATION_REQUIRED" + store = fx.store() + # The folded step is checkpointed with the save: the pause is at the save. + assert [c.actuation for c in store.checkpoints()] == ["api_folded"] + pending = store.read_pending() + assert pending.step_id == "step_001" and pending.resume_from_index == 1 + assert pending.delivery_uncertainty is not None + + # An ordinary resume can't repeat a save that may have landed. + second = ScriptedActuator(fx.url, ActuationStatus.ACTUATED) + with pytest.raises(ApprovalRequired, match="may already have actuated"): + resume(fx.run_dir, fx.replayer(second), approval=fx.approval()) + assert second.calls == 0 + + # Even a resume that authorizes one uncertain retry doesn't resend an API + # save or redo it through the GUI on an empty form. + resumed = resume( + fx.run_dir, + fx.replayer(second), + approval=fx.approval(authorize_uncertain_retry=True), + ) + assert second.calls == 0 and fx.backend.actions == [] + assert resumed.success is False + assert "never sends it again" in resumed.results[-1].error + assert [r["note"] for r in fx.records()] == ["api"] + + +def test_reconcile_reads_the_record_back_and_finishes_without_sending(tmp_path): + fx = Fixture(tmp_path, _absolute_check_workflow()) + try: + _reconcile(fx) + finally: + fx.stop() + + +def _reconcile(fx: Fixture) -> None: + first = SentThenLost(fx.url, ActuationStatus.HALT) + fx.run(first) + capability = AttendedActionStore(fx.run_dir).read() + assert capability is not None and "reconcile" in capability.allowed_actions + + second = ScriptedActuator(fx.url, ActuationStatus.ACTUATED) + decision = execute_attended_action( + fx.run_dir, + AttendedActionRequest( + capability_digest=capability.digest, + idempotency_key="reconcile-api-step-001", + action="reconcile", + disposition="reconciliation_requested", + ), + operator="staff", + decided_by="human", + executor=BoundAttendedExecutor(lambda _manifest: fx.replayer(second)), + ) + + assert decision.status == "completed", decision.message + assert decision.report_success is True + assert second.calls == 0 and fx.backend.actions == [] + assert [r["note"] for r in fx.records()] == ["api"] + + +def test_a_save_the_record_check_refutes_is_not_redone_by_resume(fx): + first = AcceptedButNothingSaved(fx.url, ActuationStatus.ACTUATED) + report = fx.run(first) + assert report.success is False and fx.records() == [] + assert fx.store().read_pending().resume_from_index == 1 + + second = ScriptedActuator(fx.url, ActuationStatus.ACTUATED) + resumed = resume(fx.run_dir, fx.replayer(second), approval=fx.approval()) + assert second.calls == 0 and fx.backend.actions == [] + assert resumed.success is False + assert "never sends it again" in resumed.results[-1].error + + +def test_a_save_that_wasnt_sent_resumes_the_whole_api_step(fx): + # The record check's pre-state can't be read, so nothing is sent. + first = ScriptedActuator(fx.url, ActuationStatus.ACTUATED) + report = fx.run(first, verifier_url="http://127.0.0.1:9") + assert first.calls == 0 and report.success is False + store = fx.store() + assert store.checkpoints() == [] + assert store.read_pending().resume_from_index == 0 + + second = ScriptedActuator(fx.url, ActuationStatus.ACTUATED) + resumed = resume(fx.run_dir, fx.replayer(second), approval=fx.approval()) + assert resumed.success is True, [r.error for r in resumed.results] + assert second.calls == 1 and fx.backend.actions == [] + assert [c.actuation for c in fx.store().checkpoints()] == ["api_folded", "api"] + assert [r["note"] for r in fx.records()] == ["api"] + + +def test_a_gui_fallback_that_halted_at_the_save_resumes_on_the_gui(fx): + # The API step is unavailable, so the GUI steps run; the GUI save then + # can't read its record check's pre-state and stops before pressing. + first = ScriptedActuator(fx.url, ActuationStatus.UNAVAILABLE) + report = fx.run(first, verifier=SecondReadFails(fx.url)) + assert "did not pass" in report.results[0].api_fallback + assert report.success is False and fx.records() == [] + checkpoints = fx.store().checkpoints() + assert [c.step_id for c in checkpoints] == ["step_000"] + assert checkpoints[0].actuation != "api_folded" + + # The steps before the save ran on the GUI, so the GUI save resumes. + second = ScriptedActuator(fx.url, ActuationStatus.ACTUATED) + resumed = resume(fx.run_dir, fx.replayer(second), approval=fx.approval()) + assert resumed.success is True, [r.error for r in resumed.results] + assert second.calls == 0 + assert ("press", "Enter") in [(a[0], a[1]) for a in fx.backend.actions] + assert [r["note"] for r in fx.records()] == ["gui"] diff --git a/packages/flow/tests/test_api_tier_web_form.py b/packages/flow/tests/test_api_tier_web_form.py index f0f0fe58a..538505ae4 100644 --- a/packages/flow/tests/test_api_tier_web_form.py +++ b/packages/flow/tests/test_api_tier_web_form.py @@ -735,3 +735,17 @@ def test_the_fallback_reports_the_rung_that_ran(tmp_path): assert "did not pass" in first.api_fallback assert first.actuation != "api_folded" and save.actuation != "api" assert ("press", "Enter") in [(a[0], a[1]) for a in backend.actions] + + +def test_an_identity_checked_save_needs_a_right_record_contract(tmp_path): + # The save checks the right record on screen. An API step that names no + # right-record contract can't make that check, so the GUI steps run (a + # durable checkpoint of the save would otherwise lack identity evidence). + wf = _span_workflow(admitted=False) + save = wf.steps[-1].model_copy(update={"identity_armed": True}) + wf = wf.model_copy(update={"steps": [*wf.steps[:-1], save]}) + wf, _ = admit_api_step(wf, admitted_by="reviewer") + report, backend, actuator, records = _run(tmp_path, wf, ActuationStatus.ACTUATED) + assert actuator.calls == 0 + assert "names no right-record contract" in report.results[0].api_fallback + assert ("press", "Enter") in [(a[0], a[1]) for a in backend.actions]