Show which rule required the approval - #191
Conversation
An approver reviewing a policy could read its CEL expression, but an approver reviewing an actual request saw only the tool name and the argument values. So a call sitting exactly on a threshold looked identical to one in the middle of a band, and a mis-scoped rule was invisible at the one moment someone could catch it. The matched rule is now snapshotted onto the approval request when it is created: rule id, name, expression, its type, priority, the decision, and the ids of any lower-priority rules that also matched. Snapshotted, not recomputed, so editing or deleting a rule later cannot rewrite the record of why a past call was gated. Cases without a rule are stated plainly rather than dressed up: default tool gating says the tool requires approval for every call, a rule that failed to evaluate says Preloop failed closed, and the request_approval builtin records nothing at all. Surfaces omit the block when there is no context; historical rows stay blank rather than get a fabricated reason. The console shows a "Why this needs approval" block with the expression verbatim in monospace; list rows show the rule name only, since a clipped expression reads as a different rule. Push payloads carry the rule name for the same reason.
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
Preloop Code ReviewLast Updated: 2026-08-06 (re-review pass) SummaryThis PR adds rule context snapshots to approval requests so approvers can see which policy rule gated a call. The implementation is clean: What Looks Good
Issues FoundMedium Priority
Resolved Issues
Progress: 1 of 2 issues addressed This summary updates automatically on each review. Inline comments provide detailed feedback on specific lines. |
Two things from the PR review. The sync and async evaluators each built the rule snapshot inline with identical code, which is how the two quietly start recording different things about the same rule. Both now call _matched_rule_context(). "Review this rule in Tools" dropped the reviewer into the full tool catalogue. There is no per-tool route to deep-link to yet, so the link now carries the tool name in the hash and the Tools page narrows its list to it. Without a tool name it falls back to the plain page.
The gap
Reviewing a policy, you can read its CEL expression. Reviewing an actual
approval request, you saw the tool name and the argument values and nothing
else.
So a call sitting exactly on a threshold looked identical to one in the middle
of a band, and a mis-scoped rule was invisible at the one moment someone could
still catch it. The approver had the numbers but not the line they were being
measured against.
Origin: a LinkedIn thread with Dylan Merigaud, who shipped PO approval routing
at Pivot. His "amount exactly on the threshold" story is this exact blindness,
on our surface.
What changed
The matched rule is snapshotted onto the approval request when it is created:
rule id, name, expression, expression type, priority, the decision, and the ids
of any lower-priority rules that also matched (the mis-scoping signal).
Snapshotted, not recomputed at read time. Editing or deleting a rule tomorrow
must not rewrite the record of why a call was gated last week.
The console approval view gets a "Why this needs approval" block with the
expression verbatim in monospace, tags for its priority and the arguments it
inspects, and a link to review the rule. List rows and push payloads show the
rule name only: a clipped expression reads as a different rule.
Where there is no rule
Five things can gate a call and only one is a
ToolAccessRule. The rule-lesscases say so plainly instead of inventing an expression:
require approval for every call, whatever the arguments are."
request_approvalbuiltin: records nothing at allNo context means the block is not rendered. Historical rows stay blank rather
than get a fabricated reason.
The block reports what matched and nothing more. There is no risk score here
and no recommendation; a test asserts the copy does not claim one.
Compatibility
evaluate_policy()returnsPolicyDecision, atuplesubclass that IS thesame 3-tuple and carries
.rule_contexton the side. Existing callers unpackit unchanged; tests that patch the evaluator with a plain tuple still work,
because every consumer reads the snapshot with
getattr(..., None)and amissing one reads as "not recorded" rather than raising on the enforcement
path.
New column is nullable JSONB; the read-schema field is optional.
Tests
36 backend tests (including the boundary-amount case, winning-rule-by-priority,
also-matched rules, and all three rule-less paths) and 9 frontend tests.
Nine mutations were run against the implementation and each killed the intended
test; one initially survived, so a test was added for it. Details in
factory/briefs/2026-08-06-approval-rule-context-report.md.Note
Migration is parented on
20260806_ai_model_updated_at(from #190), not on20260801_stagger_email, to keep a single alembic head.Note
Low Risk
Well-tested snapshot of rule context onto approval requests with backward-compatible tuple subclass design. No security concerns. One pre-existing code structure observation remains (duplicate sync/async eval loops).
Overview
Adds a
rule_contextJSONB column toApprovalRequestcapturing the policy rule that gated a call. ThePolicyDecisiontuple subclass preserves backward compatibility while carrying rule identity, expression, priority, and also-matched rules through the approval pipeline. A new frontend block renders "Why this needs approval" with the expression verbatim.Written by Preloop PR Reviewer for commit on feat/approval-rule-context. Updates automatically on new commits.