Repository navigation
feat(flow): recheck a verified write for writes that land after the verdict - #1639
Merged
Merged
Conversation
…ter_s) The whole-database frame reads the database twice after a save, settle_s apart. A write the app makes later (a background job, a trigger-driven integration, a delayed side record) lands after the verdict and nothing sees it. frame.database.recheck_after_s (unset by default) makes a verified receipt carry recheck_due_at and bind the settled post-save state. verifier.recheck reads the whole database again at or after that time and judges every change since the post-save read with the frame's rules. Later writes by the same actor under the same contract, of any outcome, account for their declared effects and side records and for what their own frames already judged in their windows. Anything else is REFUTED (late); an unreadable database or a state that doesn't match its receipt is UNCERTAIN.
Review of #1639 found a fail-open: a later write's declared effects were excused with no time bound, so a duplicate matching its selector, or a save of its that landed after its window, passed the earlier write's recheck. - A later write now accounts only for rows and audit entries that changed in its own window and still hold what its post-save read saw. Removals match by a per-host row link MAC; keyless tables are never excused. - A change in the window of a later write that was not verified, and that its frame reported as collateral (or whose delta failed), gives UNCERTAIN: the harm may be the earlier write's. - verify_write records each recheck state it issues under the state directory; recheck uses only issued states. - The due time adds a second for the truncated read time, so it is never early.
… onto the one verifier API The recheck now rides on #1643's API: WriteResult (CONFIRMED/REFUTED/ UNCERTAIN) carries recheck and recheck_due_at; verify_write(pending) saves the state as recheck.json in the write's folder and records it as issued under the verifier's state directory; the deprecated capture/verify_write form schedules it too; scope names the recheck's due time in place of the delayed-writes line when one is due. The recheck tests use begin_write.
…xture's quoting refuses @)
… from the environment
…g and the history side row The REST patient insert writes a history_data row (a declared side record now) and per-request bookkeeping the openemr profile excuses only with the bot's sign-in name, so the contract gets audit_read attribution over OpenEMR's log on the frame's own read-only connection. The REST read uses the oracle token through auth.bearer_env by default: verify-run drops a verifier.session hook, which is why the read was unauthenticated (401). Tokens are used once and the test waits 2 s before each baseline.
… match A live OpenEMR moves on its own (a UI session polling, the scheduler), so one pair of reads at a recheck often differed and the recheck was UNCERTAIN. frame.database.settle_attempts (default 1, as before) allows up to that many more reads, settle_s apart; two reads match when they are the same or differ only by rows added to append-only tables, which the frame excuses anyway. The earlier read of the matching pair is judged, and the evidence says how many reads it took. No pair within the budget: not checked, as before.
…y_write still never raises
…ory row; patient_data.id is filled by OpenEMR
…recheck # Conflicts: # CAPABILITIES.md # llms-full.txt # packages/flow/openadapt_flow/verify_run.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
A verified write can now be checked again later. Before this change, a write that the app made after the verdict went unnoticed.
frame.database.recheck_after_sis a new optional setting with no default. When it's unset, behavior and every contract digest stay the same. When it's set and the run is verified:recheck_due_at, in whole-second UTC. It is never early: the post-save read time is truncated to the second, so one second is added before rounding up. A verified receipt that carries it is provisional until the recheck runs.evidence.frame.rechecknames what the recheck compares and binds a salted digest of the settled post-save state.WriteResult.recheckholds that state privately. It contains the post-save snapshot as keyed MACs, the audit entries seen then as keyed hashes, the run's bound baseline, what the run's frame reported as collateral (row key MACs and entry hashes) and the salt. As with the baseline since verify-run: the baseline holds the read set as keyed MACs, not values #1599, no values are kept.RecheckState.savewrites the state with mode 0600.verify_writerecords each state it issues under the verifier's state directory (rechecks/, named by the state's digest). A state's salt and digests can be recomputed by whoever writes the file, sorecheckuses only a state with that record. An unissued state being rechecked givesUNCERTAIN; an unissued excuse raisesVerifierError.openadapt_flow.verifier.recheck(contract, write, *, params, actor, excused_by=(), wait=False) -> RecheckResultreads the whole database at or after the due time. It reads twice,settle_sapart, as after the save. It judges every change since the post-save read with the frame's own rules: profile bookkeeping, audit attribution of other actors, and declared side records. The outcome isCONFIRMED,REFUTED(late) orUNCERTAIN.statusmaps these toverified,refutedandunconfirmed.excused_bytakes later writes by the same actor under the same contract, of any outcome. A later write accounts only for its own window, the time between its baseline and its post-save read, where its own frame judged every change:recheck_after_sis set. Tables without a primary key are never excused this way.UNCERTAIN, notCONFIRMED.UNCERTAIN.VerifierError. Examples are another actor, a write that started before the post-save read, or a loaded state without its params.RecheckNotDue(withdue_atandwait_s). Withwait=True, it sleeps until the due time instead.Code layout: the state's shape, the due time and the later-write window (
LaterWindow) are in a new module,runtime/effects/late_write.py, next to the frame. The read and the judgment reusejudge_run_frameinverify_run.py, which gets adatabase_onlymode and alaterhook.db_snapshot.pycan keep row link MACs.verifier.pyadds only a thin entry point.The second commit fixes a fail-open the review found. In the first version, a later write's declared effects were excused with no time bound, so a duplicate matching its selector, or a save of its that landed after its window, passed the earlier write's recheck. Probes P1 to P4 from the review are now regression tests.
Docs:
docs/VERIFIER_API.mdhas a new "Writes that land after the result" section and the narrowed scope line;docs/VERIFY_RUN.mdhas a new "Writes that land later" section,frame.pydocuments the field next tosettle_s, and CHANGELOG and CAPABILITIES.md are updated.frame.database.settle_attemptsis a new setting (default 1, as before). It lets the reads after a save, and at a recheck, retry up to that many times,settle_sapart, until two reads in a row match. A live OpenEMR moves on its own, for example when a UI session polls, so a single pair of reads often differed and the recheck came back UNCERTAIN.evidence.frame.database.settle_attemptsrecords how many reads it took.settle_attempts * settle_s, the frame is not checked, as before.Why
verify_writeand the frame check a write once: two whole-database readssettle_sapart, right after the save. A collateral write that lands later is missed, such as a background job, a trigger-driven integration or a delayed duplicate side record. A VERIFIED write can turn out wrong a minute later.On top of the one verifier API (#1643)
#1643 merged the standalone verifier API from #1628. This branch merges main and ports the recheck onto it:
WriteResult(CONFIRMED/REFUTED/UNCERTAIN) carriesrecheckandrecheck_due_at, andto_dict()includesrecheck_due_at.verify_write(pending)saves the state asrecheck.jsonin the write's folder, besidebaseline.jsonandeffect-receipt.json, and records it as issued under the verifier's state directory. That directory follows the same private-directory rules as snapshot issuance. It is kept apart from the ledger's own directory, soledger=Noneand memory ledgers work too.capture/verify_write(contract, before, ...)form schedules a recheck the same way.scope.not_coverednames its due time in place of the generic delayed-writes line, and says the result is provisional until the recheck runs.begin_write/verify_write. One test covers the deprecated form._reconcilecalls_judge) also writes a recheck state for that run.Behavior is otherwise what was reviewed: a later write excuses only its own window, a possibly-refuting change gives UNCERTAIN, issuance is required, the due time is never early, and removals match by link MAC. The only change made by porting is the vocabulary: reason text now says "the earlier CONFIRMED" instead of "the earlier VERIFIED".
How it was tested
tests/test_verifier_recheck.pyhas 51 focused tests on SQLite through the public API:waitsleeps until it;test_verifier_api.py(public API pins updated),test_write_ledger.py,test_verifier_snapshot_api.py,test_verify_write.py, the launcher'stests/test_verify_command.py,test_durable_authority_v13.py,test_frame_database.py,test_verify_run.py,test_frame_conditions.py,test_app_profile.py,test_frame_links.py,test_frame_secret.py,test_guarded_save.py,test_fault_gate.py,test_jit_reuse_binding.pyandtest_jit_repair_admission.py.ruff checkandruff formatpass on the changed files.mypy(CI config) reports no errors in the touched modules; the remaining errors are in other modules and were already there.What the live test needs
tests/test_late_write_recheck_live_openemr.pyis skipped unlessOPENEMR_LATE_WRITE_LIVE=1. It has not been run yet. It needs:pymysqlby default);SELECTonopenemrandTRIGGER.It has two tests:
REFUTED.excused_by, must giveCONFIRMED.The test's
new_row_fieldsfor OpenEMR's REST patient insert is a starting guess. If the first save isn't verified, the assertion prints the frame evidence.OPENEMR_LATE_WRITE_FRAME_JSONcan add fields without editing the test. The test'srecheck_after_s(2 s, orOPENEMR_LATE_WRITE_RECHECK_S) is a test value only. No app gets a recommended value.