Skip to content

feat(mandate): namespace constraint types, gated behind schema 1.2 - #533

Merged
Levaj2000 merged 1 commit into
mainfrom
claude/namespace-constraint-types
Sep 11, 2026
Merged

feat(mandate): namespace constraint types, gated behind schema 1.2#533
Levaj2000 merged 1 commit into
mainfrom
claude/namespace-constraint-types

Conversation

@Levaj2000

Copy link
Copy Markdown
Owner

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.constraints carries Constraint entries with a dotted type of at least two segments. Two segments 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. 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 inside constraints[]. One bound with two encodings is how the two end up disagreeing.

The risk was the signature, not the vocabulary

constraints sits 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_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.

Document version constraints in signable bytes
1.0 stripped
1.1 stripped
1.2 present, so the bounds are covered

Both 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_construct rather 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.

Check Result
Mandate suite 81 passed, 6 skipped
Gateway mandate tests 21 passed
ruff check / ruff format --check clean

Scope

This lands the vocabulary and the container, not a catalogue of implemented types. EVALUABLE_TYPES is 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

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
@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 3:15pm UTC
dashboard Ready Ready Preview Sep 11, 2026 3:15pm UTC

@Levaj2000
Levaj2000 marked this pull request as ready for review September 11, 2026 15:19
@Levaj2000
Levaj2000 merged commit d704eff into main Sep 11, 2026
11 checks passed
@Levaj2000
Levaj2000 deleted the claude/namespace-constraint-types branch September 11, 2026 15:19
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