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
2 changes: 1 addition & 1 deletion CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ improvising or retrying when something is off.
| Judge an "ensure the record exists" write (found, created once with every value, or needs review) through a FHIR server's read path only: searches, AuditEvent and `_history`, never the database; the coverage claim names that read path. An agent's own correction of the record it created counts as one write when the audit trail and history show only that agent, no other reader, and the same record in every version | On main ([#1640](https://github.com/OpenAdaptAI/OpenAdapt/pull/1640), corrections [#1655](https://github.com/OpenAdaptAI/OpenAdapt/pull/1655)) | Real app (Medplum 5.2.8; three blind fault suites: 17/18, 17/20, 19/20; no fault passed in the last two. Corrections, on 15 blind cases and the 37 public ones: false stops 8 to 2 and 9 to 4 for Claude, no harm passed that the old rule caught) | `openadapt_flow/ensure_record.py`, [ENSURE_RECORD.md](packages/flow/docs/ENSURE_RECORD.md) |
| Generate an ensure-record contract from three demonstrations, the server's CapabilityStatement and its SearchParameter definitions: field paths and `[system=…]` filters, comparison kinds, value maps, identity, search parameters, the closed record and the change feed. It refuses when unsure, asks a person to confirm an identity for a type with no reviewed template, and self-checks every demonstration plus a perturbation per field | On main ([#1653](https://github.com/OpenAdaptAI/OpenAdapt/pull/1653)) | Real app (Medplum 5.2.8: all 16 hand-written Patient lines regenerated; one blind write type, Immunization: 78 s of person time, 2 lines edited after the person declined the proposed identity; blind suite 23/24, no fault passed) | `openadapt_flow/generate/ensure_contract.py`, [RESULTS.md](packages/flow/benchmark/contract_autogen/results/RESULTS.md) |
| A stop card for a stopped write: the exact record difference and who else wrote, with Undo or Fix that reads back, or Open in the app; cost modelled from published operators and timed machine steps, as pre-registered | On main ([#1653](https://github.com/OpenAdaptAI/OpenAdapt/pull/1653)) | Real app (Medplum 5.2.8, blind suite: 16 stops, 14 one-tap with no failed read-back; modelled 42 s per caught harm against 420; one wrong category on a false stop, gone in the re-measure with `void`: 24/24, 15 stops, no wrong category, no failed read-back) | `benchmark/stop_card/`, [PREREG_REVIEW.md](packages/flow/benchmark/stop_card/PREREG_REVIEW.md) |
| Generate `openadapt.verify/v1` contracts from a MySQL, MariaDB or PostgreSQL schema, the kinds of write with their parameter names, and one demonstration write per example: one contract per field set (an update gets one for each set of fields it may change), list parameters by length, a `found` twin for a record already in place, the whole-database frame on every dialect, rows the app adds on its own declared by count, a recheck window the person confirms, and `frame.versions` (a lost update) from a version column or the app's audit trail of saves. Every undecided point is one numbered question; every contract is loaded and dry-read against the live store before it is written; a needed `TRIGGER` grant or view is applied with a flag or the kind is refused. Not yet: OpenAPI metadata | [#1672](https://github.com/OpenAdaptAI/OpenAdapt/pull/1672) | Real app (first study, instance copies; questions for a person: Dolibarr 4, Redmine 2, EspoCRM 3, against 11 hand decisions each; every in-scope planted fault stopped (22/22, 19/19, 21/21); correct writes confirmed 32/43, 32/38, 27/40; evidence in the private study repository) | `python -m openadapt_flow.generate.verify_contract`, [GENERATE_CONTRACTS.md](packages/flow/docs/GENERATE_CONTRACTS.md) |
| Generate `openadapt.verify/v1` contracts from a MySQL, MariaDB or PostgreSQL schema, the kinds of write with their parameter names, and one demonstration write per example: one contract per field set (an update gets one for each set of fields it may change), list parameters by length, a `found` twin for a record already in place, the whole-database frame on every dialect (it compares the contract's own tables too, so an excused column holds only on the rows the write declares, and the recheck sees a late change to a look-alike row), rows the app adds on its own declared by count, a recheck window the person confirms, and `frame.versions` (a lost update) from a version column or the app's audit trail of saves. Every undecided point is one numbered question; every contract is loaded and dry-read against the live store before it is written; a needed `TRIGGER` grant or view is applied with a flag or the kind is refused. Not yet: OpenAPI metadata | [#1672](https://github.com/OpenAdaptAI/OpenAdapt/pull/1672) | Real app (first study, instance copies; questions for a person: Dolibarr 4, Redmine 2, EspoCRM 3, against 11 hand decisions each; every in-scope planted fault stopped (22/22, 19/19, 21/21); correct writes confirmed 32/43, 32/38, 27/40; evidence in the private study repository) | `python -m openadapt_flow.generate.verify_contract`, [GENERATE_CONTRACTS.md](packages/flow/docs/GENERATE_CONTRACTS.md) |
| `void` in an ensure-record contract: values that make a record assert nothing (`status="entered-in-error"`). A record holding one is never the person, is not a duplicate, and a created one needs review; it names values to leave out, never values to keep, and can't name an identity field | On main ([#1657](https://github.com/OpenAdaptAI/OpenAdapt/pull/1657)) | Real app (Medplum 5.2.8: 8 live cases written in advance, 8/8 with the line, 5/8 and one silent pass without it; frozen blind Immunization suite 24/24); adversarial review recorded | `openadapt_flow/ensure_record.py`, [RESULTS.md](packages/flow/benchmark/fhir_medplum/void/results/RESULTS.md), [REVIEW_VOID.md](packages/flow/benchmark/fhir_medplum/void/REVIEW_VOID.md) |
| Check a patient registration on a commercial EHR through its public FHIR sandbox: Epic (open.epic, R4), read through FHIR search and read with SMART backend auth; the frame is the search set, since the sandbox has no AuditEvent or `_history` | On main ([#1656](https://github.com/OpenAdaptAI/OpenAdapt/pull/1656)) | Real app (Epic public sandbox, August 2026 release; blind round 12/13 with none passed as verified, the miss a false refusal fixed in the contract; caught Epic dropping the phone on a 201 create) | `benchmark/commercial_ehr/`, [FINDINGS.md](packages/flow/benchmark/commercial_ehr/FINDINGS.md) |
| A public benchmark a vendor reruns with their own agent: `openadapt bench medplum --agent <scripted|claude|cmd:...|py:...>` runs 37 written-for-it cases (Patient, AllergyIntolerance, Appointment) against a local Medplum through a fault proxy, and scores the agent's claim, a read-back and OpenAdapt's verifier against an end-state oracle | On main ([#1652](https://github.com/OpenAdaptAI/OpenAdapt/pull/1652)) | Real app (Medplum 5.2.8; Claude Sonnet 5.5: OpenAdapt missed 1 of 9 harms with 4 false stops, all corrections that changed the record's identity, against 8 missed by the claim and the read-back; cold run from clone to scorecard 5 min) | [README](packages/flow/benchmark/fhir_medplum/public/README.md), [RESULTS.md](packages/flow/benchmark/fhir_medplum/public/RESULTS.md) |
Expand Down
12 changes: 11 additions & 1 deletion packages/flow/docs/GENERATE_CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,17 @@ refused. A mapping without `accept: true` takes only what it names.
in any table that the contract doesn't declare stops the write, except rows
added to a table the person named as a log. The main record's table and the
tables of rows that hold values are read by the contract's own reads, with
every column. On PostgreSQL the read account also needs `SELECT` on the
every row and column. Those tables are compared too
(`frame.database.judged_by`): the rows the contract's read declares (the
target, or the rows the write adds) are judged by the table, every column
exactly, with only their declared fields allowed to change, and any other
row that moves stops the write. A table's key is read as `id` unless the
table has its own `id` column that isn't the whole key; then it is read as
`oa_key` and `id` as it is. A new declared row may set any column of these
tables (`new_row_fields`), since the contract's read checks each one. An excused column is excused only on the rows the
write declares, never on a look-alike row of the same table. The recheck
compares these tables like any other, so a late change to any of their rows
stops the write. On PostgreSQL the read account also needs `SELECT` on the
sequences (see [VERIFY_RUN.md](VERIFY_RUN.md)); without it the dry read
fails and no contract is written.

Expand Down
29 changes: 29 additions & 0 deletions packages/flow/docs/VERIFY_RUN.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,35 @@ refuted. The read account needs `TRIGGER` and `SHOW VIEW` besides `SELECT`:
without them a trigger planted in the window can't be seen, and the frame is
not checked.

`judged_by` names a table whose rows one of the contract's own reads
declares, such as an update's read of its main record's table:

```yaml
judged_by:
assets: record # the table, and the surface that declares its rows
```

The surface only says which rows are declared: its records, before or after
the save, that an effect routed to it selects, matched to the table by
primary key. The table judges every row itself, every column exactly as
stored, so a lossy read (`substr`, `COALESCE`, a cast) can't hide a change.
A declared row may change only the surface's fields in
`target_fields_by_surface`, and a declared new row may set only the columns
the effects name or `new_row_fields` lists; every other column must hold its
default. Every other row of the table must not move, and the app profile's
bookkeeping rules apply as on any table. A recheck reads no surface, so
there nothing is declared and any change since the post-save read refutes
the write.

A `judged_by` table is compared, not allow-listed, so it can't also appear
in `allow_tables` or `views`. One surface declares rows of one table only,
and the surface may not ignore a field (`frame.ignore_fields`, or a
`record_field` attribution's `server_set`) that the table doesn't ignore
(`ignore_columns`). When a declared record's key can't be read, nothing is
declared, and the change stops the write. The database is read before the
surfaces, so a change can't land between the surface's read and the read a
recheck compares with.

Every column type is read, and none is dropped. On MySQL and MariaDB each
row is read as `JSON_OBJECT`. A type whose value it can't carry as exact text
is read another way: binary strings, blobs, `BIT`, geometries and vectors as
Expand Down
65 changes: 41 additions & 24 deletions packages/flow/openadapt_flow/generate/verify_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -1246,7 +1246,7 @@ def _versions_frame(
return {
"read": {"kind": "sql", **settings.read, "sql_query": sql},
"record_field": "id",
"target_field": "id",
"target_field": _key_alias(b.schema, plan.anchor),
"target_surface": "record",
"version_field": "ver",
}
Expand All @@ -1271,7 +1271,7 @@ def cond(c: str, val: Any) -> str:
return {
"read": {"kind": "sql", **settings.read, "sql_query": sql},
"record_field": "id",
"target_field": "id",
"target_field": _key_alias(b.schema, plan.anchor),
"target_surface": "record",
"version_field": "ver",
"author_field": "author",
Expand Down Expand Up @@ -1621,15 +1621,28 @@ def sql(self, b: SqlBuilder) -> str:
return f"SELECT {cols} FROM {b.qt(self.table)} {self.alias}"


def _key_alias(schema: Schema, table: str) -> str:
"""The name a surface reads the table's key under: ``id``, unless the
table has its own ``id`` column that is not the whole primary key (a
``uuid`` key, a key of ``id`` and ``rev``), which must be read as it is."""

t = schema.tables[table]
if t.column("id") is None or t.primary_key == ["id"]:
return "id"
return "oa_key"


def _base_columns(b: SqlBuilder, table: str, alias: str = "t") -> list[tuple[str, str]]:
"""Every column of ``table`` by its own name, and the key under
:func:`_key_alias` when that isn't a column already."""

t = b.schema.tables[table]
cols: list[tuple[str, str]] = []
key = b.key(alias, table)
if key is not None:
cols.append(("id", key))
name = _key_alias(b.schema, table)
if key is not None and t.column(name) is None:
cols.append((name, key))
for c in t.columns:
if c.name == "id" and key is not None:
continue
cols.append((c.name, b.text(alias, table, c.name)))
return cols

Expand Down Expand Up @@ -1724,6 +1737,8 @@ def build_contract(
#: Surface -> the fields its declared rows may change.
by_surface: dict[str, set[str]] = {}
named_tables: set[str] = {anchor}
#: Table -> the surface that reads it in full (``frame.database.judged_by``).
judged: dict[str, str] = {anchor: "record"}

# The main record, with every column and the values its parameters reach.
main = Surface(
Expand Down Expand Up @@ -1849,6 +1864,7 @@ def build_contract(
sname, settings.views.get(g.table, g.table), scols
)
named_tables.add(g.table)
judged.setdefault(g.table, sname)
surf = surfaces[sname]
items = range(variant.lists[p]) if g.list_param else [None]
for i in items:
Expand Down Expand Up @@ -2025,29 +2041,24 @@ def build_contract(
if t in named_tables:
continue
allow.append({"table": t})
for t in sorted(named_tables):
if plan.mode == "create" or flavor == "found":
# The contract's own read judges the rows the write adds; the
# whole-database frame still sees any existing row change,
# now and at the recheck (a late edit to a look-alike).
allow.append({"table": t})
continue
# An update changes a row here, which an append-only table
# can't allow: its own read judges this table, and the recheck
# (database only) doesn't see it.
allow.append(
{
"table": t,
"mode": "skip",
"reason": "read in full, every column, by this contract's own surface",
}
)
views = {s.table: s.name for s in surfaces.values() if s.name.startswith("raw_")}
# A table the contract's own read covers, every row and column, is
# compared like any other: after the save, the rows that read saw move
# are left to it (the target, or the rows the write adds, each with only
# its declared fields), and every other row of the table must not move.
# A recheck reads no surface, so there any change to the table since the
# post-save read (a late checkout of a look-alike) stops the write.
judged_by = {t: judged[t] for t in sorted(named_tables)}
views = {
s.table: s.name
for s in surfaces.values()
if s.name.startswith("raw_") and s.table not in judged_by
}
db: dict[str, Any] = {
"dialect": schema.dialect,
"read": dict(settings.read),
"allow_tables": allow,
"views": views,
"judged_by": judged_by,
"settle_s": 1,
"settle_attempts": 5,
}
Expand All @@ -2056,6 +2067,12 @@ def build_contract(
for s in surfaces.values()
if s.name.startswith("raw_") and getattr(s, "_new_row_fields", None)
}
for t in judged_by:
# A row the contract's own read declares is judged by the table
# too, every column exactly. A new one may set any column: the read
# checks each (placed, pinned, pinned empty, or named unchecked by
# the person).
nrf.setdefault(t, sorted(c.name for c in schema.tables[t].columns))
if nrf:
db["new_row_fields"] = nrf
if settings.recheck_after_s:
Expand Down
24 changes: 21 additions & 3 deletions packages/flow/openadapt_flow/runtime/effects/frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,14 @@ class FrameDatabase(BaseModel):
#: Table -> the surface whose records it holds (``patient_data`` ->
#: ``patient``): an effect routed to the surface declares rows here too.
views: dict[str, str] = Field(default_factory=dict)
#: Table -> a surface that declares its rows (a contract's own read of
#: its main record). After the save, the table's rows that surface's
#: records select (by an effect routed to it, matched by primary key)
#: are declared rows of the table: the table judges them, every column
#: exactly, with the surface's ``target_fields_by_surface`` fields as the
#: ones they may change. A recheck reads no surface, so there nothing is
#: declared and any change since the post-save read refutes.
judged_by: dict[str, str] = Field(default_factory=dict)
#: Table -> columns a new declared or side row may set beyond the fields
#: the effects name (ids, creation dates, columns the app's form fills
#: on its own). Any other column must hold its default.
Expand Down Expand Up @@ -363,6 +371,8 @@ def _compact(self, handler: Any) -> dict[str, Any]:
data.pop("recheck_after_s", None)
if data.get("settle_attempts") == 1:
data.pop("settle_attempts", None)
if not data.get("judged_by"):
data.pop("judged_by", None)
return data

@model_validator(mode="after")
Expand Down Expand Up @@ -785,6 +795,7 @@ def judge_frame(
]
] = None,
user_ids: Optional[Mapping[str, str]] = None,
declared_rows: Optional[Mapping[str, Collection[str]]] = None,
) -> FrameVerdict:
"""Judge the frame of ``effects`` over ``slices``.

Expand Down Expand Up @@ -1339,13 +1350,18 @@ def attribute(
added_by_others: set[tuple[str, str]] = set()
views_of = dict(database.views) if database is not None else {}

#: Table -> the surface that declares its rows (``database.judged_by``).
judged_of = dict(database.judged_by) if database is not None else {}
surface_declared = {t: set(k) for t, k in (declared_rows or {}).items()}

def may_change_for(name: str) -> set[str]:
by_surface = spec.target_fields_by_surface
if by_surface is None:
return may_change
if name in by_surface:
return set(by_surface[name])
return set(by_surface.get(views_of.get(name, ""), []))
surface = views_of.get(name) or judged_of.get(name, "")
return set(by_surface.get(surface, []))

declared_keys: set[str] = set()
#: What an audit entry names for each compared record (``ref_of``).
Expand Down Expand Up @@ -1525,8 +1541,10 @@ def partner(
before = before_map.get(key)
after = after_map.get(key)
is_declared = (
before is not None and declared(before, hashed=hasher is not None)
) or (after is not None and declared(after))
(before is not None and declared(before, hashed=hasher is not None))
or (after is not None and declared(after))
or str(key) in surface_declared.get(name, ())
)
refs[(name, str(key))] = ref_of(name, key, before, after)
if is_declared:
ref = refs[(name, str(key))][0]
Expand Down
Loading
Loading