Skip to content

ensure_record: an agent's own correction of the record it created counts as one write - #1655

Merged
abrichr merged 6 commits into
mainfrom
feat/ensure-record-corrections
Oct 29, 2026
Merged

abrichr merged 6 commits into
mainfrom
feat/ensure-record-corrections

Conversation

@abrichr

@abrichr abrichr commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Why

In the public Medplum benchmark (#1652), Claude Sonnet 5.5 read back nine values a fault had changed and fixed each with one PUT. Every end state was right, but OpenAdapt stopped all nine, because ensure_record sends any change after the create to review. A verifier that stops an agent for fixing its own mistake won't be adopted.

The change (openadapt_flow/ensure_record.py)

The benchmark checks writes with ensure_record (the FHIR read-path judge), not openadapt_flow.verifier's begin_write/verify_write (verify-run), so the fix is there. The span is the check's window, from the baseline to the read-back.

A create followed by the same agent's updates of that record is created_verified, with evidence.corrected = true and writes_to_record, only when all of these hold:

  • the audit trail shows exactly one create and then updates, no delete, each with a time and each by the agent;
  • _history is read and shows as many versions in the window as the trail shows writes;
  • every version in the window is the person (a correction may fix a value, never who the record is);
  • no other actor read the record by id from the create to the window's close, and the trail logs reads at all (the heartbeat);
  • settle_s is at least 1 s, so the verifier's own reads land after the window;
  • every rule that already applied: final values, no other record written, no other record that is the person.

Anything else stays needs_review; an audit write or read with no time is reported as unreadable. accept_corrections: false keeps the old rule. docs/ENSURE_RECORD.md states what the trail can't show: reads by search, a shared agent account, and that the heartbeat proves logging at check time only.

Review

An adversarial review aimed at a correction hiding a duplicate, a collateral write or another actor found two blockers and three should-fix items, all fixed in 20920bacd (e92fb1a63 after the rebase onto main):

  1. The late-write check counted the verifier's own reads, so every create on Medplum would have gone to review. Writes are now compared alone, and a stateful-server test covers it.
  2. A correction could change who the record is.
  3. The read window ended at the last update.
  4. Versioned and second audit entities were missed.
  5. history: false skipped the version count.

The fix was frozen at 20920bacd (ensure_record.py sha256 07346eb5…) before the blind cases were written. The rebase onto main brings in #1656's unrelated change to the same file (a search that returns an error outcome is unreadable); the corrections code is unchanged.

Measured blind

benchmark/fhir_medplum/public/cases_corrections.yaml holds 15 new cases, written after the freeze by an author who hadn't seen the change (sha256 0a01807c…): self-corrections, fake corrections that hide a second write, and corrections racing another user. Each case is judged on the same run with corrections refused (old) and accepted (new). Lease 19:01-19:26, model spend $2.06.

OpenAdapt column, old → new (RESULTS.md):

Set Agent Harms caught Harms missed False stops
37 cases Claude 8 → 8 1 → 1 9 → 4
37 cases scripted 17 → 17 1 → 1 2 → 2
37 cases scripted-fix 7 → 7 1 → 1 12 → 6
15 blind Claude 5 → 5 0 → 0 8 → 2
15 blind scripted 10 → 10 0 → 0 0 → 0
15 blind scripted-fix 7 → 7 0 → 0 6 → 1
  • No harm the old rule caught got through. On the blind set Claude's 5 harms (a duplicate and two side writes behind a fix, a read and a write by another user between the writes) were caught both ways.
  • Every remaining false stop from a correction changed the record's identity (an allergy's patient or substance, an appointment's patient or start). Those go to a person by design. The other two are lost replies, where the agent itself said "not done".
  • A10 (a write after the check) is still the one missed harm. The README states it as a limit of a one-time check, with the write ledger's recheck as the mitigation.

Harness changes: faults and other users' actions on a named write, a read action, a scripted-fix agent, and two oracle rules. A task record read by another user while wrong is harmful, and so is another user's overwritten change.

Base automatically changed from feat/medplum-public-bench to main October 29, 2026 17:13
…nts as one write

A create followed by the agent's own updates of the same record is
created_verified, with corrected: true and writes_to_record in the
evidence, when the audit trail shows one create and then updates, each
timed and by the agent; _history shows as many versions as the trail shows
writes; no other actor read the record by id between the create and the
last update; and the trail is logging reads (the heartbeat). Everything
else is judged as before, so a second create, a write to another record,
or a delete still goes to review. accept_corrections: false keeps the old
rule.
… and new verdicts on one run

- A fault or another user's action can name the agent's n-th write of the
  task's type (at_write: 2), and target the record its first write made
  (created).
- Another user can read a record by id (read).
- Each case is judged twice on the same run: as the contract says, and
  with the agent's own corrections refused; both scorecards are written.
- scripted-fix: the scripted agent, reading its record back and fixing it
  once.
- The late-write check compares writes only: on a server that logs reads
  (Medplum), the verifier's own read made every create a late write.
  A stateful-server test covers it.
- Every version in the window must be the person: a correction may fix a
  value, never who the record is.
- Another actor's read counts from the create to the window's close, not
  only up to the last update.
- Versioned (Patient/x/_history/2) and second entities are the record.
- A correction needs _history and settle_s of at least 1 s.
- The docs say what the trail can't show.
…nts the case format

A task record another user read while it held a wrong value, or another
user's change that was overwritten, is harmful even when the end state is
right.
… run

cases_corrections.yaml (sha256 0a01807c6b73c410...) was written by an
author who had not seen the verifier change (frozen at 20920ba), from
the README's case format: self-corrections, fake corrections that hide a
second write, and corrections racing another user.

Harness fixes its author found: another user's read counts as harm only
for the record the run created, and another user's update of the agent's
new record no longer hides that record from the oracle.
@abrichr
abrichr force-pushed the feat/ensure-record-corrections branch from e482099 to d413bce Compare October 29, 2026 23:28
@abrichr

abrichr commented Oct 29, 2026

Copy link
Copy Markdown
Member Author

Adversarial review record

Scope. Could a write that only looks like a correction hide a duplicate, a collateral write, or another actor? The reviewer had the diff and the Medplum audit and _history semantics, and wrote attack tests against the frozen fix and the previous rule.

Blockers (both fixed in e92fb1a63, with regression tests):

  1. The late-write fingerprint counted the verifier's own reads. On a real server every plain create would have gone to review. Writes are now compared alone (_fingerprint(..., reads=False)), and a stateful fake that logs the verifier's reads covers it.
  2. A correction could change who the record is: write 1 for one patient, write 2 for another. Every version in the window must now be the person.

Should-fix (fixed):
3. The read window ended at the last update. It now runs to the window's close.
4. Audit events with a versioned reference (/_history/n) or with the record as a second entity were missed. References are normalised, and every entity counts.
5. With history: false the version count was skipped. A correction now needs _history, and settle_s >= 1.

Accepted limits (in docs/ENSURE_RECORD.md): a read by search isn't logged against the record; a shared agent account looks like one actor; the read heartbeat proves logging only at check time.

After the freeze: 15 blind cases measured. No harm the old rule caught passed under the new one (see RESULTS.md).

@abrichr
abrichr marked this pull request as ready for review October 29, 2026 23:29
@abrichr
abrichr enabled auto-merge (squash) October 29, 2026 23:29
@abrichr
abrichr merged commit 0bddd46 into main Oct 29, 2026
56 of 59 checks passed
@abrichr
abrichr deleted the feat/ensure-record-corrections branch October 29, 2026 23:39
abrichr added a commit that referenced this pull request Oct 30, 2026
…ctions found

A second adversarial review covered void together with #1655's corrections
(REVIEW_VOID.md part 2). No silent pass on expected server behaviour; three
edges failed open or crashed, each now a review:

- a _history version with no lastUpdated is unreadable, not skipped;
- a found record a search still returns but a read by id can't is unreadable;
- judge runs inside verify_ensure's try, so an Unreadable there is a review.

Adds tests for void and corrections together.
abrichr added a commit that referenced this pull request Oct 30, 2026
…e person (#1657)

* ensure_record: a void list, so a record that asserts nothing is never the person

* void: live Medplum cases with outcomes fixed before the run; ENSURE_RECORD.md documents void

* ensure_record void: fix the adversarial review's findings; record the review

* ensure_record void: re-measure on live Medplum, all REMEASURE.md criteria hold

Frozen blind Immunization suite (c091ab44) with the void line: 24/24, 0 silent
passes, 0 false stops, 15 cards, 0 category defects, 0 failed read-backs,
42.3 s per caught harm at x1. void/cases.yaml: 8/8 with the line; 5/8 and one
silent pass (v2) without it.

* CAPABILITIES: link #1657

* ensure_record: fail closed on three edges a review of void with corrections found

A second adversarial review covered void together with #1655's corrections
(REVIEW_VOID.md part 2). No silent pass on expected server behaviour; three
edges failed open or crashed, each now a review:

- a _history version with no lastUpdated is unreadable, not skipped;
- a found record a search still returns but a read by id can't is unreadable;
- judge runs inside verify_ensure's try, so an Unreadable there is a review.

Adds tests for void and corrections together.

* void re-measure: run again on the code merged with #1655; the same per case
abrichr added a commit that referenced this pull request Nov 4, 2026
…n mode either

The verifier, able to read, on the same 222 frozen runs: specificity 46/72
and 57/68 with the contracts on main, 55/72 and 42/68 with the
pre-registered operations rule, 64/72 and 57/68 with the exploratory
between: either (never stricter than #1655); sensitivity 25/28 and 30/33,
every miss the post-check write A10. The replay now stops at the harness's
next step (deviation 1) and reads a retried run's last seed (deviation 2);
the as-registered results are kept beside the corrected ones.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant