diff --git a/packages/flow/CHANGELOG.md b/packages/flow/CHANGELOG.md index bb68a6a4c..12cab9289 100644 --- a/packages/flow/CHANGELOG.md +++ b/packages/flow/CHANGELOG.md @@ -21,6 +21,10 @@ - `scaffold-verifier` closes three gaps the zero-config benchmark found. A generated REST read now carries `records_complete: true` when `--complete-read` declares it or one unpaged response was seen to hold the whole collection; otherwise it stays incomplete and `verify-run` returns INDETERMINATE. A step counts as a write when the capture shows the page sending a POST, PUT, PATCH or DELETE to its own origin, or its store snapshots differ, not only when its label looks like Save. `--probe-reads` (with optional `--probe-base`) finds a read for an app that never reads the saved record back: it probes the collection and item reads the write implies with GET on the local machine, uses one only when it returns the written values and nothing outside it could change unseen (otherwise it refuses and writes the TODO draft), and records every probe (`read_discovery` in the proof, or `read_discovery.json` when none confirms). +### 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. + ## v2.7.0 (2026-11-09) Flow adds `openadapt wrap`, which turns one agent task into a compiled program diff --git a/packages/flow/docs/VERIFY_RUN.md b/packages/flow/docs/VERIFY_RUN.md index b53dfa6b2..1f5c7f67d 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 0a89c01bc..bfb354946 100644 --- a/packages/flow/openadapt_flow/runtime/effects/frame.py +++ b/packages/flow/openadapt_flow/runtime/effects/frame.py @@ -2176,6 +2176,23 @@ def names_moved(entry_ref: Any) -> bool: unverified_columns=unverified_columns, ) 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 343c0545c..93114c68b 100644 --- a/packages/flow/tests/test_frame_conditions.py +++ b/packages/flow/tests/test_frame_conditions.py @@ -225,7 +225,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 @@ -282,10 +284,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"), @@ -472,84 +475,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 e2a5823c1..dbd290478 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 000000000..3bcf07feb --- /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