Status: kit surface implemented + CI contract-proven. Per-substrate
maturity is listed honestly below — the SQL and file/SFTP verifiers are
contract-proven against local fixtures (sqlite, fakes) in CI, not
live-proven against a production database or SFTP endpoint; the FHIR
verifier additionally has an opt-in live-OpenEMR test
(tests/test_effect_fhir_live_openemr.py).
Without a declared effect and a configured verifier, OpenAdapt falls back to screen evidence for a consequential write or read outcome. This kit exists so declaring the outcome and configuring the verifier is a reviewed YAML section, not a bespoke per-deployment integration.
The underlying design (three-valued verdicts, fail-safe-to-HALT, the shared
judge) is docs/design/EFFECT_VERIFIER.md.
This page is the operator kit: what to declare, how to configure each
substrate, how run parameters and secrets bind, and two worked examples lifted
from the reference apps.
- The bundle declares WHAT must be true — typed
Effectcontracts on each consequential step (record_writtenfor mutations;field_equalsfor a unique persisted field or independently read business outcome;exact_new_setfor the full set of records an action may add), at-most-once counts, idempotency keys, and{param: ...}references that bind to the run's governed parameters. Contracts are substrate-neutral. - The deployment declares WHERE truth lives — the
effects:section ofdeployment.yamlwires oneEffectVerifier(REST / GraphQL / FHIR / SQL / file / email / document / document-hash, the separate-session UI read-back when none of those is reachable, or a registered plugin adapter) plus its secret-isolated auth. When more than one reviewed read boundary is available,candidates:selects the strongest verification class for each resolved effect before input. It does not downgrade after input. An unavailable selected proof halts or enters reconciliation. - The runtime refuses to guess. Every verdict is CONFIRMED / REFUTED /
INDETERMINATE; both non-confirmed verdicts HALT. A step that declares
effects with no verifier configured HALTs. An escalated failure emits a
typed
ReconciliationTask(see below) — halt + evidence, never silent. - Certification measures coverage.
openadapt-flow lintreports per-consequential-step effect coverage (% of irreversible steps declaring a contract) and warns per gap (missing_effect_contract;same_channel_oraclewhen the declared oracle is the same page session, screenshot, or banner as the acting surface). A policy that setsrequire_effects_for_irreversible: trueturns a missing contract into a certification failure (warn-vs-fail is the policy's choice). API, SQL, file, and separate-session (ui_readback) reads are channel-disjoint; on-screen success is not.
An oracle read returns the whole collection. VerifierOracle.read and
JsonDocumentOracle.read in openadapt_flow/reward/oracles.py stamp the
episode's oracle_identity onto the observation and then hand back every
record they can see. Nothing filters those records by patient or by account.
So the effect's own match is where the subject enters. A {param: ...}
reference resolves against the episode identity before the judge runs, and the
judge keeps only the records that selector picks. Write the subject into
match and the contract asks about one patient. Leave it out and the contract
asks about the collection.
Right:
effects:
- kind: record_written
match:
patient_id: {param: patient_id} # binds to THIS episode's patient
type: Triage
expected_count: 1
count_new_only: true # and to THIS episode's writeWrong, and RewardBundle.load refuses it:
effects:
- kind: record_written
match:
type: Triage # any patient's triage row satisfies it
expected_count: 1The second contract still declares oracle.identity_keys: [patient_id], so
its receipt names a patient. Put one triage row in the store belonging to
somebody else and the judge CONFIRMS the effect: the receipt then asserts a
patient whose record was never read. The load-time guard closes that. A bundle
whose required effects select no record by a declared identity key fails to
load, and the error names the missing keys and the selector to add.
A required effect that names its subject can still be satisfied by a row that
was already in the store. record_written and field_equals are statements
about the store's current contents, so a rollout that did nothing collects
the full reward whenever the subject already has a matching row.
count_new_only and exact_new_set are the two kinds the judge settles
against the pre-episode baseline, so a reward contract's required effects
must include at least one of them. RewardBundle.load refuses a contract
where none does. The required effects are judged as a conjunction, so one
change claim is enough to make verified mean the episode added something:
pair a count_new_only write with as many field_equals read-backs as the
contract needs.
The baseline comes from begin_episode, which the environment calls before
the rollout runs. Without it the change claim is INDETERMINATE and the
episode is unscored, never zero.
json_file and screen_dump build the same reader over a JSON document on
the worker's own disk, so they read through the same channel, ocr, at tier
0. A contract that declares file for a json_file recipe fails to load.
Nothing in a JSON document separates a system-of-record dump from a screen
scrape, and letting the recipe kind decide would let the same bytes earn tier
0 under one name and tier 2 under the other.
sqlite earns tier 2 because the worker opens a real database read-only and
runs one SELECT through the engine, and it checks the file header, so a
screen dump renamed store.db is refused. rest and fhir earn it on the
network read the worker performs; whether the endpoint is the customer's
system of record is the bundle author's claim and nobody checks it here.
idempotency_key counts as a selector too, because it filters the matched set
by key_field. value does not. On a field_equals contract match chooses
the record and value asserts its content, so a subject id in value
describes whatever matched instead of choosing what to read.
identity_keys is not identity_field. identity_keys is the reward
contract's list of parameters that name the subject of an episode.
identity_field is the row id an exact_new_set effect reads to tell a record
added by the action from one that was already there, and an
environment-assigned surrogate key is the right choice for it.
effects.kind |
Verifier | Probe | Proven how |
|---|---|---|---|
onscreen |
OnScreenReadbackVerifier |
re-OCR the saved value off the live screen — the no-API default for GUI-only recordings; auto-derived from the demonstration. Different-path (re-open the record) is default-eligible; same-surface (re-read the write's own form) is opt-in only | measured in benchmark/effect_readback/ — different-path false-CONFIRM 0, same-surface > 0. A read-back CONFIRMED is a consistency signal, NOT transactional proof (docs/LIMITS.md) |
ui_readback |
SeparateSessionReadbackVerifier |
the application's own record page, opened by identifier in a separate browser with its own sign-in, read-only, fields read from the page structure. Tier 2: not a system-of-record read | CI against MockMed's server-rendered record pages in a real browser; measured in benchmark/ui_readback/ (see the section below) |
rest |
RestRecordVerifier |
GET a JSON records document (templatable path, secret-isolated auth headers) | live in CI against the MockMed transactional back end; Frappe-shaped read in the reference matrix (PR #131) |
fhir |
FhirEffectVerifier |
FHIR R4 search → flattened resources | CI against a byte-faithful fake FHIR server; opt-in live test against a real local OpenEMR |
graphql |
GraphQLRecordVerifier |
ONE read-only GraphQL query (a mutation/subscription refuses to construct), records extracted at a dotted path; optional freshness window → STALE demotion |
contract-proven in CI against a fake session -- read-only guard, entity binding, error-body handling, staleness; not live-proven against a production GraphQL endpoint |
sql |
SqlRecordVerifier |
ONE read-only SELECT (enforced whitelist), rows judged like any substrate | contract-proven in CI against sqlite fixtures only — the query/whitelist/verdict logic is what's proven, not any specific production database |
file |
FileArrivalVerifier |
directory / SFTP listing → size_ok + fresh + content_match per candidate |
contract-proven in CI against temp dirs and a fake SFTP transport — not live-proven against a real SFTP server |
email |
MaildirDeliveryVerifier |
maildir / SMTP-capture directory → one record per message (to, subject, message_id, body probe, freshness); verifies delivery TO THE CAPTURE POINT, not end-to-end receipt |
contract-proven in CI against maildir/tmp-dir fixtures (wrong recipient, duplicate send, leak-to-other-recipient collateral hook) -- not live-proven against a production MTA |
document |
DocumentArrivalVerifier |
report arrival + parseable-content assertion: each candidate parses (JSON dotted paths or named-regex groups) into judgeable fields; a corrupt report REFUTEs a parseable: True contract |
contract-proven in CI against temp-dir fixtures (corrupt report, wrong entity, duplicates, stale mtime) |
document-hash |
DocumentHashVerifier |
SHA-256 of each document in a store | live in CI (no external service) |
All substrates share one judge (runtime/effects/_common.py), so
at-most-once counting, idempotency-key de-duplication, field read-back,
collateral-loss detection, the duplicate-write guard, and the exact_new_set
over-write guard below behave identically everywhere.
Effect(kind=record_written, count_new_only=True, expected_count=1) counts
only records that did not exist in the pre-action snapshot: "exactly one
NEW matching record was created by this action." Use it when the selector
legitimately matches pre-existing rows (e.g. "an encounter for this patient").
It requires a readable pre-state — an unreachable baseline is INDETERMINATE →
HALT, never a guess. Available on every substrate.
Every other kind answers "is my record there?". None of them answers
"and nothing else?". A contract set that declares one record_written
per intended new record is silent about the records it never named, so an
actuation that writes the 6 intended rows and 31 unintended ones satisfies
every declared contract: the runtime CONFIRMS while the system of record holds
writes nobody asked for. That is a false pass — the one error direction
this design must never take. (The 6-vs-37 case is not hypothetical: it was
measured in a 150-trial benchmark study of an agent asked to download 6
records.)
Effect(kind=exact_new_set, ...) closes it. One table-scoped effect
declares the FULL set of records the action may add:
effects:
- kind: exact_new_set
# `match` is the SCOPE, not a target selector. Empty = the whole read set.
match: {user_id: "32"}
# One selector per intended record. Repeat a selector to declare that many
# identical additions. Values may be literals or {param: ...} references.
new_records:
- {user_id: "32", song_id: "199"}
- {user_id: "32", song_id: "9"}
# Must equal len(new_records). Stated explicitly so an edit that drops a
# member fails loud instead of silently weakening the contract.
expected_count: 2
# How a record ADDED by this action is told apart from one already there.
# A surrogate key is the RIGHT choice here even though it is the wrong
# thing to pin in a selector.
identity_field: idIt REFUTES: an addition no member names (the guard), a missing or duplicated
member, a wrong cardinality, and collateral loss inside the same scope. It
requires a real pre-action baseline: with an unreachable baseline, or a
record on either side carrying no identity_field value, the added set cannot
be enumerated and the verdict is INDETERMINATE → HALT. Newness is never
guessed. new_records: [] with expected_count: 0 is the meaningful
assertion "this action adds NOTHING to this read set."
Available on every substrate (it runs in the shared judge). It is judged against the pre-action snapshot, so the current-state read-back paths (durable resume, attended qualified read-back) refuse it rather than judge it against a synthesized empty baseline.
Backward compatibility and the honest boundary. This kind is additive
and opt-in. Flow contracts are operator-authored. There is no derivation
step that can turn the guard on for you. Thus, every contract written before
this option judges exactly as it did before, and its contract_hash is
byte-identical (the new fields enter the digest only on the new kind).
The boundary follows directly: an existing contract does not detect an
over-write unless the operator declares an exact_new_set effect for that
read set. Declare one on any step where an unintended extra write would
matter.
capture_table_counts(connect, tables) + audit_table_deltas(before, after, expected) promote the exact row-count-delta contract from the governed
Frappe Lending reference matrix (benchmark/frappe_lending/fixture.py, PR
#131): every table in the contract must move by exactly its declared delta and
every other audited table by exactly 0. This is a harness-level companion to
the verifier (it brackets a whole run, not one step).
For an application with no API, database or export, the person can show how
they would check the save at the end of the recording. In an authoring
session, begin_check() returns the prompt "Show how to check the save".
The person then navigates to the saved record (recorded like any other step)
and marks each field that shows a value they typed, with mark_field(param=..., node_id=...). The marks are stored beside the recording as check.json, and
compile_recording turns them into an onscreen read-back on the save step
(openadapt_flow/compiler/readback_authoring.py):
- the navigation leaves the task and becomes the read-back's re-navigation. A value typed into a search box (for example the patient's name) is replayed with this run's value;
- each marked field is a screen region, compared with the run parameter typed
into the form. Every field must read back. One unreadable field makes the
check
INDETERMINATE, and a readable field with another value makes itREFUTED. Either one halts the run; - compiling checks the marks against the demonstration. Each marked region must show the value typed for its parameter, or compiling fails. A check that types a secret, marks an input that wasn't typed, or comes before any save is refused.
Each field is read one of two ways:
- From the page structure. When the backend can read the text an element
shows (
displayed_text_at; the browser backend reads the DOM) and, at marking time, the marked element showed exactly the value typed for its parameter, the field is compiled withsource: structure. At runtime the element at the field's center must show exactly the expected value. A backend that can't read the structure makes the checkINDETERMINATE; it does not fall back to OCR. The page body is never read as a field. - By OCR of the marked region, otherwise.
Reading the structure avoids OCR misreads. It is not more independent: it is still the same application and session, and the tier is the same. Native accessibility reads (macOS, Linux) and template matching aren't implemented.
How strong it is, compared with a read outside the application:
| The check | Tier | What it can catch | What it can't |
|---|---|---|---|
| re-opens the record (at least two navigation actions) | 3, reacquired-state |
a save only the form showed; a wrong value in a marked field | a page served from a cache the write filled; a duplicate submit; a lost update by another writer; fields nobody marked |
| fields marked on the screen the save left | 4, screen-consistency |
a value the save's own screen shows wrong | anything the screen paints but the record never got |
ui_readback (separate signed-in browser session) |
2, independent-session |
the above, without sharing the acting session | it is still the same application, not its record store |
| REST, SQL, FHIR and other system-of-record reads | 1, independent-system |
what the record store holds |
A check the person shows is never a system-of-record read. Under
problems_only approval, a save checked this way still goes to a person
(onscreen_readback). At runtime it makes no model calls.
Use it when the workflow has no API, database, or export that Flow can read, which is the usual case for a legacy GUI. After the save, Flow:
- starts its own browser, never the one that typed, with a new, empty context (no cookies, storage, or service workers);
- signs in with the read-back account named in
sign_in; - opens
record_urlfor the record's identifier; - checks that the page shows that identifier (
identity); - reads each declared field from the page structure (
fields: element text, or an attribute such astitlewhen the visible text is shortened); and - judges the effect contract with the shared judge, so counts, duplicates, lost updates, and field values are judged as for every other substrate.
After sign-in the session can't write. A script installed in every page
refuses fetch, XHR, beacon, and form requests that aren't reads before they're
sent, and a network guard aborts any request other than GET, HEAD, or
OPTIONS. A refused request makes the read uncertain, so the step halts.
read_post_paths lists read endpoints that use POST (a GraphQL query, say)
when a page needs one.
The check declares tier 2 (INDEPENDENT_SESSION; Seal oracle tier 1,
"separate read-only session"). It doesn't go through the session that typed,
and it isn't a system-of-record read: the adapter sets
independent_system_of_record = False, its verdicts say "record page", and a
halt message names the read as a record page read in a separate session. Under
the Standard and Regulated profiles a tier-2 confirmation can end VERIFIED,
as before. The regulated policy pack needs tier 1, so this check doesn't
qualify a workflow for it.
Pilot: with per-entry approval only. While a person approves each entry
(approval_mode: each_item, the default), a confirmed UI read-back is record
evidence for that entry. It never lets an entry save without a person: under
problems_only, a save whose record check is the UI read-back goes to a person
with the detail ui_readback_pilot_each_item_only, and it doesn't count toward
the readiness test. Whether it can ever do more will be decided after it has
been measured on one real clinic's record page.
effects:
kind: ui_readback
ui_readback:
record_url: "https://app.example.org/patients/{patient}/encounters"
url_params: { patient: { param: patient_id } }
sign_in:
url: https://app.example.org/login
username_selector: "#username"
password_selector: "#password"
submit_selector: "button[type=submit]"
signed_in_selector: "#user-menu"
username_env: APP_READER_USERNAME
password_env: APP_READER_PASSWORD
ready_selector: "#encounters"
empty_selector: "#no-encounters"
row_selector: "tr.encounter"
fields:
id: { attribute: data-id }
type: { selector: "td.type" }
note: { selector: "td.note", attribute: title }
identity:
reader: { selector: "#patient-mrn" }
value: { param: patient_id }
field: patient_id| The record page shows | Verdict |
|---|---|
| the saved values | CONFIRMED |
| a different value, a missing or duplicated row, or a row that was there before the save and is gone | REFUTED. The run ends RECONCILIATION_REQUIRED: a row the page doesn't show is never taken as proof that nothing was saved, because pages can hide rows |
| another record, no readable identifier, rows the selectors no longer match, a shortened or unparseable value in a field the contract reads, a refused request, or a failed sign-in | INDETERMINATE (halt) |
Values are compared after collapsing whitespace, because HTML rendering
collapses it. date_format parses a displayed date into ISO 8601 and
casefold compares case-insensitively; both apply to the expected value too.
Measured. benchmark/ui_readback/RESULTS.md
runs the repository's fault list (benchmark/effect_e2e and
benchmark/fault_model) with the same effect contracts read three ways: on the
screen that typed, through this check, and through the API. In a real
browser replay it caught all 7 wrong saves that the screen showed as saved;
the same-screen check caught none and the API read caught 7. On the API-write
list it caught 5 of 7 and the API read 6 (the two misses are under Limits).
It stopped none of the correct saves in either list. Of nine probes that
change only how the record page displays a correct save, it stopped on five:
text the page shortens, a replica slower than the effect timeout, a redesign
that breaks the selectors, a page that posts when it loads, and a wrong
read-back password. The last two stopped before the save, so nothing was
written. These are counts over a small, hand-written fault list on synthetic
fixtures, not production rates.
- It sees only what the record page shows. A wrong write to a field or table
the page doesn't display passes (
collateral_unaudited, a stray billing row; the encounters API read missed it too). - It's only as independent as the page's own read path. A record page that
renders from a cache the failed write filled shows the save, and the check
passes (
page_cache). The API read caught that case. - Display changes stop it: text the page shortens, a replica slower than the
effect's
timeout_s, a redesign that breaks the selectors, a page that posts when it loads, or a failed sign-in. Each is a stop, never a pass, and each costs a person's time. Read full values from attributes where the page has them, settimeout_sabove the replica lag, and declareempty_selector. - A whitespace-only difference is invisible, because both sides are compared with whitespace collapsed.
- The record needs a URL that an identifier the run already knows can fill. A record whose identifier is assigned at save can't be opened this way yet.
- It needs its own account. Signing in as the account the workflow types with can end the acting session in applications that allow one session per user. Single sign-on and multi-factor sign-in aren't supported.
- A duplicate that lands after the check has read isn't seen. That's true of every read after the save.
Complete commented example: docs/deployment.example.yaml.
Schema: openadapt_flow/deployment.py (EffectsConfig).
Two kit-wide conventions:
-
Secrets are references, never literals.
auth(rest),access_token_env(fhir), andsql_password_env(sql) name environment variables; a missing variable fails LOUD at construction (a verifier is never wired silently unauthenticated). Resolved secrets never enter configs, reports, or contract hashes.effects: kind: rest auth: bearer_env: SOR_BEARER_TOKEN # or header+value_env, or basic_env
-
Run-parameter binding is explicit.
path_params(rest),search_param_exprs(fhir), andsql_query_params(sql) take the same{param: name}/{literal: value}ValueExprform the bundle's effect contracts use (a bare string is a literal). They resolve against the governed run parameters (--params-file/--param, PR #130) when the verifier is built, and an unresolved{param: ...}reference refuses to construct — so one bundle + one deployment YAML ships with its verification bound to the record each run actually writes.
Per-kind required fields:
| kind | required | optional highlights |
|---|---|---|
onscreen |
(none — auto-derived from the demo) | readback_region, readback_min_ratio (hand-config fallback) |
ui_readback |
ui_readback section: record_url, sign_in (env-var names for the read-back account), ready_selector, row_selector, fields, identity |
url_params, empty_selector, not_found_selector, read_post_paths, per-field attribute / date_format / casefold, truncation_markers, navigation_timeout_s |
rest |
base_url |
records_path (may contain {placeholder}s), records_key, path_params, auth |
graphql |
base_url + graphql_query |
graphql_variables ({param: ...} entity binding), graphql_records_path, graphql_freshness_field + graphql_freshness_window_s, auth |
fhir |
base_url |
resource_type, search_params, search_param_exprs, field_paths, access_token_env, verify_tls |
sql |
sql_query + (sqlite_database or sql_driver) |
sql_query_params, sql_connect_args, sql_password_env |
file |
root |
file_pattern, file_min_size, file_mtime_window_s, file_content_probe |
email |
root |
mail_pattern, file_mtime_window_s (freshness), file_content_probe (body regex) |
document |
root |
file_pattern, document_format (json | text), document_field_paths, document_text_pattern, file_mtime_window_s |
document-hash |
root |
glob |
Any kind additionally accepts the evidence-minimization fields
evidence_redact_fields / evidence_keep_fields (see "Evidence minimization"
below).
A database connection is not required. Configure the strongest qualified
read boundary that the workflow has: REST/FHIR/GraphQL, read-only SQL, a file
or report export, the separate-session UI read-back (ui_readback), or a
persisted-state re-acquisition. Do not configure a same-surface
screen read-back as proof of a consequential write.
For more than one reviewed boundary, use effects.candidates instead of
effects.kind. Each candidate has the normal EffectsConfig fields. Flow
constructs every candidate before actuation, then selects the strongest
VerificationTier enum member for each resolved effect; declaration order
resolves a tie. VerificationTier is a persisted Flow v1 implementation field.
Do not present its numeric value as a Seal Oracle tier. Public receipts use one
ladder: Oracle tier 0 is visual evidence, tier 1 is a separate read-only
session, tier 2 is a system-of-record read, and tier 3 is a counterparty
acknowledgment. This makes the choice deterministic and reviewable. A missing
secret, invalid config, or invalid plugin verification class refuses the run
before input. The on-screen candidate uses
PERSISTED_STATE_REACQUISITION only for that exact effect when its read-back
reopens persisted state through a different path. It uses IMMEDIATE_SCREEN
for a same-surface read-back. After the action, Flow does not fall back to a weaker
candidate if the selected verifier is unavailable. It records the unavailable
proof and halts or creates the normal reconciliation task.
effects:
candidates:
- kind: document # independent system-of-record export
root: /secure/exports
file_pattern: "confirmation-*.json"
document_format: json
- kind: onscreen # persisted-state read-backThe single kind: form remains the recommended configuration when one
qualified verifier exists and remains fully compatible with prior deployments.
The sql kind refuses to construct unless sql_query passes the read-only
statement filter (single statement, SELECT/WITH leading keyword, no
comments, no mutating/DDL/control keywords or known side-effecting functions,
values bound only through DB-API parameters). The filter is defense in
depth, not proof: on Postgres/MySQL a lexically-clean SELECT can still
call a side-effecting function (a UDF, nextval, dblink), so always run
the SQL verifier under a dedicated read-only database role — no
INSERT/UPDATE/DELETE, no EXECUTE on writing functions, no sequence
privileges. The role is the real enforcement; the filter catches config
mistakes early. The SFTP variant of file is programmatic-only (inject a
paramiko-compatible transport into FileArrivalVerifier); YAML wires local
directories.
Every substrate above implements ONE stable interface
(openadapt_flow/runtime/effects/adapter.py), so "add a system of record"
means "implement the interface", first-party or as a customer plugin. The
lifecycle every adapter honors:
- configure -- the constructor / registered factory, called with the
deployment's
effects:section and the governed run params. Secrets arrive as env-var / secret-manager REFERENCES (never literals, isolated from the actuation session's credentials) and entity + tenant binding usesValueExpr({param: ...}), both resolved here -- fail LOUD on anything missing. - test-connection --
test_connection(): a read-only reachability probe (never a write, never raises) for operator preflight. - capture-before --
capture_pre_state(): the baseline snapshot for delta (count_new_only), duplicate, and collateral accounting. - capture-after --
capture_post_state(): a fresh post-action snapshot (default: the same read), also fed to collateral-effect hooks. - verdict --
verify(): poll-until-settled within the effect's deadline (Effect.timeout_s-- an asynchronous write that never settles is a failure, not a pass), judge with the shared judge (cardinality zero / exactly-one / exact-N viaexpected_count; duplicates viacount_new_only+idempotency_key; collateral loss viaforbid_collateral_lossplus substrate-specificcollateral_hooks), optionally enforce a freshness window, then minimize evidence.
classify_adapter_result refines the three-valued verdict into six explicit
result classes; transaction_outcome_for maps them onto the terminal
transaction taxonomy. No non-confirmed class maps to a pass -- the
refinement tells the operator WHICH failure they are reconciling, it never
softens one:
| Adapter result | Meaning | Transaction outcome |
|---|---|---|
confirmed |
effect present, correct, fresh | (step proceeds; run-level VERIFIED is decided by the run classifier) |
refuted |
affirmatively ABSENT (observed count zero) | HALTED_BEFORE_EFFECT (this step; run-level HALTED_BEFORE_EFFECT is decided by the run classifier, which additionally requires absence for EVERY declared effect of EVERY consequential step) |
conflicting |
a write LANDED but is duplicated / wrong-valued / collateral | RECONCILIATION_REQUIRED |
unavailable |
system of record unreachable / credential failure | RECONCILIATION_REQUIRED |
stale |
data read but outside the declared freshness window | RECONCILIATION_REQUIRED |
indeterminate |
cannot certify for another reason (e.g. unreadable baseline) | RECONCILIATION_REQUIRED |
Every adapter advertises a VerificationTier (lower = stronger); execution
profiles gate on the tier, never on prose:
| Tier | Label | What it proves | Adapters |
|---|---|---|---|
| 1 | independent-system |
a read through the SoR's own API/DB/store -- independent proof | rest, graphql, fhir, sql, file, email, document, document-hash |
| 2 | independent-session |
same app, read through its record page in a separate, separately signed-in, read-only session. Not a system-of-record read | ui_readback; a plugin may also declare it |
| 3 | reacquired-state |
the app's own UI re-navigated to re-fetch persisted state | onscreen with a different-path read-back |
| 4 | screen-consistency |
the same surface the write drove still shows the value | onscreen same-surface |
Same-application screen read-back is a LOWER-CONFIDENCE consistency check,
never independent system-of-record proof -- an optimistic UI can paint
success while nothing persisted. The onscreen adapter declares
independent_system_of_record = False in code, and docs/LIMITS.md carries
the measured false-CONFIRM evidence behind the demotion.
| Adapter | Status | Notes |
|---|---|---|
| REST read-back | supported | rest |
| GraphQL read-back | supported | graphql; read-only guard, freshness window |
| FHIR R4 | supported | fhir; a FHIR profile over HTTP read-back with resource/entity binding (search_param_exprs); opt-in live-OpenEMR test |
| read-only SQL | supported | sql; whitelist + read-only role |
| file / SFTP arrival | supported / programmatic SFTP | file; SFTP today via an injected paramiko-compatible transport |
| maildir / SMTP-capture email delivery | supported | email; delivery to the capture point |
| document / report arrival + parse | supported | document |
| document store (exact bytes) | supported | document-hash |
| separate-session UI read-back | supported | ui_readback; tier 2, not a system-of-record read |
| on-screen read-back | supported (demoted) | onscreen; tiers 3-4, consistency only |
| customer plugin | supported (SDK seam) | any kind via the entry-point group below |
Any kind accepts evidence_redact_fields (denylist) or
evidence_keep_fields (allowlist): the named record fields in every emitted
verdict's evidence (matched records; observed/expected values when the
effect's read-back field is named) are replaced with opaque markers. This
avoids making low-entropy identifiers recoverable through a digest dictionary.
The verdict itself is never altered -- redaction minimizes evidence, it cannot
soften a failure into a pass.
A customer package implements the interface and registers a factory; no
OpenAdapt fork required. The worked reference is
tests/example_verifier_plugin.py (a CSV-ledger adapter exercising every
platform obligation), qualified in tests/test_verifier_adapter_platform.py.
-
Subclass
VerifierAdapterBase(or match theVerifierAdapterprotocol): setsubstrate+verification_tier, implementcapture_pre_state(one fresh, fail-safe read -- returnreachable=False, never raise) andverify(use the sharedpoll_until_settled+ the shared judge so cardinality/duplicate/collateral semantics match every other substrate). -
Write a factory with the
VerifierFactorysignature(cfg, params) -> verifierthat reads its config from theeffects:section and FAILS LOUD on missing fields or secrets. -
Register it under the entry-point group in your package:
[project.entry-points."openadapt_flow.effect_verifiers"] csv-ledger = "acme_verifiers.csv_ledger:build_csv_ledger_verifier"
(or programmatically:
register_verifier_factory("csv-ledger", factory)). -
Deploy with
effects: {kind: csv-ledger, ...}--build_effect_verifierresolves built-ins first (a plugin can never shadow a built-in kind), then the registry; a plugin that fails to import fails the build loudly. -
Qualify it with the platform's adversarial fixture set (stale data, wrong entity, duplicate rows, settlement timeout, credential failure -> UNAVAILABLE) -- copy the per-adapter test modules as the template.
A per-system record check (which endpoint to read, how to flatten its records, how to sign in to a separate read-only session) is application knowledge, so it ships as a plugin kind outside this repository. Its deployment section uses three plugin-only fields. Built-in kinds refuse them rather than ignore them.
effects:
kind: acme-claims-readback # registered by the pack
base_url: https://claims.example.invalid
options: # non-secret, JSON-safe, reviewed
records: {path: /api/claims, page_size: 50}
option_params: # run-parameter bindings, resolved at build
claim_code: {param: claim_no}
secret_env: # slot -> environment variable NAME
username: ACME_READER_USER
password: ACME_READER_PASSWORDInside the factory, plugin_options(cfg, params) returns a copy of options
with each option_params entry resolved (an unresolved reference fails loud),
and plugin_secrets(cfg) returns a ResolvedSecrets mapping, which fails
loud on a missing variable and never prints its values. An option key that
names a credential (its last word is password, secret, token or
credential, or it ends in api_key) is refused at load time, so
client_secret is refused while token_path and password_env are allowed.
A secret_env value must look like a variable name. The options enter the verifier's deployment
identity, so a changed option is a changed verifier. Configs that do not use
these fields keep a byte-identical identity.
The pack should sign in through its own read-only session (a separate client or account whose server-side permissions only allow reads), and never share the session that the program drives.
A GUI-only demonstration often never observes the system of record, so the
compiled save step carries no contract (or a placeholder that the gate
refuses). bind-record-checks attaches a reviewed contract afterwards:
openadapt-flow bind-record-checks bundle/ --binding record-checks.yaml --check
openadapt-flow bind-record-checks bundle/ --binding record-checks.yaml --out bundle-checked/schema: openadapt.record-check-binding/v1
workflow_name: claim-intake
source_content_digest: <optional pin to the exact compiled program>
steps:
- step: step_012
intent: "click 'Save'" # optional guard against a recompiled program
effects:
- {kind: record_written, match: {code: {param: claim_no}}, expected_count: 1, count_new_only: true}
- {kind: field_equals, match: {code: {param: claim_no}}, field: member_no, value: {param: member_no}}
not_compared:
visit_note: "free text the system stores in a different form"What it does and refuses:
- It writes a new bundle at
--out(which must not exist), re-seals its manifest, and leaves the source untouched. An encrypted source stays encrypted. - It only adds contracts. A step's action, target, postconditions, identity contract, risk label, and review flag are unchanged. Confirmed contracts the step already has are kept. Compiler placeholders are replaced.
- Each bound step needs a change claim (
record_writtenwithcount_new_only, orexact_new_set), and eachrecord_written/field_equalscontract must select its record through a run parameter. - Every non-secret parameter the program enters must be compared by some
contract or listed under
not_comparedwith a reason.--checkprints the per-field coverage without writing anything. - A contract that references a secret parameter is refused.
- A prior certification or qualification decision is invalidated, because the program changed. Binding certifies nothing and approves nothing: the new bundle still goes through lint, qualification, and the run gate.
record-check-binding.json in the new bundle records the binding digest, the
source program digest, and the coverage. The contracts themselves are in
workflow.json, covered by the sealed content digest.
When verification cannot be reconciled, reconcile_or_escalate returns a
CompensationResult whose task is a typed ReconciliationTask: kind
(effect_refuted / effect_indeterminate / compensation_failed), the
one-way contract hash (never the resolved values), the verdict evidence
(observed/expected counts and values, matched records), and a
suggested_action for the operator. The pattern is halt + evidence: the
run stops, the task tells a human exactly what could not be certified, the
human repairs the system of record, re-verifies, and resumes. There is
intentionally no compensation engine beyond the single proven safe undo
(duplicate-record deletion via a configured Compensator) — automatic repair
of missing/partial/collateral state would be another wrong write.
Runs against the pinned reference fixture
(benchmark/frappe_lending/ — its
README documents the pinned compose bring-up and fixture bootstrap). The
bundle's effect
contracts are exactly the ones the reference matrix ships
(openadapt_flow/benchmark/frappe_lending.py::loan_application_effects):
one record_written (at-most-once for the synthetic applicant) plus a
field_equals read-back per entered field, each bound to {param: ...}.
deployment.frappe.yaml (REST oracle, read-only user, path templated on the
run's applicant):
effects:
kind: rest
base_url: http://localhost:8000
records_path: >-
/api/resource/Loan%20Application?fields=["name","applicant","loan_product","loan_amount","repayment_periods"]&filters=[["Loan Application","applicant","=","{applicant}"]]&limit_page_length=100
records_key: data
path_params:
applicant: { param: applicant }
auth:
header: Authorization
value_env: FRAPPE_ORACLE_AUTH # "token <api_key>:<api_secret>" of the READ-ONLY oracle userThe independent SQL cross-check (same contract, different transport — the fixture's MariaDB):
effects:
kind: sql
sql_query: >-
SELECT name, applicant, loan_product,
CAST(loan_amount AS CHAR) AS loan_amount,
CAST(repayment_periods AS CHAR) AS repayment_periods
FROM `tabLoan Application` WHERE applicant = %(applicant)s
sql_query_params:
applicant: { param: applicant }
sql_driver: pymysql
# database = the fixture site's DB name (the fixture derives it at runtime —
# see benchmark/frappe_lending/fixture.py::_site_db_name). The fixture only
# exposes root; a REAL deployment must use a dedicated read-only DB role
# (see the enforcement note above).
sql_connect_args: { host: 127.0.0.1, user: root, database: "<site-db-name>" }
sql_password_env: FRAPPE_DB_ROOT_PASSWORDRun either with
openadapt-flow run bundle/ --config deployment.frappe.yaml --params-file params.json
where params.json supplies applicant, loan_product, etc. The whole-run
table-delta audit (audit_table_deltas) is what the reference matrix layers
on top — see benchmark/frappe_lending/fixture.py::EXPECTED_TABLE_DELTAS.
Runs against the live-OpenEMR fixture
(benchmark/openemr_live/; bring-up:
docker compose -f benchmark/openemr_live/docker-compose.yml up -d then
eval "$(benchmark/openemr_live/setup.sh)", which exports
OPENEMR_FHIR_BASE_URL + OPENEMR_FHIR_TOKEN).
FHIR verifier, patient bound per-run, token secret-isolated:
effects:
kind: fhir
base_url: https://localhost:9300/apis/default/fhir
resource_type: Observation
search_param_exprs:
patient: { param: patient_ref } # e.g. "Patient/9", from --params-file
field_paths:
id: id
patient: subject.reference
status: status
note: valueString
access_token_env: OPENEMR_FHIR_TOKEN
verify_tls: false # the local fixture uses a self-signed certwith a bundle effect such as:
{"kind": "record_written",
"match": {"patient": {"param": "patient_ref"}, "status": {"literal": "final"}},
"expected_count": 1, "count_new_only": true,
"probe": "exactly one NEW final Observation for this run's patient"}The SQL cross-check against the fixture's MariaDB (openemr database) reads
the same truth through a different transport:
effects:
kind: sql
sql_query: >-
SELECT f.encounter, f.pid, f.note
FROM form_encounter f WHERE f.pid = %(pid)s
sql_query_params:
pid: { param: patient_pid }
sql_driver: pymysql
sql_connect_args: { host: 127.0.0.1, port: 3306, user: openemr, database: openemr }
sql_password_env: OPENEMR_DB_PASSWORDHonesty note: the FHIR configuration above is exercised end-to-end by the opt-in live test when the fixture is up; the OpenEMR SQL snippet is a configuration template validated at the kit level (sqlite-backed contract tests), not a CI-run assertion against OpenEMR's schema.
Replay/run resolves each step's effects against the run params, snapshots the
pre-state, performs the action, then verifies — HALTing on any non-CONFIRMED
verdict (irreversible effects get one reconcile-or-escalate pass). That flow
is unchanged by this kit; the kit adds the declarative construction path, two
substrates, the guard, coverage reporting, and the typed reconciliation
surface. See docs/design/EFFECT_VERIFIER.md and
docs/design/GOVERNED_RUN_AUTHORIZATION.md (effect contracts are bound into
run authorization, PR #129).