Repository navigation
Learned excuses: learn the app's own per-request writes, then enforce exactly what a person approved - #1679
Conversation
6b63680 to
a9b471b
Compare
a9b471b to
f9d09a8
Compare
Adversarial review (round 1, independent): BLOCKEDHead I read The enforcement side holds up. Each check re-derives the relation from this run's actor and declared rows. I could not make an BLOCKER1. The agent's own systematic wrong write on the target is learned as a The PR's guard against poisoning is "only checks whose declared effects held teach". A write that sets the declared field correctly, and also changes an undeclared column of the same record, passes that guard. Shadow-mode learning watches the agent's own traffic, so a consistent agent mistake looks the same as the app's own bookkeeping. Repro, on SQLite, MariaDB and PostgreSQL, using the PR's
A
The same mechanism can teach an SHOULD-FIX2. An 3. The digest catches accidents, not edits. File: NIT4. One excuse can allow a column combination that was never seen. Each column must pass the K-of-N bar on its own, so 5. Shared accounts. The actor's id stands for every session of that app user, so another person's session under a shared login is excused within the learned columns. One line in Limits would cover it. 6. Checked and held (all three engines unless noted)
To clear the block: fix finding 1 (per-excuse opt-in or an explicit warning for |
a838344 to
829f290
Compare
Adversarial review (round 2, independent): BLOCKEDHead I read Most of round 1's fixes hold. The learner no longer proposes a BLOCKER1. The written row is learned as an "target": bool(c.on_target and not c.other_on_target),A declared row that another actor also changed in the window is collateral with At enforcement the excuse never applies to its source, because declared rows never reach Repro, on SQLite, MySQL and PostgreSQL. The
The setup is narrow: the declared field must already hold its value (an idempotent or ensure-style write), and something else must write the record in the window. Neither is malicious, though. An app's auto-assignment rule under a service account is enough. It also breaks the stated rule that the written row is never learned, which was round 1's blocker. Fix: SHOULD-FIX2. The "a column the agent sends" rule reads request bodies only. File: 3. NIT4. 5. An Checked and held (SQLite, MySQL, PostgreSQL)
To clear the block, fix finding 1 and add a test for it. Findings 2 and 3 can go in a follow-up. |
… exactly the approved moves
…d; docs, CLI test
…until the engine ships it
…ant names (update contracts too)
…d the actor, and each must hold it
… or the contract names; exact column sets; max_rows on added actor rows; all-or-none approval; digest and shared-account limits in the docs
…y is refused at load
829f290 to
8196408
Compare
…other actor also wrote it; query-string names count as sent; a warning on every excuse in the proposal
Adversarial review (round 3, independent): APPROVEHead CI on I reran round 2's attack tests against local throwaway PostgreSQL 17 and MySQL 5.7 servers (temporary data directories, since removed) and SQLite, together with Round 2 items
New findingsSHOULD-FIX (follow-up). Bracket-style names aren't matched to their column. NIT. No blockers. |
Stacked on #1678 (shadow mode). Large: about 2,700 lines of its own, 1,230 of them tests.
The problem
Many apps write to their own database on every request: the acting user's
last_login, an API token'slast_seen, a first-login property row for the acting user. The whole-database frame sees those rows move inside every write's window, nothing in the contract explains them, and it refutes every write, correct or not. In the held-out retest, three of four apps had every correct write refuted this way.The change: learn, then enforce
openadapt-flow learn(alsopython -m openadapt_flow.learnandopenadapt learn):collect: runs shadow mode (SQL) with a hook onShadow.on_checked. Each verifier call adds one line toepisodes.jsonl: the kind, the outcome, which parts held, and the frame's moved rows by table and column name. No record values.propose: writesproposal.yamland a readableproposal.mdwith a digest.approve: one command by a person writes the excuses into the contracts asframe.learned, recording the proposal's digest, who approved and when. All contracts of a kind or none. An edited proposal is refused.Fail closed at every step:
target(a row the write declares or adds) oractor_row(the users table's row keyed by the actor's id, or a row whose user column holds it; the user columns come from the schema's foreign keys or a person's--user-column). Any other row is listed as "stays a stop", however often it moves.{table, columns, change: changed|added, relation, actor_column}. Exactly the columns seen to move. Never a whole table, a wildcard, any row, or a removal. Proposed only when seen in at least K of every N learnable checks of that kind (defaults 3 of 5; each column must meet the same bar). Why: five is the smallest sample in which a majority still allows two checks where a throttled column (a last-used time written at most once a minute) didn't move, and a one-off isn't proposed. The count isn't the safety boundary; the relation is.frame.learnedinruntime/effects/learned.pyandframe.py): at each check and recheck the relation is derived again from this run's actor and declared rows. Atargetexcuse applies only to rows this write declares; anactor_rowexcuse only when the actor column holds this run's actor id before and after; the moved columns must all be in the excuse. A row in a table overmax_rows, whose baseline didn't keep every column, is never excused. Anything else is collateral, as before. The receipt names the excuses used and the proposal digest. Contracts without the key keep their digests.The verifier now also reports, for a refuted frame, each collateral row by names only (
evidence.frame.moves: table, change, columns, whether it's the write's own row, which columns hold the actor's id). The learner reads only that.Tests
tests/test_learned_excuses.py, on SQLite, and on MariaDB and PostgreSQL withOPENADAPT_SNAPSHOT_MYSQL/OPENADAPT_SNAPSHOT_POSTGRES:actor_rowexcuse never covers another user's row or token, another column of the user's row, a row moved to the actor, a removal, a row added for another user, an added row of a kind only changes were learned for, a column a large table didn't compare, or the same moves under another run's actor;targetexcuse never covers a look-alike row, another column of the target, or the excused column on a second row (the retest's fail-open, where a column excused for the target was excused on every row);--reject;A mutation check (scratch, not committed): letting any row count as the actor's fails 6 tests; letting any column count fails 2; letting a row count as the target fails 2.
Real-app evidence
Running.
Docs
packages/flow/docs/LEARNED_EXCUSES.md(new), pointers inVERIFY_RUN.mdandSHADOW_MODE.md, the command map and CAPABILITIES.Note on the diff
This branch merges
maininto #1678's branch to get the PostgreSQL frame (#1673), so until #1678 is updated frommainthe diff against it also shows #1672 and #1673.