Skip to content

feat(verify): add standalone agent write verification - #1948

Merged
abrichr merged 13 commits into
mainfrom
agent/145
Nov 11, 2026
Merged

abrichr merged 13 commits into
mainfrom
agent/145

Conversation

@abrichr

@abrichr abrichr commented Nov 5, 2026 •

Copy link
Copy Markdown
Member

Adds openadapt-verify, a facade over openadapt-flow[receipts], for checking an agent's writes through an operator-configured independent store read path. It reuses canonical verification, signed local Seals, portable COSE receipts and SDK/MCP wrappers. Installation includes Flow and its vision dependencies: the tested Linux/Python3.12 core installation contains 33 distributions and about 413 MB.

The CLI exits zero only for a final confirmed result. Uncertain, refuted and provisional results have distinct nonzero codes, and a recheck command delegates to the existing bound-state verifier. Signature integrity and store verdict remain separate. Plain imports avoid loading the legacy guard adapter, and missing MCP reports its install extra cleanly.

Validation: 38 facade controls, 237 canonical controls (2 live-model tests deselected), actual MCP 2.3 transport, 132 root controls, and two installed-wheel SQL/HTTP write/readback/signature/retry controls. Final formatting preserves all runtime/test ASTs and assertions. Actual wheel/sdist code, license and source boundaries and the unchanged install budget pass. Tests use synthetic local stores and scripted SDK callbacks; live provider behavior is untested. Against official published Flow 2.6.0 and Types 0.24.0 wheels, 18 core CLI/recheck cases passed in a fresh installation. A separate fresh installation with official MCP 2.3.0 passed the actual in-memory client/server transport case. These released-floor results have zero failures, errors or skips; package origins and installed bytes match the pinned wheels. They do not test stdio or live providers.

The Verify facade requires Python 3.11 or 3.12 and Pillow 13 or later. Flow still supports Python 3.10 and its lock is unchanged. The Verify lock selects a NumPy version compatible with the SciPy in the layered CI install.

Signed-off-by: abrichr <richard.abrich@mldsai.com>
@abrichr abrichr added the review-needed Needs an independent review from another vendor's agent label Nov 5, 2026
@abrichr

abrichr commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

Independent adversarial review: BLOCK

Head reviewed: 8417329aef4c2c7fec3fa985c26ee615a1289349

What I ran

  • pytest -q in packages/verify with in-tree types/flow plus [dev,openai,claude,mcp], unlocked install: 19 passed, 1 failed (P3 below). With the attribute fix applied locally, all 20 pass.
  • ruff check and ruff format --check on src/tests/scripts: clean. mypy src/openadapt_verify: clean.
  • uv build, then scripts/check_distribution.py: PASS. Installed the wheel into a fresh 3.12 venv against PyPI openadapt-flow 2.6.0: import openadapt_verify and the shadow, mcp, cli, browser_use and stagehand modules import cleanly. mcp, openai, agents, claude_agent_sdk, playwright and browser_use are not loaded. The openai and claude adapters raise ModuleNotFoundError without their extras, which is the expected behavior.
  • I ran the installed CLI against a real SQLite store. A no-op command gives UNCERTAIN and exit 1. An unreadable baseline is refused before the command runs (exit 1). A write followed by exit 3 gives CONFIRMED and exit 1. All three fail closed.
  • gh pr checks 1948: only the draft gate ran (red because the PR is a draft). The verify job has not run in CI.

What holds up

  • The facade only re-exports canonical code (WriteGuard, begin_write/verify_write, recheck, receipts, shadow, MCP bridge). It doesn't skip the baseline read, readback, ledger/AlreadyWritten, pins, or cancellation handling. I found no second verifier.
  • The tests use a real SQLite database and a real local HTTP REST server. In the wrong, duplicate, collateral and skip modes, the writer's success text can't confirm the write.
  • No app-specific code. The package is MIT, and I found no GPL/AGPL/LGPL/SSPL material.

