From 55c4803089535ff733f9bcff36ece15dbb59484e Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 19 Oct 2026 12:21:58 -0400 Subject: [PATCH 1/3] flow: run the API step in durable runs without ever resending a save A durable or resumed run used to fall back to the GUI steps. Now the API step runs there too, under the same no-double-write rule. - The span's steps are checkpointed together once the save was sent (api_folded, then api). A save that wasn't sent checkpoints nothing in the span, so a resume runs the whole API step again. - A resume that would start inside a span whose steps were folded stops at the save: that save may have landed, so it is never sent again and never redone through the GUI on an empty form. This holds even when the approval authorizes one uncertain retry. A run that fell back to the GUI and stopped at the GUI save resumes it as before. - The attended reconcile action reads the record back and finishes the run when the record is there, without sending. - The linear loop now records each span result at its own index (it used the span's first index for all of them). - action_evidence accepts api_folded as a skipped, undelivered step. RECONCILIATION_REQUIRED is unchanged. Reconcile can't prove a record check that needs a before-snapshot (exact_new_set, count_new_only, collateral loss); that limit is the same for GUI saves and is documented. Co-Authored-By: Claude Opus 5.5 --- CAPABILITIES.md | 1 + llms-full.txt | 1 + packages/flow/docs/API_TIER.md | 34 +- .../flow/openadapt_flow/action_evidence.py | 6 +- .../flow/openadapt_flow/runtime/replayer.py | 87 ++++- packages/flow/tests/test_api_step_durable.py | 297 ++++++++++++++++++ 6 files changed, 418 insertions(+), 8 deletions(-) create mode 100644 packages/flow/tests/test_api_step_durable.py diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 20e8806f6..6f5a260c2 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 | 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..8044e7743 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 | 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..bdc641ee4 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,7 +172,7 @@ 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; - there's no live browser session (`--backend web` only). 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..ac28e3a92 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( @@ -2444,6 +2471,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"] From 73167dcc3326c6f135c4fe62f7d28290c5ad1ee7 Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 19 Oct 2026 12:22:24 -0400 Subject: [PATCH 2/3] docs: name #1358 in the capability row Co-Authored-By: Claude Opus 5.5 --- CAPABILITIES.md | 2 +- llms-full.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 6f5a260c2..4376ed1a6 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -182,7 +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 | Synthetic | [API_TIER.md](packages/flow/docs/API_TIER.md#in-a-durable-run) | +| 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 8044e7743..9e97777a5 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -538,7 +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 | Synthetic | [API_TIER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/API_TIER.md#in-a-durable-run) | +| 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) | From f50ecd450c3299bf18778103b9f26ddb53db69fd Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 19 Oct 2026 13:31:04 -0400 Subject: [PATCH 3/3] flow: an identity-checked save's API step needs a right-record contract The referral measurement found it: the save checks the right record on screen, the API step sent the save without any identity check, and the durable checkpoint then refused the result for lacking identity evidence, after the save had landed. The API step now runs for an identity-checked save only when its binding names a right-record contract; otherwise the GUI steps run and the report says why. Co-Authored-By: Claude Opus 5.5 --- packages/flow/docs/API_TIER.md | 2 ++ packages/flow/openadapt_flow/runtime/replayer.py | 7 +++++++ packages/flow/tests/test_api_tier_web_form.py | 14 ++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/packages/flow/docs/API_TIER.md b/packages/flow/docs/API_TIER.md index bdc641ee4..c245cdc4c 100644 --- a/packages/flow/docs/API_TIER.md +++ b/packages/flow/docs/API_TIER.md @@ -175,6 +175,8 @@ steps run instead, when: - 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/runtime/replayer.py b/packages/flow/openadapt_flow/runtime/replayer.py index ac28e3a92..c3f59a856 100644 --- a/packages/flow/openadapt_flow/runtime/replayer.py +++ b/packages/flow/openadapt_flow/runtime/replayer.py @@ -2448,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 " 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]