Skip to content

feat(mandate): evaluate a mandate's signed conditions at enforcement - #532

Merged
Levaj2000 merged 1 commit into
mainfrom
claude/gateway-evaluates-conditions
Sep 11, 2026
Merged

feat(mandate): evaluate a mandate's signed conditions at enforcement#532
Levaj2000 merged 1 commit into
mainfrom
claude/gateway-evaluates-conditions

Conversation

@Levaj2000

Copy link
Copy Markdown
Owner

#531 made /verify honest 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 conditions 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 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.

gateway                          mandate service
  |  agent metadata + endpoint/method  |
  |----------------------------------->|  evaluate conditions
  |                                    |  then status/currency/limit
  |<-----------------------------------|  then record

The context

Agent metadata, plus 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. 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's when clause. 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_reason gains mandate_conditions_unmet. Per-condition results ride the audit row and the OCSF unmapped.mandate block as conditions_checked and conditions_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_cents asserts 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.

Check Result
Mandate suite 68 passed, 6 skipped
Gateway suite 142 passed
OCSF mandate-block tests 3 passed
ruff check / ruff format --check clean

Two pre-existing failures in my local environment, a rate-limiter timing test and seven OCSF export cases, were confirmed identical on clean main by 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

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
@vercel

vercel Bot commented Sep 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
ai-identity-landing Ready Ready Preview Sep 11, 2026 2:47pm UTC
dashboard Ready Ready Preview Sep 11, 2026 2:47pm UTC

@Levaj2000
Levaj2000 marked this pull request as ready for review September 11, 2026 14:50
@Levaj2000
Levaj2000 merged commit f8d6e1c into main Sep 11, 2026
11 checks passed
@Levaj2000
Levaj2000 deleted the claude/gateway-evaluates-conditions branch September 11, 2026 14:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants