From 7079736e0b0218f8c29a8b7524b822298f2c5a02 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 4 Nov 2026 13:14:24 -0500 Subject: [PATCH] fix(flow): an audit_read frame without frame.database never holds (#1505) The frame excuses the bot's log entries for the record it saved, whatever table they wrote. When the frame reads only the record's own table, the bot's other writes for that record (an insurance row, an allergy, a note) are never seen, so the frame held and verify-run reported verified. A frame with audit_read attribution and no frame.database now reports not_checked instead of held, so the save is never verified. Refutations are unchanged. With frame.database the whole-database diff reads every table and judges such a write as a row. Tests: verify-run on SQLite with an unread insurance table (not_checked without frame.database, refuted with it, a clean save verified only with it; it held on main), and a live OpenEMR module (the bot's allergy for the patient it saved) that runs under the lane-1 fixture lease. --- packages/flow/CHANGELOG.md | 2 + packages/flow/docs/VERIFY_RUN.md | 7 + .../openadapt_flow/runtime/effects/frame.py | 17 + packages/flow/tests/test_frame_conditions.py | 212 +++++++---- packages/flow/tests/test_frame_links.py | 6 +- .../tests/test_frame_scope_live_openemr.py | 333 ++++++++++++++++++ 6 files changed, 515 insertions(+), 62 deletions(-) create mode 100644 packages/flow/tests/test_frame_scope_live_openemr.py diff --git a/packages/flow/CHANGELOG.md b/packages/flow/CHANGELOG.md index 8854402d95..79bfb04c76 100644 --- a/packages/flow/CHANGELOG.md +++ b/packages/flow/CHANGELOG.md @@ -43,6 +43,8 @@ ### Fixes +- A frame with `audit_read` attribution and no `frame.database` never holds: it reports `not_checked`, so `verify-run` can't call the save `verified`. The bot's log entries for the saved record were excused whatever table they wrote, so an extra insurance row, allergy or note for the new patient passed when the frame read only the record's own table ([#1505](https://github.com/OpenAdaptAI/OpenAdapt/issues/1505)). Add `frame.database` (the whole-database diff) to such a contract; its own refutations are unchanged. + - Typed input now needs the complete whitespace-squashed OCR value, including values shorter than four characters. Native and pixel-only runs halt when delivery cannot be confirmed instead of clicking and typing again. The Windows agent checks the foreground window before and after each character or clipboard paste; a change stops delivery and reports that input may be partial ([#1478](https://github.com/OpenAdaptAI/OpenAdapt/issues/1478)). - A REST read names its own records path: `effects.kind: rest`, a reward recipe of kind `rest`, `RestRecordVerifier`, `RestCompensator` and the fault gate's in-memory copy require `records_path` (and `records_key`, `delete_path_template`) instead of defaulting to the demo app's `/api/db`; `run --effects-kind rest` takes `--effects-records-path`. Compile-time REST binding admission (`compile_recording(admit_api_bindings=True, admission_fixture=...)`) takes the app's read path, write path, server-assigned fields and idempotency key field from an `AdmissionFixture`, and proposes nothing when no write was captured and no write path is named. Effect mining and `scaffold-verifier` mine an idempotency key only from a field the caller names. Verifier identities and fault-gate check digests of existing configs are unchanged. diff --git a/packages/flow/docs/VERIFY_RUN.md b/packages/flow/docs/VERIFY_RUN.md index f7c1f67d27..2a155bf4c9 100644 --- a/packages/flow/docs/VERIFY_RUN.md +++ b/packages/flow/docs/VERIFY_RUN.md @@ -122,6 +122,13 @@ collateral, and the outcome is `reconciliation_required`. With `audit_read`, a log entry by the bot for a record no effect names is collateral too, even when the read shows no change. +An `audit_read` frame needs `frame.database`. The bot's entries for the +record it saved are excused by record, whatever table they wrote, so its +other writes for that record (an insurance row, an allergy, a note) are +caught only as rows, and only the whole-database diff reads every table. +Without `frame.database` the frame never holds: it reports `not_checked`, so +the save is never `verified` (#1505). + Only evidence the bot can't write counts: - `audit_read` reads the app's own audit log or change feed. diff --git a/packages/flow/openadapt_flow/runtime/effects/frame.py b/packages/flow/openadapt_flow/runtime/effects/frame.py index d8f2cb6c3c..ca48f0649d 100644 --- a/packages/flow/openadapt_flow/runtime/effects/frame.py +++ b/packages/flow/openadapt_flow/runtime/effects/frame.py @@ -1985,6 +1985,23 @@ def names_moved(entry_ref: Any) -> bool: learned_used=learned_used, ) verdict.reason = _reason(verdict) + if ( + verdict.status == "held" + and spec.attribution is not None + and spec.attribution.kind == "audit_read" + and database is None + ): + # The bot's audit entries for a saved record are excused by record, + # whatever table they wrote, so its other writes for that record (an + # insurance row, an allergy, a note) must be judged as rows. Only the + # whole-database diff reads every table; without it such a write + # would pass unseen (#1505). Never hold: fail closed. + verdict.status = "not_checked" + verdict.reason += ( + "; not checked: audit_read attribution needs frame.database, " + "since without the whole-database diff the run's own writes to " + "tables the frame doesn't read can't be judged (#1505)" + ) return verdict diff --git a/packages/flow/tests/test_frame_conditions.py b/packages/flow/tests/test_frame_conditions.py index 9160af0ac8..ac106c1914 100644 --- a/packages/flow/tests/test_frame_conditions.py +++ b/packages/flow/tests/test_frame_conditions.py @@ -224,7 +224,9 @@ def test_an_audited_edit_by_another_user_is_concurrent() -> None: audit_before=[OLD_ENTRY], audit_after=entries, ) - assert verdict.status == "held" + # Attributed, so nothing refutes; without frame.database the frame never + # holds (#1505). + assert verdict.status == "not_checked" assert len(verdict.concurrent) == 1 @@ -281,10 +283,11 @@ def test_a_bot_log_entry_on_an_undeclared_record_is_collateral() -> None: [ # Both reads read uuid_registry as append-only, and the profile marks # it: the bot's insert there, filed under no chart, is bookkeeping. - (["openemr.uuid_registry"], {}, "held"), + # Nothing refutes; without frame.database the frame never holds. + (["openemr.uuid_registry"], {}, "not_checked"), # An empty record field names no record in any app. - (["openemr.uuid_registry"], {"record": None, "values": ()}, "held"), - (["openemr.uuid_registry"], {"record": "", "values": ()}, "held"), + (["openemr.uuid_registry"], {"record": None, "values": ()}, "not_checked"), + (["openemr.uuid_registry"], {"record": "", "values": ()}, "not_checked"), # Record 0 names no record only where the app profile says so; in an # app whose profile doesn't, 0 is a record id and the entry counts. (["openemr.uuid_registry"], {"values": ()}, "refuted"), @@ -453,84 +456,173 @@ def test_verify_run_stray_addition_is_refuted(sor, tmp_path: Path) -> None: assert result["evidence"]["frame"]["collateral_kinds"] == ["added"] -def test_verify_run_attributes_through_an_audit_read(tmp_path: Path) -> None: - """A SQL store with its own audit log: another user's edit is concurrent.""" +def _audited_store(tmp_path: Path) -> Path: + """A SQL store with its own audit log, and a table the record read skips.""" db = tmp_path / "app.sqlite" con = sqlite3.connect(db) con.executescript( "CREATE TABLE chart (id INTEGER PRIMARY KEY, lname TEXT, phone TEXT);" - "CREATE TABLE log (id INTEGER PRIMARY KEY, user TEXT, chart_id INTEGER);" + "CREATE TABLE insurance (id INTEGER PRIMARY KEY, chart_id INTEGER, " + "provider TEXT);" + "CREATE TABLE log (id INTEGER PRIMARY KEY, user TEXT, chart_id INTEGER, " + "event TEXT, tbl TEXT);" "INSERT INTO chart VALUES (1, 'Priorchart', '555-0188');" - "INSERT INTO log VALUES (1, 'clinic2', 1);" + "INSERT INTO log VALUES (1, 'clinic2', 1, 'update', 'chart');" ) con.commit() - - def doc(self_user: str) -> dict: - return { - "schema_version": "openadapt.verify/v1", - "verifier": { + con.close() + return db + + +def _audited_doc(db: Path, *, whole_database: bool) -> dict: + frame: dict = { + "identity_field": "id", + "attribution": { + "kind": "audit_read", + "actor_field": "user", + "record_field": "chart_id", + "self": ["bot"], + "event_field": "event", + "write_events": ["insert", "update", "delete"], + "insert_events": ["insert"], + "delete_events": ["delete"], + "table_field": "tbl", + "read": { "kind": "sql", "sqlite_database": str(db), - "sql_query": "SELECT id, lname, phone FROM chart", - }, - "effects": [ - { - "kind": "record_written", - "match": {"lname": {"param": "lname"}}, - "count_new_only": True, - "timeout_s": 0.2, - } - ], - "frame": { - "identity_field": "id", - "attribution": { - "kind": "audit_read", - "actor_field": "user", - "record_field": "chart_id", - "self": [self_user], - "read": { - "kind": "sql", - "sqlite_database": str(db), - "sql_query": "SELECT id, user, chart_id FROM log", - }, - }, + "sql_query": "SELECT id, user, chart_id, event, tbl FROM log", }, + }, + } + if whole_database: + frame["attribution"]["record_key"] = "id" + frame["attribution"]["record_keys"] = {"insurance": "chart_id"} + frame["database"] = { + "dialect": "sqlite", + "read": {"sqlite_database": str(db)}, + "allow_tables": ["log"], + "views": {"chart": ""}, + "new_row_fields": {"chart": ["phone"]}, } + return { + "schema_version": "openadapt.verify/v1", + "verifier": { + "kind": "sql", + "sqlite_database": str(db), + "sql_query": "SELECT id, lname, phone FROM chart", + }, + "effects": [ + { + "kind": "record_written", + "match": {"lname": {"param": "lname"}}, + "count_new_only": True, + "timeout_s": 0.2, + } + ], + "frame": frame, + } - params = {"lname": "Newchart"} - def run(edit_user: str, name: str) -> dict: - cfg = parse_verify_config(doc("bot")) - baseline = tmp_path / f"{name}.json" - capture_baseline(cfg, params, baseline) +def _audited_run( + tmp_path: Path, + db: Path, + name: str, + *, + whole_database: bool, + edit_user: str = "", + insurance_by: str = "", +) -> dict: + """The bot saves a chart; ``edit_user`` then edits chart 1, and + ``insurance_by`` adds an insurance row for the new chart.""" + + params = {"lname": "Newchart"} + cfg = parse_verify_config(_audited_doc(db, whole_database=whole_database)) + baseline = tmp_path / f"{name}.json" + con = sqlite3.connect(db) + try: con.execute("DELETE FROM chart WHERE id > 1") + con.execute("DELETE FROM insurance") con.execute("UPDATE chart SET phone = '555-0188' WHERE id = 1") + con.commit() + capture_baseline(cfg, params, baseline) con.execute("INSERT INTO chart (lname, phone) VALUES ('Newchart', '555-0131')") + new_id = con.execute("SELECT last_insert_rowid()").fetchone()[0] con.execute( - "INSERT INTO log (user, chart_id) VALUES ('bot', last_insert_rowid())" + "INSERT INTO log (user, chart_id, event, tbl) VALUES " + "('bot', ?, 'insert', 'chart')", + (new_id,), ) - con.execute("UPDATE chart SET phone = '555-0199' WHERE id = 1") - con.execute("INSERT INTO log (user, chart_id) VALUES (?, 1)", (edit_user,)) + if edit_user: + con.execute("UPDATE chart SET phone = '555-0199' WHERE id = 1") + con.execute( + "INSERT INTO log (user, chart_id, event, tbl) VALUES " + "(?, 1, 'update', 'chart')", + (edit_user,), + ) + if insurance_by: + con.execute( + "INSERT INTO insurance (chart_id, provider) VALUES (?, 'Acme')", + (new_id,), + ) + con.execute( + "INSERT INTO log (user, chart_id, event, tbl) VALUES " + "(?, ?, 'insert', 'insurance')", + (insurance_by, new_id), + ) con.commit() - return verify_run( - cfg, - params=params, - run_record={"executor_kind": "agent", "executor_id": "test-agent"}, - authorization_ref="ticket-0000-audit", - baseline_path=baseline, - private_dir=tmp_path / name, - ) - - try: - concurrent = run("clinic3", "concurrent") - assert concurrent["receipt"]["outcome"] == "verified", concurrent["evidence"] - assert concurrent["evidence"]["frame"]["concurrent_count"] == 1 - own = run("bot", "own") - assert own["receipt"]["checked"]["frame"] == "refuted" - assert own["receipt"]["outcome"] == "reconciliation_required" finally: con.close() + return verify_run( + cfg, + params=params, + run_record={"executor_kind": "agent", "executor_id": "test-agent"}, + authorization_ref="ticket-0000-audit", + baseline_path=baseline, + private_dir=tmp_path / name, + ) + + +def test_verify_run_attributes_through_an_audit_read(tmp_path: Path) -> None: + """With the whole-database diff, another user's edit is concurrent and + the bot's own edit of another chart refutes.""" + + db = _audited_store(tmp_path) + concurrent = _audited_run( + tmp_path, db, "concurrent", whole_database=True, edit_user="clinic3" + ) + assert concurrent["receipt"]["outcome"] == "verified", concurrent["evidence"] + assert concurrent["evidence"]["frame"]["concurrent_count"] == 1 + own = _audited_run(tmp_path, db, "own", whole_database=True, edit_user="bot") + assert own["receipt"]["checked"]["frame"] == "refuted" + assert own["receipt"]["outcome"] == "reconciliation_required" + + +def test_the_bots_unrelated_write_for_the_saved_record_is_never_excused( + tmp_path: Path, +) -> None: + """#1505: the bot's log entry for the chart it saved is excused by + record, so its insurance row for that chart must be judged as a row. + + Without ``frame.database`` the frame can't read the insurance table, so + it never holds and the save is never verified. With it, the row refutes. + A clean save holds only with it. + """ + + db = _audited_store(tmp_path) + unread = _audited_run( + tmp_path, db, "unread", whole_database=False, insurance_by="bot" + ) + assert unread["receipt"]["checked"]["frame"] == "not_checked" + assert unread["receipt"]["outcome"] != "verified" + whole = _audited_run(tmp_path, db, "whole", whole_database=True, insurance_by="bot") + assert whole["receipt"]["checked"]["frame"] == "refuted" + assert whole["receipt"]["outcome"] == "reconciliation_required" + clean_unread = _audited_run(tmp_path, db, "clean-unread", whole_database=False) + assert clean_unread["receipt"]["checked"]["frame"] == "not_checked" + assert clean_unread["receipt"]["outcome"] != "verified" + clean = _audited_run(tmp_path, db, "clean", whole_database=True) + assert clean["receipt"]["outcome"] == "verified", clean["evidence"] def test_receipt_check_refuses_verified_with_an_unchecked_part( diff --git a/packages/flow/tests/test_frame_links.py b/packages/flow/tests/test_frame_links.py index e2a5823c1a..dbd2904785 100644 --- a/packages/flow/tests/test_frame_links.py +++ b/packages/flow/tests/test_frame_links.py @@ -47,7 +47,8 @@ def _notes_change(spec: FrameSpec, author: str) -> str: def test_an_audit_entry_names_a_row_through_its_record_key() -> None: spec = FrameSpec(identity_field="id", attribution=ATTRIBUTION) # Another user's edit to patient 1's note: concurrent, not collateral. - assert _notes_change(spec, "clinic2") == "held" + # Excused, so nothing refutes; without frame.database it never holds (#1505). + assert _notes_change(spec, "clinic2") == "not_checked" # The bot's own: collateral. assert _notes_change(spec, "bot") == "refuted" @@ -118,7 +119,8 @@ def status(spec: FrameSpec) -> str: audit_after=[{"id": 1, "user": "clinic2", "patient_id": "1"}], ).status - assert status(FrameSpec(identity_field="id", attribution=keyed)) == "held" + # Excused; without frame.database the frame never holds (#1505). + assert status(FrameSpec(identity_field="id", attribution=keyed)) == "not_checked" assert status(FrameSpec(identity_field="id", attribution=ATTRIBUTION)) == "refuted" diff --git a/packages/flow/tests/test_frame_scope_live_openemr.py b/packages/flow/tests/test_frame_scope_live_openemr.py new file mode 100644 index 0000000000..3bcf07feb8 --- /dev/null +++ b/packages/flow/tests/test_frame_scope_live_openemr.py @@ -0,0 +1,333 @@ +"""Live test of #1505 on a real OpenEMR: the bot's unrelated write for the +patient it saved is never excused. + +The bot saves a made-up patient through OpenEMR's Standard REST API (the +fixture's ``actor`` OAuth client). It then writes an allergy for that same +patient (a ``lists`` row) that the task never asked for. OpenEMR logs both +writes under the bot's user and the new patient's pid, so an audit-log frame +that excuses the bot's entries for the saved record by pid alone would excuse +the allergy too. + +Two frames are judged on the same window: + +- ``audit_read`` attribution without ``frame.database`` reads only + ``patient_data``. It can't see the ``lists`` row, so it must not hold: the + judge returns ``not_checked`` and ``verify-run`` can't report the save as + verified. +- The same attribution with ``frame.database`` and OpenEMR's app profile diffs + ``patient_data`` and ``lists`` row by row, and must refute: the allergy is + the bot's write outside the declared delta. + +What is real and what is not: + +- The patient save goes through OpenEMR's REST API, and OpenEMR writes the + rows and its own audit entries. +- The allergy is written with SQL plus the audit row OpenEMR would write for + it (event ``patient-record-insert`` under the bot's user name, the statement + base64-encoded in ``comments``, filed under the new pid). The fixture's + OAuth client isn't granted the allergy scope, and the frame judges rows and + log entries, not the channel that wrote them. +- As in ``tests/test_owner_attribution_live_openemr.py``, the database is read + with ``SELECT`` statements through the fixture's ``docker compose exec`` + channel and the rows are passed to ``judge_frame``. + +The module is skipped unless ``OPENEMR_FRAME_SCOPE_LIVE=1``. It needs the +local fixture already up and bootstrapped (``benchmark/openemr_local``; it +does not start containers), under the shared machine's lane-1 fixture lease. +Run it from ``packages/flow``:: + + OPENEMR_FRAME_SCOPE_LIVE=1 PYTHONPATH=../types:. \\ + python -m pytest tests/test_frame_scope_live_openemr.py -v -p no:cacheprovider + +``OPENEMR_FRAME_SCOPE_RESET=1`` restores the fixture's baseline database +afterwards (only for the session that holds the fixture). Without a reset, +the made-up patients and allergies stay. +""" + +from __future__ import annotations + +import base64 +import binascii +import os +import re +import uuid +from collections.abc import Iterator +from typing import Any + +import pytest + +from openadapt_flow.app_profiles import load_profile +from openadapt_flow.runtime.effects import Effect, EffectKind, EffectState, ValueExpr +from openadapt_flow.runtime.effects.frame import FrameSpec, FrameVerdict, judge_frame + +pytestmark = pytest.mark.skipif( + os.environ.get("OPENEMR_FRAME_SCOPE_LIVE") != "1", + reason=( + "live OpenEMR: set OPENEMR_FRAME_SCOPE_LIVE=1 with the local fixture up " + "(benchmark/openemr_local); see this module's docstring" + ), +) + +DB = "openemr" +#: The statement's table, from OpenEMR's logged SQL text. +_TABLE = re.compile( + r"^\s*(?:insert\s+(?:ignore\s+)?into|replace\s+into|update|delete\s+from)\s+" + r"`?(?:\w+`?\.`?)?(\w+)`?", + re.IGNORECASE, +) +_TABLES = ("patient_data", "lists") + + +@pytest.fixture(scope="module") +def openemr() -> Iterator[Any]: + from benchmark.openemr_local.qualify_hooks import fixture + + fx = fixture() + yield fx + if os.environ.get("OPENEMR_FRAME_SCOPE_RESET") == "1": + fx.reset() + + +def _sql(fx: Any, sql: str) -> None: + fx._db(sql) + + +def _lines(fx: Any, sql: str) -> list[str]: + return list(fx._db_lines(sql)) + + +def _rows(fx: Any, sql: str) -> list[list[str]]: + return [line.split("\t") for line in _lines(fx, sql)] + + +def _quote(value: str) -> str: + return "'" + value.replace("\\", "\\\\").replace("'", "''") + "'" + + +def _actor(fx: Any) -> str: + return str(fx._runtime_values()["OPENEMR_ACTOR_USER"]) + + +def _patients(fx: Any) -> list[dict[str, Any]]: + return [ + {"id": r[0], "pid": r[1], "fname": r[2], "lname": r[3], "email": r[4]} + for r in _rows( + fx, f"SELECT id, pid, fname, lname, email FROM {DB}.patient_data" + ) + ] + + +def _lists(fx: Any) -> list[dict[str, Any]]: + return [ + {"id": r[0], "pid": r[1], "type": r[2], "title": r[3]} + for r in _rows(fx, f"SELECT id, pid, type, title FROM {DB}.lists") + ] + + +def _last_log_id(fx: Any) -> int: + return int(_lines(fx, f"SELECT IFNULL(MAX(id), 0) FROM {DB}.log")[0]) + + +def _statement(comments: bytes) -> str: + """The logged statement, plain or as OpenEMR 8 stores it (base64).""" + + plain = comments.decode("utf-8", "replace") + if _TABLE.match(plain): + return plain + cut = comments[: len(comments) - len(comments) % 4] + try: + return base64.b64decode(cut, validate=True).decode("utf-8", "replace") + except (binascii.Error, ValueError): + return plain + + +def _window(fx: Any, after_id: int) -> list[dict[str, Any]]: + """The window's write entries, each with the table its statement names. + + An entry whose statement is encrypted or names no table keeps an empty + table, so it can explain nothing.""" + + out = [] + for r in _rows( + fx, + "SELECT l.id, l.user, IFNULL(l.patient_id, ''), l.event, " + "HEX(LEFT(l.comments, 300)), IFNULL(e.encrypt, 'No') " + f"FROM {DB}.log l LEFT JOIN {DB}.log_comment_encrypt e ON e.log_id = l.id " + f"WHERE l.id > {int(after_id)} ORDER BY l.id", + ): + log_id, user, patient_id, event, comments_hex, encrypted = r + kind = event.rsplit("-", 1)[-1] + if kind not in ("insert", "update", "delete"): + continue + match = ( + None + if encrypted == "Yes" + else _TABLE.match(_statement(bytes.fromhex(comments_hex))) + ) + out.append( + { + "id": log_id, + "user": user, + "patient_id": patient_id or None, + "kind": kind, + "tbl": match.group(1).lower() if match else "", + } + ) + return out + + +def _attribution(actor: str) -> dict[str, Any]: + return { + "kind": "audit_read", + "actor_field": "user", + "self": [actor], + "record_field": "patient_id", + "record_key": "pid", + "event_field": "kind", + "write_events": ["insert", "update", "delete"], + "insert_events": ["insert"], + "delete_events": ["delete"], + "table_field": "tbl", + # Read by this module (``_window``), not by the judge. + "read": {"kind": "sql", "sql_query": "SELECT 1"}, + } + + +def _spec(actor: str, *, whole_database: bool) -> FrameSpec: + doc: dict[str, Any] = {"identity_field": "id", "attribution": _attribution(actor)} + if whole_database: + profile = load_profile("openemr") + doc["database"] = { + "dialect": "mysql", + # The judge reads nothing; this module passes the rows. + "read": {"sql_driver": "pymysql"}, + "profile": "openemr", + "profile_sha256": profile.sha256(), + "views": {"patient_data": "patient", "lists": "lists"}, + } + return FrameSpec.model_validate(doc) + + +def _lname() -> str: + letters = "".join(chr(ord("a") + int(c, 16) % 26) for c in uuid.uuid4().hex[:8]) + return "Framescope" + letters + + +def _add_allergy_as_the_bot(fx: Any, pid: str, actor: str) -> None: + """An allergy for the saved patient, logged as OpenEMR logs the bot's + own writes: under its user and the patient's pid.""" + + title = f"Synthetic allergy {uuid.uuid4().hex[:6]}" + statement = ( + "INSERT INTO lists (date, type, title, pid, activity) " + f"VALUES (NOW(), 'allergy', {_quote(title)}, {int(pid)}, 1)" + ) + _sql(fx, statement.replace("INSERT INTO lists", f"INSERT INTO {DB}.lists")) + comments = base64.b64encode(statement.encode("utf-8")).decode("ascii") + _sql( + fx, + f"INSERT INTO {DB}.log (date, event, category, user, groupname, comments, " + "patient_id, success) VALUES (NOW(), 'patient-record-insert', " + f"'Patient Demographics', {_quote(actor)}, 'Default', {_quote(comments)}, " + f"{int(pid)}, 1)", + ) + + +def _save_and_judge( + fx: Any, *, allergy: bool +) -> tuple[FrameVerdict, FrameVerdict, list[dict[str, Any]]]: + """Save a patient through the API, optionally add the bot's allergy for + it, and judge the window with and without ``frame.database``.""" + + actor_name = _actor(fx) + lname = _lname() + params = {"lname": lname, "email": f"{lname.lower()}@example.invalid"} + before_patients, before_lists = _patients(fx), _lists(fx) + since = _last_log_id(fx) + + response = fx.token_session("actor").post( + f"{fx.api_base_url}/apis/default/api/patient", + json={ + "title": "Ms.", + "fname": "Ada", + "lname": lname, + "DOB": "1984-03-07", + "sex": "Female", + "street": "101 SYNTHETIC WAY", + "city": "EXAMPLETON", + "state": "MA", + "postal_code": "02139", + "phone_home": "555-0107", + "email": params["email"], + "country_code": "USA", + }, + ) + assert response.status_code == 201, ( + f"OpenEMR refused the patient write: HTTP {response.status_code}" + ) + saved = _lines( + fx, f"SELECT pid FROM {DB}.patient_data WHERE lname = {_quote(lname)}" + ) + assert len(saved) == 1, "the bot's save did not land exactly once" + if allergy: + _add_allergy_as_the_bot(fx, saved[0], actor_name) + + effect = Effect( + kind=EffectKind.RECORD_WRITTEN, + match={"lname": ValueExpr(param="lname"), "email": ValueExpr(param="email")}, + ).resolve(params) + window = _window(fx, since) + users = {r[1]: r[0] for r in _rows(fx, f"SELECT id, username FROM {DB}.users")} + + def judge(whole_database: bool) -> FrameVerdict: + surfaces: dict[str, Any] = { + "patient": ( + EffectState(substrate="sql", reachable=True, records=before_patients), + _patients(fx), + ) + } + if whole_database: + surfaces["lists"] = ( + EffectState(substrate="sql", reachable=True, records=before_lists), + _lists(fx), + ) + return judge_frame( + _spec(actor_name, whole_database=whole_database), + [effect], + surfaces, + params=params, + routes={"0": ["patient"]}, + audit_before=[], + audit_after=window, + user_ids=users, + ) + + return judge(False), judge(True), window + + +def _tables(window: list[dict[str, Any]], actor: str) -> set[str]: + return {e["tbl"] for e in window if e["user"] == actor} + + +def test_without_the_whole_database_an_audit_frame_never_holds(openemr: Any) -> None: + unread, _whole, window = _save_and_judge(openemr, allergy=True) + assert "lists" in _tables(window, _actor(openemr)), ( + "the bot's allergy entry is not in the window" + ) + assert unread.status == "not_checked" + assert not unread.held + assert "frame.database" in unread.reason + + +def test_the_bots_allergy_for_the_saved_patient_refutes(openemr: Any) -> None: + _unread, whole, _window = _save_and_judge(openemr, allergy=True) + assert whole.status == "refuted", whole.reason + + +def test_the_save_alone_holds_with_the_whole_database(openemr: Any) -> None: + """Control: the same frame over the same tables holds for a clean save, + so the refutation above is the allergy's.""" + + unread, whole, _window = _save_and_judge(openemr, allergy=False) + assert unread.status == "not_checked" + assert whole.status == "held", whole.reason