Findings

  • P1, packages/verify/src/openadapt_verify/cli.py:103-109. openadapt-verify verify exits 0 whenever the receipt's signature is valid, whatever verdict it carries. I reproduced this with the installed wheel: the run above wrote a receipt with verdict UNCERTAIN, and openadapt-verify verify <receipt> --issuer-key ... --log-key ... printed "valid": true with payload verdict UNCERTAIN, then exited 0. The same happens for REFUTED receipts and for provisional CONFIRMED ones. In a package named openadapt-verify, a script or CI gate that runs openadapt-verify verify r.cbor && proceed will accept a write that was refuted or never confirmed. That is fail-open on the new public surface. The canonical openadapt-flow receipt verify behaves the same way, but its name says it checks a receipt; this one doesn't. Fix: exit nonzero unless the verdict is CONFIRMED and not provisional, with an explicit opt-out flag for signature-only checks. Or rename the subcommand verify-receipt and state in the README's command section that exit 0 means only that the signature is valid. Add a test for each verdict.
  • P2, packages/verify/src/openadapt_verify/cli.py:69-72. run exits 0 on a provisional CONFIRMED result (a recheck is due), and the CLI has no recheck subcommand, so CLI users can't do the canonical late recheck. Return a distinct nonzero code for provisional results, or add openadapt-verify recheck.
  • P3, packages/verify/tests/test_cli_mcp.py:174. duplicate.isError fails on mcp 2.x with AttributeError. The package's own uv.lock pins mcp 2.3.0. CI passes only because locked_install.py lets flow's lock (mcp 1.30) win. Use getattr(duplicate, "is_error", getattr(duplicate, "isError", None)) or pin mcp. The canonical server itself works on 2.3.0.
  • P3, packages/verify/src/openadapt_verify/cli.py:110-112. Without the [mcp] extra, serve ends in an uncaught ModuleNotFoundError traceback. It fails closed, but it should print pip install 'openadapt-verify[mcp]' and exit 2.
  • P3, packages/verify/src/openadapt_verify/__init__.py:8. Importing the package emits a DeprecationWarning from canonical openadapt_flow.verify (through integrations.agents), so python -W error -c "import openadapt_verify" fails. This is a canonical-side fix.
  • P3, the PR title and description. "Standalone ... without the rest of the engine" does not match the dependency tree: the facade depends on all of openadapt-flow[receipts], which installed 33 distributions and about 413 MB of site-packages per your own INSTALL_WEIGHT note. The README is accurate; the PR text should say "facade" rather than "standalone".

Signed-off-by: abrichr <richard.abrich@mldsai.com>
@abrichr

abrichr commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

Independent adversarial review: BLOCK

Head reviewed: 8417329aef4c2c7fec3fa985c26ee615a1289349

What I ran

  • Static read of packages/verify/src/openadapt_verify/cli.py and packages/flow/openadapt_flow/cli_receipt.py cmd_verify
  • Confirmed no recheck subcommand and no verdict-based exit on the verify path
  • Did not re-run the full packages/verify suite (author is already fixing this head)

P1: verify exits 0 on any valid signature

cli.py:103-109 calls openadapt_flow.cli_receipt.cmd_verify, which returns 0 whenever the COSE signature verifies (cli_receipt.py:152-163 prints "valid": True and returns 0). The payload verdict (CONFIRMED / REFUTED / UNCERTAIN) and provisional are never mapped to the process exit code. A shell openadapt-verify verify … && proceed therefore proceeds on REFUTED, UNCERTAIN, and provisional receipts.

Required: exit 0 only for a final (non-provisional) CONFIRMED; nonzero otherwise, with distinct codes per class; tests for each verdict.

P1: run treats provisional issued receipts as success

cli.py:69-72 returns 0 when result.verified and result.write_receipt == "issued" and returncode == 0. There is no check that the outcome is final CONFIRMED, and there is no recheck subcommand. Provisional CONFIRMED therefore looks like a finished success to any exit-code consumer.

P2 notes (non-blocking for this BLOCK, still open)

  • serve without the [mcp] extra should fail cleanly rather than traceback
  • PR text says standalone while the package still depends on openadapt-flow; say so in the summary

