Repository navigation
openadapt-flow 2.5.0
Shadow mode checks the writes of an agent you already run against the system of record without blocking any of them, the whole-database frame works on PostgreSQL, a write that lands after the verdict can be caught by a recheck, and several verifier checks that could be unreadable or pass on the wrong value now read and compare exactly.
Features
-
Self-repair's new clicks no longer need a browser. On a backend with an accessibility tree, a click with no record identity under it is admitted when its element (role, name, ancestry, window) is the only one with that locator in the pinned window and a record check covers every save: a system-of-record verifier, a
standardorregulatedprofile, and confirmed effects and an armed identity check on each save. On a pixel-only backend the record check alone admits it. The new step carries the dialog around it as its identity band, a screen pre-check, and only a person approves such a fix; a signed standard change can't. The browser rule is unchanged.MacOSBackend.element_identity_atreads the AX element, the repair hold answerselement_identity, and agent teach takes the dialog box from it. Design:docs/REPAIR_ON_EVERY_BACKEND.md(#1709). -
Shadow mode:
openadapt shadowruns beside an agent you already have, as a loopback pass-through proxy, and checks each write it makes against the system of record (CONFIRMED,REFUTED,UNCERTAIN). It never blocks, changes or resends a write; the only delay is the read before a write, at mosthold_ms, measured per write. FHIR writes are checked byensure_record.verify_ensureand SQL writes byverifier.begin_write/verify_writewith the whole-database frame. EachCONFIRMEDis checked again later (recheck_s, or the contract'srecheck_after_s), and a lateREFUTEDgets its own line inreport.htmlandreport.json.verify_ensuretakesdeclared: resources of other writes the caller saw, judged on their own (#1678). -
The whole-database frame (
frame.database) and its recheck now work on PostgreSQL (dialect: postgresql). The snapshot is one read-only repeatable-read transaction over every schema but the system ones. Every column type is read as text by a declared rule, in a session pinned to UTC and exact float output:numericat full precision (with its stored scale when it has no declared one), floats exactly,byteaas hex,jsonas stored andjsonbcanonical, arrays, ranges, enums, domains,uuid, network, time,interval,money, bit,xml, text-search and geometric types. A table without a primary key is keyed by the whole row, with duplicates counted. A column type without a rule, a table the account can't read in full (noSELECT, row-level security), a foreign table and an unreadable sequence are refused unless the allow-list skips them with a reason. A sequence a column owns is that table's counter, and a counter set back counts as a change; any other sequence is compared as a one-row table. Large objects are read by the SHA-256 of their content.frame.database.schemasmeans the same as on MySQL (each must be visible, and every visible schema is read); a schema is left out only by anallow_tablesentry<schema>.*withmode: skipand a reason. -
A lost update now stops the run.
frame.versionsnames a read of the target's versions (HAPI'shfj_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 withoutframe.versionskeep 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), andopenadapt_flow.verifier.recheckreads 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 intoREFUTED(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 givesUNCERTAIN. The saved state holds keyed hashes, not values.frame.database.settle_attemptslets 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). -
A
CONFIRMEDwrite with a recheck due now says it is provisional (WriteResult.provisional,provisionalinto_dict()). MCP clients ofopenadapt verify servecan run the recheck with the newverify_rechecktool. A recheck that givesREFUTEDorUNCERTAINnow resolves the write's ledger attempt again for review, so a retry under the same key is held for a person instead of being handed the earlierCONFIRMED. A recheck from a savedrecheck.jsonfinds the attempt by the receipt'scorrelation_id; the result carries its ledger (WriteResult.ledger); when no attempt is re-resolved, the recheck's reasons say so. Over MCP,verify_recheckexcuses the agent's own later writes under the same contract.
Fixes
-
A composite primary key in the whole-database frame is now kept as a JSON array of its values. Before, the values were joined with
|, so the keys (x|y,z) and (x,y|z) were the same, one row hid the other, and deleting it went unseen. This applies to every dialect. A snapshot or recheck state saved by an earlier version keys rows differently, so recheck a write with the version that checked it. -
The read-only SQL guard now reads the query's tokens instead of matching substrings.
REPLACE(phone, '-', ''), a;inside a string literal, and a table quoted as`call`no longer refuse the query. Every write and multi-statement form it refused before is still refused. It now also refuses a backslash, a dollar-quoted string, an unterminated literal, and a call toquery_to_xmloreval, which run a query passed as text. When the query has parameters, it also refuses a%placeholder inside a literal or quoted name (the driver splices the value in as SQL;%%is fine). It refuses a dollar quote right after a number, row locks (FOR SHARE,FOR KEY SHARE,LOCK IN SHARE MODE), andget_lock,pg_advisory_*,pg_sleep_for,pg_sleep_until,master_pos_wait,source_pos_wait,pg_terminate_backend,pg_cancel_backend,load_file,pg_read_file,pg_ls_dirandpg_notify. -
The whole-database snapshot reads a SQLite
REALwith 17 digits, so 0.3 and 0.1+0.2 no longer read the same, and the text"\x00null"no longer reads as NULL. A number's plain value, which selectors compare with, is the shortest text that reads back as the stored value (1.1for a MySQLFLOAT,1.5for aDECIMAL(24,8)), so a correct write is no longer stopped because a selector stopped matching. -
The whole-database frame (
frame.database) now reads MySQL and MariaDB schemas with every column type. APOINTcolumn (Dolibarr 21) made the read raiseTypeError; aBITcolumn did the same;FLOATvalues compared by six printed digits; decimals lost digits past 28. Binary strings,BIT, geometries and vectors are read as hex,FLOATis widened toDOUBLE,JSONis read as its stored text, and numbers parse asDecimal. A value that still can't be read exactly makes the database unreadable, never a column dropped, andbegin_writenow says why the frame could not be read. -
A parameter set to
Noneno longer makes the record check unreadable. Before,verify_writereturnedUNCERTAIN("could not be read back") whatever was written, because the null could not be turned into text. Now aNonein an effect'smatch,new_recordsorvaluematches only a field that is present and null. A missing field matches only where the contract declaresabsent_is_null: trueon it. A null never matches""or any other value, and a stored null no longer matches the text"None". ANonewhere a null can't be bound (a query parameter, a key, the frame, a screen read) is refused atbegin_write. The write ledger keysNoneand""as different writes; a null write is held while any attempt stands under its old key (Nonekeyed as""), and that attempt's result is never handed back. A read that loses information (a FHIR path that never resolves, an outer join that filters rows) can confirm a null, so match a null only on a base column (#1660). -
Behavior change:
field_equalswith an expected""no longer passes on a storedNULL. A contract that clears a field on an app that stores a cleared field asNULLshould expectNone. -
A whole-number float parameter now matches its stored value. Before,
hours: 2.0reached the check as the text"2"and a SQLfloat8column read back as2.0, so the two never matched and a correct write was refuted. Now a value the store typed as a number (a SQL numeric column; a JSON number) compares with an expected number by exact decimal value. Text stays byte-exact, and there is no tolerance.