feat(mandate): namespace constraint types, gated behind schema 1.2 - #533
Merged
Conversation
A mandate could express exactly one bound, spend_limit, as a bare field with no type discriminator. Adding a second kind was never a one-line change: there was nothing to dispatch on, so every consumer would have had to infer a bound's kind from which keys happened to be present. MandateDocument.constraints now carries Constraint entries with a dotted type of at least two segments. Two segments minimum is what makes it a namespace rather than a bare word: spend.ceiling and someone else's budget.ceiling do not collide on the unqualified noun, so types from our vocabulary, a standards body's and a counterparty's can coexist without a naming argument first. Unknown types are rejected at parse rather than skipped, the same rule already applied to unknown top-level fields and to unevaluated conditions. A registered type with no evaluator behind it denies at spend time under mandate_constraint_unevaluable: naming a type is a promise to evaluate it, and a registry longer than the evaluator is how a vocabulary starts lying about what it enforces. The ceiling stays on spend_limit, registered in the vocabulary but refused inside constraints[]. One bound with two encodings is how the two disagree later. The risk here was the signature, not the vocabulary. constraints sits inside the signed payload, so adding it naively would have given every re-serialized 1.0 and 1.1 document an empty list it was never signed over, and every mandate ever issued would have stopped verifying. _POST_1_1_FIELDS strips it below schema 1.2, extending the gate _POST_1_0_FIELDS already established when spend_limit was added. Default schema_version moves to 1.2. Thirteen tests. Two parametrized cases pin the canonical bytes for 1.0 and 1.1 and both go red against the naive implementation, verified by removing the gate and re-running. One checks that a constraint added after signing fails verification, built with model_construct because an attacker editing a stored document does not pass through the validator. Mandate suite 81 passed / 6 skipped, gateway mandate tests 21 passed, ruff check and format clean. 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.
The last open item from the Verifiable Intent review. A mandate could express exactly one bound,
spend_limit, as a bare field with no type discriminator, so adding a second kind was never a one-line change: there was nothing to dispatch on, and every consumer would have had to infer a bound's kind from which keys happened to be present.The namespace
MandateDocument.constraintscarriesConstraintentries with a dottedtypeof at least two segments. Two segments is what makes it a namespace rather than a bare word:spend.ceilingand someone else'sbudget.ceilingdo not collide on the unqualified noun. Types from our vocabulary, a standards body's, and a counterparty's can coexist without anyone winning a naming argument first.Unknown types are rejected at parse, not skipped. Same rule already applied to unknown top-level fields (#531) and unevaluated conditions (#532), and the same one argued upstream in ocsf#1756.
A registered type with no evaluator behind it denies at spend time under
mandate_constraint_unevaluable. Naming a type is a promise to evaluate it, and a registry longer than the evaluator is how a vocabulary starts lying about what it enforces. The registry is deliberately narrow for that reason.The ceiling stays on
spend_limit: registered in the vocabulary, refused insideconstraints[]. One bound with two encodings is how the two end up disagreeing.The risk was the signature, not the vocabulary
constraintssits inside the signed payload. Adding it naively would have given every re-serialized 1.0 and 1.1 document an empty list it was never signed over, and every mandate ever issued would have stopped verifying._POST_1_1_FIELDSstrips it below schema 1.2, extending the gate_POST_1_0_FIELDSalready established whenspend_limitwas added. Defaultschema_versionmoves to 1.2.constraintsin signable bytesBoth directions are pinned. Absent for old versions, or old signatures break. Present for 1.2, or the namespace is decorative and a constraint could be added or dropped without disturbing the signature.
Tests
Thirteen new. The two parametrized compatibility cases are the ones that earn their place: I removed the version gate and re-ran to confirm both go red, then restored it and confirmed green.
One checks that a constraint added after signing fails verification. It is built with
model_constructrather than the normal constructor, because an attacker editing a stored document does not pass through our validator. That also makes the point that the signature, not the parser, is what has to catch it.ruff check/ruff format --checkScope
This lands the vocabulary and the container, not a catalogue of implemented types.
EVALUABLE_TYPESis empty on purpose: the next constraint type is now an additive change with a place to go, which was the entire objective.🤖 Generated with Claude Code
https://claude.ai/code/session_01XyU3PRkgvMxgimkPBC5726
Generated by Claude Code