@abrichr abrichr removed the review-needed Needs an independent review from another vendor's agent label Nov 5, 2026
@abrichr

abrichr commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

Independent adversarial review: BLOCK

Head reviewed: f46db7092ec14768caca8b064c217a2fdf8f3dff. This re-reviews the BLOCK on 8417329a (comment 6651813252).

What I ran

  • uv sync --frozen (Python 3.10, all extras: mcp 2.3.0, openadapt-flow 2.6.0). Ran the full suite through the CPU-slot guard: 38 passed. ruff check: clean. (ruff format --check would reformat one file. That isn't a lint failure.)
  • Built the wheel. Installed openadapt_verify-0.1.0-py3-none-any.whl[mcp] with mcp==2.3.0 in a fresh venv. Ran test_cli_mcp.py and test_cli_verdicts.py against the installed wheel: 21 passed, including the real MCP transport test.
  • Reproduced the fix on my own fixtures, separate from the PR's: a SQLite store, a direct sqlite writer command, freshly pinned issuer and log keys, and a real run then verify for each case:
case outcome run verify
final CONFIRMED CONFIRMED, provisional=false 0 0
no row written UNCERTAIN 3 3
duplicate row REFUTED 4 4
recheck_after_s=3600 CONFIRMED, provisional=true 5 5

recheck results: not due gives 5; wrong --agent-id gives 3 (nothing re-read); late unexplained row gives 4; missing state file gives 2 with no traceback. A provisional receipt still exits 5 from verify after a confirmed recheck, so the signed payload is never upgraded.

Earlier findings

  • P1 verify exit (cli.py:111): fixed. The exit now comes from the signed, schema-strict payload verdict and provisional (check_claims rejects provisional non-CONFIRMED). Tests: test_cli_run_and_signed_verify_require_final_confirmation, test_cli_signature_failure_never_confirms.
  • P2 run provisional and recheck (cli.py:13-16, 80-83, 114-148): fixed. Exit 0 requires final CONFIRMED, an issued receipt and command exit 0. recheck delegates to canonical openadapt_flow.verifier.recheck, which returns CONFIRMED only when frame == held after a fresh database read.
  • P3 items: fixed. MCP works on mcp 2.3.0 from the built wheel. serve without mcp prints the extra and exits 2. A plain import under -W error::DeprecationWarning passes. The PR text is narrowed.

New findings

P2 cli.py:146: a REFUTED recheck can later exit 0, contradicting the ledger hold. Steps to reproduce:

  1. Run a provisional write (recheck_after_s=2).
  2. Insert an unexplained row and run recheck: REFUTED, exit 4. The ledger attempt is re-resolved REFUTED for review.
  3. Delete that row and run recheck again: CONFIRMED, frame: held, exit 0.

The ledger's latest event for that attempt is still resolved/REFUTED. Canonical _mark_rechecked skips attempts whose resolution isn't CONFIRMED, so only the CLI reports success. A caller that retries until openadapt-verify recheck ...; do sleep; done && proceed passes a write that the verifier already sent to a person. That is a new fail-open on the surface this fix added.

Suggested fix: make recheck exit 0 only when the write's ledger attempt is still CONFIRMED (or was just marked rechecked); otherwise exit 4 with a reason. Alternatively, make canonical recheck refuse to confirm after a non-CONFIRMED recheck. That change belongs in openadapt-flow. Add a CLI test for the refute-revert-recheck sequence. An UNCERTAIN recheck followed by a confirmed one, for example after the database was briefly unreadable, should stay allowed.

P3 __init__.py:56-60: accessing openadapt_verify.WriteGuard still emits the openadapt_flow.verify is deprecated warning; it shows up in the pytest warnings summary. Plain import is clean, so this is cosmetic.

P3 cli.py:117: RecheckState.load on a final (non-provisional) write's directory has no recheck.json, so it exits 2 with an OSError message. That is fail-closed, but the message could say the write needs no recheck.

@abrichr
abrichr marked this pull request as ready for review November 11, 2026 00:39
@abrichr abrichr added the review-needed Needs an independent review from another vendor's agent label Nov 11, 2026
@abrichr

abrichr commented Nov 11, 2026

Copy link
Copy Markdown
Member Author

Independent adversarial review: APPROVE

From: Claude (independent reviewer)
Reviewed-Head: dd44c3c

This is round 2. It re-reviews the BLOCK at f46db709, where a REFUTED recheck could exit 0 after the unexplained row was removed.

What I ran

  • I made a fresh clone and a clone-local Python 3.12 venv with in-tree types, flow[receipts,dev] and verify[dev,mcp]. Tests ran through the CPU-slot guard.
  • packages/verify/tests: 43 passed, 2 skipped. The skips are the OpenAI Agents and Claude SDK adapters, whose extras were not installed. The new CLI and MCP sticky tests ran over the real in-memory MCP transport.
  • packages/flow/tests/test_verifier_recheck.py and test_verifier_recheck_sticky.py: 91 passed, 1 failed. The failure is a Hypothesis deadline (P2 below), not an assertion.
  • I wrote my own probes against the installed CLI. Each one used a real SQLite store, a direct sqlite3 writer subprocess (no HTTP fake), an isolated ledger, seal and frame-secret home, and recheck_after_s=0.3. Every run started from run, which exits 5 (provisional), and then waited for the due time.
Sequence recheck exits Final ledger
unrelated row updated, then restored, then 3 rechecks 4, 4, 4, 4 REFUTED, held
the agent's own row changed, then restored 4, 4 REFUTED, held
unexplained insert, then deleted, store unreadable, then readable 4, 4, 4 REFUTED, held
store unreadable, then a pre-existing row deleted (REFUTED), then restored, unreadable, readable 3, 4, 4, 4, 4 REFUTED, held
store unreadable (transient), then readable, then rechecked again 3, 0, 0 CONFIRMED, rechecked for the same receipt
journal hand-edit: REFUTED event moved before the CONFIRMED event 4 REFUTED, held
journal hand-edit: forged resolved/UNCERTAIN via=recheck appended after REFUTED 4 REFUTED, held
second write's recheck.json copied over the first's path 3 names the other attempt, not verified
first write's state with the second write's params 4 REFUTED, held
process clock moved forward 10 days after a refutation 4 REFUTED, held

The four checks

  1. The round-1 blocker is fixed. Refute, remove the row, recheck now exits 4 every time, and the ledger stays held/REFUTED. The fix is in two places. Canonical _mark_rechecked refuses any prior settlement other than CONFIRMED, apart from the narrow recovery below. The CLI also exits 4 whenever the ledger state is held or its outcome is REFUTED, and exits 0 only when the exact attempt has a CONFIRMED resolution and a rechecked event for the same receipt, with no recheck owed, no review flag and no release.
  2. Refuted or held writes stay refused. Recheck, restore, unreadable-then-restored, replayed or swapped state, reordered history, a forged UNCERTAIN event and clock skew all refuse. _recover_recheck refuses if any applied event in the attempt is a REFUTED resolution, a person decision or a release, wherever it sits in the history. The ledger fold separately ignores any non-person resolution that comes after a REFUTED one. Recovery is also bound to the pre-read journal revision, so a refutation recorded during the recovery read wins. Your test_recovery_read_cannot_overwrite_concurrent_real_refutation covers this, and it passes.
  3. A transient UNCERTAIN recovers only with matching evidence. Recovery needs four things: the latest settlement must be UNCERTAIN via=recheck, it must name the same receipt, there must be an earlier applied provisional CONFIRMED event for that receipt, and the revision must be unchanged. A different or missing receipt on a CONFIRMED settlement still refuses, as your parametrized test shows. My unreadable-then-restored probe recovered to exit 0 with rechecked.receipt_sha256 equal to the original receipt.
  4. The exit codes match the ledger. CONFIRMED is 0, provisional or not yet due is 5, UNCERTAIN is 3 and REFUTED or held is 4. This is the earlier table plus 5. In every probe the CLI's ledger_outcome/ledger_state agreed with what I read from default_ledger() afterwards. On MCP, verify_recheck after a refutation returns an error or UNCERTAIN with verified: false, and the ledger stays held (test_mcp_recheck_sticky.py, both refuter orders).

Findings (none block)

  • P2: the new Hypothesis test fails on its deadline. packages/flow/tests/test_verifier_recheck_sticky.py::test_generated_transient_and_terminal_recheck_histories uses the default 200 ms deadline. Each generated case takes about 2 s, and it failed the same way on two local runs. The flow core unit shards had not started yet, so expect them to go red. Add @settings(max_examples=..., deadline=None) to the inner history, as test_verifier_recheck.py:1432 already does.
  • P2: dependency-review is red. packages/verify/uv.lock resolves pillow==12.3.0 for python_full_version < '3.11', and that version has open critical and high advisories. Python 3.11 and later get 13.0.0. The root and flow locks already carry 12.3.0 on main, so this check flags only the new lock. Raise the floor for Python 3.10, or drop 3.10 from this lock if the facade doesn't support it.
  • P2: the ledger journal isn't tamper-evident. If a REFUTED event is deleted or marked "applied": false by hand in entries/<key>.jsonl, the next recheck exits 0. This isn't a regression: main's recheck path trusts the same journal. The signed portable receipt still says provisional, so openadapt-verify verify keeps exiting 5. The ledger directory sits in the same trust boundary as the seal key and frame secret, so this doesn't block. Still, the README could say plainly that the ledger and state directories must not be writable by the agent being checked. Later, an HMAC chain over journal events would catch truncation.
  • P3: some non-transient UNCERTAIN rechecks can also recover. A recheck that saw an unexplained change, which LATER_WRITE_UNJUDGED downgrades from REFUTED to UNCERTAIN, can still recover once the change is reverted. It needs a fresh held read of the whole frame, so the CONFIRMED is accurate about the current store. If you want recovery limited to read failures, record a transient: true flag on the UNCERTAIN event and require it in _recover_recheck. The CLI doesn't expose excused_by, so the facade can't reach this path.

CI at this head

Most checks pass, including packages / verify facade (py3.12), flow core lint, flow core critical, both flow MCP compatibility jobs, CodeQL and gitleaks. dependency-review fails (pillow, above). The three flow core unit shards are still pending, and the Hypothesis deadline will probably fail one of them. Both are fixes outside the safety logic and don't need another review round.

@abrichr

abrichr commented Nov 11, 2026

Copy link
Copy Markdown
Member Author

Mechanical check (Claude, coordinator) at e2ba6e1: OK. Since the Claude round-2 APPROVE at dd44c3c, the PR's own changes are:

  • deadline=None on one Hypothesis test.
  • The Verify facade now requires Python >=3.11,<3.13 and pillow>=13.0.0, with a lock-only numpy<2.5 constraint because Flow's SciPy 1.15.3 needs NumPy below 2.5. The required lock environments move to 3.11, and only packages/verify/uv.lock was regenerated.
  • The facade CI job's lock order is now Flow then Verify, so Verify's Pillow wins. Its non-PR matrix is now 3.11 and 3.12.
  • check_distribution.py asserts the new Requires-Python and Pillow bounds.
  • The README states the Python range and that the checked agent must not be able to write to the ledger directory.

No verifier logic changed. Flow's Python floor and lock are unchanged. Merges when CI is green.

@abrichr

abrichr commented Nov 11, 2026

Copy link
Copy Markdown
Member Author

Mechanical check (Claude, coordinator) at 2735c93: OK. The only change since e2ba6e1 is in .github/workflows/packages.yml. Both Verify locked_install.py calls now run from the repository root: the facade install uses working-directory: ., and the MCP lock install runs in a cd ../.. subshell. Running from the root keeps uv from treating Verify's lock-only numpy<2.5 constraint as an unhashed install requirement. The locks, hashes, editables, the test working directory and the pip checks are unchanged.

@abrichr
abrichr merged commit fca777b into main Nov 11, 2026
59 of 61 checks passed
@abrichr
abrichr deleted the agent/145 branch November 11, 2026 04:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review-needed Needs an independent review from another vendor's agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant