feat(mandate): evaluate a mandate's signed conditions at enforcement - #532
Merged
Conversation
PR #531 made /verify honest about conditions it could not evaluate. It did not make anything evaluate them. The gateway, which actually gates requests, never saw them at all: it draws against /mandates/{id}/spend and that response carried no conditions. An issuer could sign {"env": "prod"} and a staging agent would spend against the grant unimpeded. Where the evaluation lives, and why it is not the gateway. The check has to be atomic with the spend. Evaluating at the gateway means evaluating after the draw returns, by which point an accepted spend is already recorded against the budget of a request about to be denied. So the gateway sends the context it alone holds and the service that owns the grant applies its own terms before mutating state. The context is agent metadata plus the endpoint and method. Those two are reserved and win over a stored attribute of the same name: an agent whose metadata claims endpoint=/v1/safe must not satisfy a condition pinning the mandate there while calling something else. Conditions reuse common.policy.eval.evaluate_when, the same function behind a policy's `when` clause, so a mandate condition and a policy condition mean the same thing and fail the same way. A missing key or an unknown operator is a failed match, never a pass, so a caller that sends no context is denied on a conditioned mandate. That is the intended rollout direction. Ordering: after status, before currency and limit. A grant that does not cover the request is not a budget question, and asking how much is left on an inapplicable grant is the wrong question. Evidence: deny_reason gains mandate_conditions_unmet, and the per-condition results ride the audit row and the OCSF unmapped.mandate block as conditions_checked / conditions_failed, so a denial says not just "denied" but which field failed with expected against actual. One judgment call, made explicit in the test that pins it. A condition-failing settlement is refused rather than recorded, unlike an over-limit settlement. Incrementing spent_cents asserts this grant funded the spend, which is what the failed condition denies. Nothing is lost: the audit row and event log capture the attempt either way, only the budget counter stays untouched. Thirteen new tests. Mandate suite 68 passed / 6 skipped, gateway suite 142 passed, ruff check and format clean. Two pre-existing failures in this environment (a rate-limiter timing test and seven OCSF export cases) were confirmed identical on clean main and are unrelated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyU3PRkgvMxgimkPBC5726
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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.
#531 made
/verifyhonest about conditions it could not evaluate. It did not make anything evaluate them, and it said so. This closes that gap.The gateway, which actually gates requests, never saw
conditionsat all: it draws against/mandates/{id}/spend, and that response carried no conditions. An issuer could sign{"env": "prod"}and a staging agent would spend against the grant unimpeded.Where the evaluation lives, and why it is not the gateway
The obvious reading of "make the gateway evaluate conditions" puts the check at the gateway. That is wrong, for an ordering reason worth stating.
The check has to be atomic with the spend. Evaluating at the gateway means evaluating after the draw returns, by which point an accepted spend is already recorded against the budget of a request you are about to deny. You would refuse the request and keep the charge.
So the halves meet instead: the gateway sends the context it alone holds, and the service that owns the grant applies its own terms before mutating state.
The context
Agent metadata, plus
endpointandmethod. Those two are reserved and win over a stored attribute of the same name. An agent whose metadata claimsendpoint: /v1/safemust not satisfy a condition pinning the mandate there while calling something else. There is a test for exactly that.Reuse rather than a second evaluator
Conditions go through
common.policy.eval.evaluate_when, the same function behind a policy'swhenclause. A mandate condition and a policy condition now mean the same thing and fail the same way: a missing key or an unknown operator is a failed match, never a pass.That is also what makes the rollout safe in the right direction. A caller that sends no context is denied on a conditioned mandate, rather than quietly spending against one.
Ordering
After status, before currency and limit. A grant that does not cover the request is not a budget question, and asking how much is left on an inapplicable grant is the wrong question. Tests pin both edges: status still outranks conditions, and satisfied conditions do not rescue an over-limit spend.
Evidence
deny_reasongainsmandate_conditions_unmet. Per-condition results ride the audit row and the OCSFunmapped.mandateblock asconditions_checkedandconditions_failed, so a denial says not just "denied" but which field failed, with expected against actual. That is the same standard the policy engine already holds itself to.One judgment call, stated rather than buried
A condition-failing settlement is refused rather than recorded, unlike an over-limit settlement. The symmetry argument says record it, since money that moved cannot be un-moved.
It is refused because incrementing
spent_centsasserts this grant funded the spend, which is precisely what the failed condition denies. Nothing is lost: the route writes the audit row and the event log entry on every call, accepted or not, so the attempt and its reason stay in the chain. Only the budget counter is untouched. The test that pins this carries the full reasoning.Tests
Thirteen new: nine on the evaluation itself, four on the gateway's context assembly.
ruff check/ruff format --checkTwo pre-existing failures in my local environment, a rate-limiter timing test and seven OCSF export cases, were confirmed identical on clean
mainby stashing and re-running. They are unrelated to this change and were green in CI on main earlier today.🤖 Generated with Claude Code
https://claude.ai/code/session_01XyU3PRkgvMxgimkPBC5726
Generated by Claude Code