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
1 change: 1 addition & 0 deletions CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ improvising or retrying when something is off.
| Never send a write twice while its outcome is unknown: an append-only ledger on local disk (pluggable backend), keyed by the write's identity, that every adapter, the MCP tools and the CLI consult before sending; a read-back against the attempt's own baseline resolves it, and a refuted write waits for a person (`openadapt verify ledger list`, `reconcile`, `release`) | On main | Synthetic (MockMed's fault server: a worker killed between the send and the read-back, killed during a timed-out send, killed before the send, a late write, and two worker processes started together on one write) | `openadapt_flow/write_ledger.py`, [VERIFIER_API.md](packages/flow/docs/VERIFIER_API.md#the-uncertain-write-ledger) |
| Catch a write that lands after the verdict (a background job, a trigger, a delayed side record): the receipt says when a recheck is due, and the recheck reads the whole database again and refutes the earlier result when a change since the post-save read has no explanation (`frame.database.recheck_after_s`, `verifier.recheck`) | On main ([#1639](https://github.com/OpenAdaptAI/OpenAdapt/pull/1639)) | Synthetic (SQLite); opt-in live OpenEMR test | `openadapt_flow/verifier.py`, `runtime/effects/late_write.py`, [VERIFY_RUN.md](packages/flow/docs/VERIFY_RUN.md#writes-that-land-later) |
| Diff the whole database for the frame (`frame.database`) on SQLite, MySQL and MariaDB. On MySQL and MariaDB every column type is read exactly (binary strings, `BIT`, geometries and vectors as hex, `FLOAT` widened to `DOUBLE`, `JSON` as stored text, decimals at full precision), so a row the save adds, changes or removes outside the declared records, such as an extra planned call or a stream note, is refuted unless a declared effect, a side record, an attributed concurrent write or an allow-listed bookkeeping table explains it | On main ([#1661](https://github.com/OpenAdaptAI/OpenAdapt/pull/1661)) | Real app (Dolibarr 21 on MariaDB 11.4: all 387 tables read where the frame raised `TypeError`; EspoCRM on MariaDB 11.4: the first-contact study's extra call and stream note refuted, correct opportunities verified); live MariaDB type tests | [VERIFY_RUN.md](packages/flow/docs/VERIFY_RUN.md#the-whole-database), `runtime/effects/db_snapshot.py` |
| Catch a lost update: another user saves a change to the record while the agent works, and the agent saves its stale copy over it. Before and after look like a correct update, so `frame.versions` reads the target's versions (FHIR or SQL version history, a version column, or the app's audit or action-history table) before and after the write. A version by another author, or more than one version step when no author is read, refutes the write; a version that can't be read or attributed stops it | On main ([#1662](https://github.com/OpenAdaptAI/OpenAdapt/pull/1662)) | Real app (HAPI FHIR and EspoCRM, first-contact study fault scripts: both lost updates refuted, correct updates verified, every other case's verdict unchanged) | [VERIFY_RUN.md](packages/flow/docs/VERIFY_RUN.md#lost-updates), `runtime/effects/versions.py` |
| Guarded save: a safety layer any agent wraps around one write (check the record, hold for approval, read back) | On main ([#1258](https://github.com/OpenAdaptAI/OpenAdapt/pull/1258)) | Synthetic | `GUARDED_SAVE.md` |
| A person's approval in Desktop is signed with a key kept behind Touch ID on a Mac: one-prompt setup in the app, a prompt for every answer, and Flow never sees the key | On main ([#1500](https://github.com/OpenAdaptAI/OpenAdapt/pull/1500)) | Unit tests; opt-in test on a real Secure Enclave | `packages/desktop/openadapt_desktop/approver_keychain.py`, [APPROVAL_MODES.md](packages/flow/docs/APPROVAL_MODES.md#the-approver-key) |
| The approver root Desktop trusts can be pinned by an administrator, in a file this user can't write (macOS, Linux, Windows); Desktop checks the owner, mode or ACL and refuses links. A Mac without one uses the root it set up behind Touch ID, read with Keychain prompts off; the two disagreeing is refused | On main | Unit tests per attack on real files; Windows ACL rules unit-tested, real-ACL test on Windows CI; opt-in test against the real login Keychain | `openadapt-desktop approver-pin`, `packages/desktop/openadapt_desktop/approver_anchor.py`, [APPROVAL_MODES.md](packages/flow/docs/APPROVAL_MODES.md#the-approver-key) |
Expand Down
1 change: 1 addition & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@ improvising or retrying when something is off.
| Never send a write twice while its outcome is unknown: an append-only ledger on local disk (pluggable backend), keyed by the write's identity, that every adapter, the MCP tools and the CLI consult before sending; a read-back against the attempt's own baseline resolves it, and a refuted write waits for a person (`openadapt verify ledger list`, `reconcile`, `release`) | On main | Synthetic (MockMed's fault server: a worker killed between the send and the read-back, killed during a timed-out send, killed before the send, a late write, and two worker processes started together on one write) | `openadapt_flow/write_ledger.py`, [VERIFIER_API.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/VERIFIER_API.md#the-uncertain-write-ledger) |
| Catch a write that lands after the verdict (a background job, a trigger, a delayed side record): the receipt says when a recheck is due, and the recheck reads the whole database again and refutes the earlier result when a change since the post-save read has no explanation (`frame.database.recheck_after_s`, `verifier.recheck`) | On main ([#1639](https://github.com/OpenAdaptAI/OpenAdapt/pull/1639)) | Synthetic (SQLite); opt-in live OpenEMR test | `openadapt_flow/verifier.py`, `runtime/effects/late_write.py`, [VERIFY_RUN.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/VERIFY_RUN.md#writes-that-land-later) |
| Diff the whole database for the frame (`frame.database`) on SQLite, MySQL and MariaDB. On MySQL and MariaDB every column type is read exactly (binary strings, `BIT`, geometries and vectors as hex, `FLOAT` widened to `DOUBLE`, `JSON` as stored text, decimals at full precision), so a row the save adds, changes or removes outside the declared records, such as an extra planned call or a stream note, is refuted unless a declared effect, a side record, an attributed concurrent write or an allow-listed bookkeeping table explains it | On main ([#1661](https://github.com/OpenAdaptAI/OpenAdapt/pull/1661)) | Real app (Dolibarr 21 on MariaDB 11.4: all 387 tables read where the frame raised `TypeError`; EspoCRM on MariaDB 11.4: the first-contact study's extra call and stream note refuted, correct opportunities verified); live MariaDB type tests | [VERIFY_RUN.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/VERIFY_RUN.md#the-whole-database), `runtime/effects/db_snapshot.py` |
| Catch a lost update: another user saves a change to the record while the agent works, and the agent saves its stale copy over it. Before and after look like a correct update, so `frame.versions` reads the target's versions (FHIR or SQL version history, a version column, or the app's audit or action-history table) before and after the write. A version by another author, or more than one version step when no author is read, refutes the write; a version that can't be read or attributed stops it | On main ([#1662](https://github.com/OpenAdaptAI/OpenAdapt/pull/1662)) | Real app (HAPI FHIR and EspoCRM, first-contact study fault scripts: both lost updates refuted, correct updates verified, every other case's verdict unchanged) | [VERIFY_RUN.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/VERIFY_RUN.md#lost-updates), `runtime/effects/versions.py` |
| Guarded save: a safety layer any agent wraps around one write (check the record, hold for approval, read back) | On main ([#1258](https://github.com/OpenAdaptAI/OpenAdapt/pull/1258)) | Synthetic | `GUARDED_SAVE.md` |
| A person's approval in Desktop is signed with a key kept behind Touch ID on a Mac: one-prompt setup in the app, a prompt for every answer, and Flow never sees the key | On main ([#1500](https://github.com/OpenAdaptAI/OpenAdapt/pull/1500)) | Unit tests; opt-in test on a real Secure Enclave | `packages/desktop/openadapt_desktop/approver_keychain.py`, [APPROVAL_MODES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/APPROVAL_MODES.md#the-approver-key) |
| The approver root Desktop trusts can be pinned by an administrator, in a file this user can't write (macOS, Linux, Windows); Desktop checks the owner, mode or ACL and refuses links. A Mac without one uses the root it set up behind Touch ID, read with Keychain prompts off; the two disagreeing is refused | On main | Unit tests per attack on real files; Windows ACL rules unit-tested, real-ACL test on Windows CI; opt-in test against the real login Keychain | `openadapt-desktop approver-pin`, `packages/desktop/openadapt_desktop/approver_anchor.py`, [APPROVAL_MODES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/APPROVAL_MODES.md#the-approver-key) |
Expand Down
2 changes: 2 additions & 0 deletions packages/flow/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

### Features

- A lost update now stops the run. `frame.versions` names a read of the target's versions (HAPI's `hfj_res_ver`, a version column, or an app's action-history table) and, where the app records one, the author of each version. The verifier reads them before and after the write; a version of a pre-existing target by anyone other than the agent, or more than one version step when no author is read, refutes the frame. A versions read that fails, a target with no version, or a version with no author leaves the frame not checked. The receipt holds counts and a problem code (`evidence.frame.versions`). Contracts without `frame.versions` keep their digests.

- A write that lands after the verdict can now be caught. With `frame.database.recheck_after_s`, a verified write's receipt says when a recheck is due (`recheck_due_at`), and `openadapt_flow.verifier.recheck` reads the whole database again at or after that time. It judges every change since the post-save read with the frame's rules: the app profile's bookkeeping, other users' writes the audit log attributes, and changes a later write by the same actor under the same contract saw in its own window (`excused_by`, any outcome). Anything else turns the earlier result into `REFUTED` (late). A change that may be what refuted a later write, a database that can't be read, or a saved state that wasn't issued here or doesn't match its receipt gives `UNCERTAIN`. The saved state holds keyed hashes, not values. `frame.database.settle_attempts` lets the reads after a save, and at a recheck, retry within a stated budget until two in a row match; rows added to append-only tables between them don't count as movement. Without these settings, nothing changes ([#1639](https://github.com/OpenAdaptAI/OpenAdapt/pull/1639)).

### Fixes
Expand Down
111 changes: 111 additions & 0 deletions packages/flow/docs/VERIFY_RUN.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,117 @@ that still can't be read exactly makes the database unreadable, and the run
stops; the error names the table. When the database can't be read at the
baseline, `begin_write` says why.

## Lost updates

Two snapshots can't see a lost update. The agent reads a record, another
user saves a change to it, and the agent then saves its stale copy over
theirs. After the save the record looks like "the agent changed the phone,
the address is unchanged", which is also what a correct update looks like.
Only the record's versions show the second save.

`frame.versions` names a read that returns one row per version of a record,
and the fields of a row that name the record, the version and, where the app
records it, the author:

```yaml
frame:
identity_field: id
versions:
read: # read-only, the same shape as `verifier:`
kind: sql
sql_driver: psycopg2
sql_connect_args: {host: 127.0.0.1, port: 5432, user: reader, dbname: hapi}
sql_password_env: HAPI_DB_PASSWORD
sql_query: >-
SELECT r.res_type || '/' || r.fhir_id AS id, v.res_ver::text AS ver
FROM hfj_res_ver v JOIN hfj_resource r ON r.res_id = v.res_id
record_field: id # the version row's record
target_field: id # the target's field in the read set (default: identity_field)
version_field: ver
# author_field: user_id # when the app records who saved each version
# self: [{param: bot_user_id}]
```

An app that logs each save with its user can name the author instead. EspoCRM's
`action_history_record` logs every API save; a lead has no version column:

```yaml
versions:
read:
kind: sql
sql_driver: pymysql
sql_connect_args: {host: 127.0.0.1, port: 3306, user: reader, database: espocrm}
sql_password_env: ESPOCRM_DB_RO_PASSWORD
sql_query: >-
SELECT CAST(CONCAT(LOWER(target_type), ':', target_id) AS CHAR) AS id,
CAST(number AS CHAR) AS ver, CAST(user_id AS CHAR) AS author
FROM action_history_record WHERE action <> 'read' AND deleted = 0
record_field: id # equals the read set's id (lead:<id>)
version_field: ver
author_field: author
self: [{param: bot_user_id}]
```

The versions are read before the write and after it. Every record that a
confirmed write effect names and that existed before the write is a target.
The versions of a target that are new in the window must be the agent's:

- With `author_field`, every new version's author must be one of `self`. A
version by anyone else is refuted as a lost update. A version with no
author is not checked. The read must be a history that only grows: every
version read before the write must still be there after it, or the check
is not checked. A read of only the current version and its last author
would hide the other user's save.
- Without it, versions must be whole numbers that step by one per save of
that record (HAPI's `res_ver`, a `version_number` column). At most one
step may happen. Two or more steps are refuted: step mode assumes the
agent saves each record once per run, so a second step is someone else's.
An agent that saves the same record twice (correcting its own write) needs
`author_field`. A version that went back is refuted. A version
that isn't a whole number (an opaque ETag) is not checked: without authors
the saves can't be counted. Prefer a history read, one row per version. A
read of only the current version counts steps as the difference, which
trusts the app's numbering: an app that numbers a save from the client's
copy (two saves from version 1 both write version 2) hides a lost update
there, while a history read shows the two rows as `duplicate_version`.
The numbers must be per record and
contiguous: a global sequence, such as an audit table's row id, also moves
for other records' saves, so a correct update would be refuted. Use
`author_field` with such a sequence.

Anything else stops the run, never a pass:

- a versions read that fails before or after the write;
- a target that existed but has no version, or a target record without the
field that names it;
- a version that reads as null, before or after (`null_version`);
- two rows of one record with the same version (`duplicate_version`): a
save could hide behind another;
- a target whose record changed in the window with no new version
(`no_new_version`): the app saved without a version row, so who saved
can't be told. A target that didn't change (already right, nothing
written) needs no new version; this relies on the read recording every
save, since a change reverted by a save the read misses looks unchanged.

A record created in the window has no earlier version to overwrite and isn't
judged. Whether a target existed is told by `frame.identity_field` (default
`id`), never by `target_field`'s value after the write: a target that existed
with no version under its current `target_field` value is `missing`. Only targets are judged: another user's save to an unrelated record
leaves this check as it was (the frame and its attribution judge that
record, as before). The versions are read before the records at the
baseline, so a save between the two reads shows in the record baseline. The
window starts at the baseline: a copy the agent read before `begin_write` is
not covered.

The baseline keeps versions as keyed MACs of the record and the version;
only a whole-number version's value is kept, to count steps. The receipt's
`evidence.frame.versions` holds counts and a problem code (`another_author`,
`extra_steps`, `went_back`, `no_author`, `not_a_number`, `null_version`,
`duplicate_version`, `no_new_version`, `history_changed`, `missing`,
`no_target_id`, `unreadable`, `self_unresolved`), never ids or values. A
refuted check refutes the frame (`checked.frame: refuted`); one that can't be
judged leaves the frame `not_checked`.

## What the receipt says was checked

The receipt's `checked` field names each part of the claim behind
Expand Down
21 changes: 21 additions & 0 deletions packages/flow/openadapt_flow/runtime/effects/frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
record_matches,
stable_id,
)
from openadapt_flow.runtime.effects.versions import FrameVersions, VersionVerdict

FrameStatus = Literal["held", "refuted", "not_checked"]
Attribution = Literal["other", "self", "unattributed"]
Expand Down Expand Up @@ -450,6 +451,19 @@ class FrameSpec(BaseModel):
side_records: list[FrameSideRecord] = Field(default_factory=list)
#: Diff the whole database (``FrameDatabase``).
database: Optional[FrameDatabase] = None
#: The target's versions, read before and after the save
#: (``versions.FrameVersions``): a version in the window that isn't the
#: agent's own is a lost update.
versions: Optional[FrameVersions] = None

@model_serializer(mode="wrap")
def _compact(self, handler: Any) -> dict[str, Any]:
# Unset, ``versions`` is left out, so a contract that doesn't use it
# keeps its digests (``frame_sha256``, the check digest).
data: dict[str, Any] = handler(self)
if data.get("versions") is None:
data.pop("versions", None)
return data

@model_validator(mode="after")
def _database_attribution(self) -> "FrameSpec":
Expand Down Expand Up @@ -591,6 +605,8 @@ class FrameVerdict:
added_by_others: set[tuple[str, str]] = field(default_factory=set)
#: In memory only: app profile rules that excused a row change.
excused_by: set[str] = field(default_factory=set)
#: ``frame.versions`` only: how the target's versions came out.
versions: Optional[VersionVerdict] = None
#: In memory only, set when the frame held and ``recheck_after_s`` is
#: set: what a later recheck compares with (the settled post-save
#: snapshot as keyed MACs, the audit entries seen then as keyed hashes,
Expand Down Expand Up @@ -621,6 +637,11 @@ def evidence(self, spec: FrameSpec) -> dict[str, Any]:
1 for c in self.collateral if c.other_on_target
),
**({"database": self.database} if self.database is not None else {}),
**(
{"versions": self.versions.evidence()}
if self.versions is not None
else {}
),
}


Expand Down
Loading
Loading