Score each action once in a policy dry-run, not once per audit row it wrote - #294
Open
kevin9327 wants to merge 1 commit into
Open
Score each action once in a policy dry-run, not once per audit row it wrote#294kevin9327 wants to merge 1 commit into
kevin9327 wants to merge 1 commit into
Conversation
… wrote Testing a candidate boundary against recent history replays the audit trail's computer-action rows. It replayed three event types, and one is a duplicate: a permitted action that fails is recorded twice — the decision row written before it was attempted (action_allowed / action_refused) and a failure row written when it did not succeed (action_failed) — and both carry the same action and are returned by the query. So every failed action was scanned and scored twice. And because a dry-run policy carries a refused action out, a refused action can fail too, leaving two rows that disagree on the baseline: the decision row records "refused", while the failure row has no decision of its own and fell to the "allowed" branch. A candidate policy that refused the same action identically was then reported as a new refusal it never introduced. The failure row is an outcome, not a decision. The replay now skips it and scores each action once from its decision row, and the query no longer fetches it — so it also stops spending the scan budget on rows that would be dropped. The baseline for a permitted-but-failed action is still "allowed", because that is what its decision row says. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kevin9327
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso and
tylerslaton
as code owners
August 30, 2026 06:22
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
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 this changes
POST /policy-dry-runlets an admin test a candidate boundary against recent history before saving it: the drafted policy is replayed over recorded computer actions, and the report says which it would decide differently. The replay double-counts.The gateway records a permitted action that fails at runtime with two rows (
server/src/computer/gateway.ts): the decision row written before the attempt (computer.action_allowed/computer.action_refused, line 524), and a failure row written in the catch when it did not succeed (computer.action_failed, line 594). Both carry the same action payload.REPLAYABLE_EVENT_TYPESlisted all three, so the trail query returned both rows for a failed action anddryRunAgainstHistoryscored each twice — inflatingscanned,wouldRefuse/wouldAllowandunchanged.Worse in dry-run mode, where a refused action is still carried out (
policy.tssetsforward: true) and so can fail. Its two rows then disagree on the baseline: the decision row recordsrefused, but the failure row has no decision of its own and fell to the"allowed"branch (policy-dry-run.ts:142). A candidate policy that refused the same action identically was reported as a new refusal it never introduced — a phantom change in the exact report an admin uses to decide whether a rule is safe to save.The failure row is an outcome, not a decision. The fix scores each action once, from its decision row:
dryRunAgainstHistoryskipscomputer.action_failed, andREPLAYABLE_EVENT_TYPESno longer lists it (so the query stops spending its row budget on rows that would be dropped). A permitted-but-failed action's baseline staysallowed— that is what its decision row says.Reproduced before the fix (both now fixed): a permitted-but-failed action gave
scanned: 2; a dry-run refused-then-failed action gavewouldRefuse: 1against a policy that changed nothing.Where it runs
dryRunAgainstHistoryis a pure function over anAuditEvent[]the caller queried; the query lives inserver/src/computer/routes.ts(POST /policy-dry-run). No new state.Boundary and audit
Changelog
Unreleased: "A policy dry-run no longer counts a failed action twice, or invents a change it did not make". (The dry-run feature shipped in 0.0.5, so a deployment's report behaves differently after upgrading.)Proof
Verified locally (bun 1.3.14):
bun test server/tests/policy-dry-run.test.ts— 10 pass / 0 fail. The prior test that asserted a lone failure row scores as allowed (a shape the query never returns on its own) is replaced by two that reflect production: a permitted-but-failed action (decision row + failure row) is counted once from its decision row; a dry-run refused-then-failed action invents no change. Existing cases (new deny, loosened policy, neutral absent facts, count-past-cap) still pass.cd server && bun run typecheck— clean.bunx biome format/bunx biome linton the changed source and test — clean.(Integration tests needing Postgres fail locally with
Connection closed; that is the absent local DB. This suite is pure and needs none.)