Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/flow/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/flow/docs/VERIFY_RUN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions packages/flow/openadapt_flow/runtime/effects/frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
212 changes: 152 additions & 60 deletions packages/flow/tests/test_frame_conditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions packages/flow/tests/test_frame_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"


Expand Down
Loading
Loading