From b12a9520ed408ee120c6f39d251c9206e087b35d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 15:14:22 +0000 Subject: [PATCH] feat(mandate): namespace constraint types, gated behind schema 1.2 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 Claude-Session: https://claude.ai/code/session_01XyU3PRkgvMxgimkPBC5726 --- CHANGELOG.md | 1 + mandate/app/constraints.py | 121 +++++++++++++ mandate/app/routers/mandates.py | 1 + mandate/app/schemas.py | 17 +- mandate/app/signing.py | 18 +- mandate/app/spend.py | 19 ++ mandate/tests/test_constraint_namespace.py | 200 +++++++++++++++++++++ 7 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 mandate/app/constraints.py create mode 100644 mandate/tests/test_constraint_namespace.py diff --git a/CHANGELOG.md b/CHANGELOG.md index be167ad8..bcd51ff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **The OCSF audit plugin tracks PR #84 at `499ee91` and holds `base64` at 0.22 to match the PPE tree.** Teryl answered the two dependency findings on praxis-proxy/policy PR #84 on 2026-09-09: `sha2` is now a workspace dependency at `0.11` (`ef20d8f`), and `base64` stays at `0.22` across the tree because `jsonwebtoken` 11, which `identity-jwt` depends on, requires it, so `0.23` would put two majors in the build (verified against the PPE lock). The same head merges the #82 docs reorganisation, which moves the auditing guide to `docs/content/auditing.md`; nothing on the seam this crate consumes changed between `3e7734e` and `499ee91`. `integrations/cpex-ocsf-audit` moves its pin to that head and its `base64` requirement from `0.23` (a Renovate bump, #497) down to `0.22`, so the copy that lands under `reference/plugins/ocsf-audit` (`PRAXIS-INTREE-PLAN.md`) is a copy and the PPE build stays on one base64; a Renovate rule holds it there. Verified the way CI runs it, on toolchain 1.96.1 with a cpex sibling at `64c8eba`: warning-free `--locked` builds and 34 tests green on each host, `emit_sample` and `decision_sink_demo` byte-identical to the committed vectors, so the AID-EMIT-1 section 12 conformance vector is untouched. The in-tree plan drops `sha2` and `base64` from its list of workspace-table adds (only `p256` remains), records the `base64` answer as settled, and follows the auditing guide to its new path. He also repeated that the `plugin_settings:` load error predates #84 (praxis-proxy/policy #55), which `PRAXIS-PORT-RESULTS.md` observation 3 already records. ### Added +- **Constraint types on the mandate document are namespaced, and the schema version bump that required is the interesting part.** 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. `MandateDocument.constraints` now carries `Constraint` entries with a dotted `type` of at least two segments (`mandate/app/constraints.py`), so `spend.ceiling` and a counterparty's `mastercard.payment.budget` coexist without either side winning a naming argument first. Unknown types are rejected at parse rather than skipped, the same rule already applied to unknown top-level fields and unevaluated `conditions`, and a registered type with no evaluator behind it denies at spend time under the new `mandate_constraint_unevaluable` reason: naming a type is a promise to evaluate it. The ceiling itself stays on `spend_limit`, registered in the vocabulary but refused inside `constraints[]`, because one bound with two encodings is how the two disagree later. **The risk 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, silently invalidating every mandate ever issued. `_POST_1_1_FIELDS` strips it below schema 1.2, extending the gate `_POST_1_0_FIELDS` already established for `spend_limit`; the default `schema_version` moves to 1.2. Two parametrized tests 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. Thirteen new tests, including that a constraint added after signing fails verification (built with `model_construct`, since an attacker editing a stored document does not pass through the validator). Mandate suite 81 passed / 6 skipped, gateway mandate tests 21 passed, ruff clean. - **The gateway's enforcement path now evaluates a mandate's signed `conditions`, closing the gap `#531` documented but did not fix.** `conditions` was written at issue time and read by nothing: the verify endpoint reported `valid: true` while ignoring it (fixed in `#531`), and the gateway, which actually gates requests, never saw it, because 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. The evaluation lands in the Mandate Service rather than the gateway, which looks backwards until the ordering is considered: the check must be atomic with the spend, and 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 (agent metadata plus endpoint and method, with the observed request facts winning over stored attributes claiming otherwise) and the service that owns the grant applies its own terms before mutating state. 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. A caller that sends no context is therefore denied on a conditioned mandate, which is the intended rollout direction. Conditions are checked after status but before currency and limit, because a grant that does not cover the request is not a budget question; `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. A condition-failing settlement is refused rather than recorded, unlike an over-limit settlement: incrementing `spent_cents` would assert this grant funded the spend, which is what the failed condition denies, and the audit row and event log still capture the attempt either way. Thirteen new tests; mandate suite 68 passed / 6 skipped, gateway suite 142 passed, ruff clean. - **The in-tree port plan now sizes the `base64` version conflict, and answers the #84 review's version-spec claim before it reaches our PR.** `PRAXIS-INTREE-PLAN.md` listed `p256`, `sha2` and `base64` as three equal adds to PPE's `[workspace.dependencies]`. Read against the manifests at `3e7734e`, only `p256` is one: `sha2 = "0.11"` is already declared directly by four crates (`ppe-core`, `ppe-apl-runtime`, `builtins/plugins/delegator-oauth`, `builtins/session/valkey`) at the version this crate uses, which is the inconsistency praxis-bot flagged on PR #84 on 2026-09-09, and `base64` is a conflict rather than an add, since `identity-jwt`, `delegator-oauth` and `elicitation-ciba` each declare `0.22` directly while this crate is on `0.23`, a semver-incompatible major. The plan's default is now to pin this crate down to `0.22`: base64 is reached only through `Engine::encode` and `Engine::decode` on `general_purpose::STANDARD` in `src/sign.rs` and `src/emitter.rs`, an API unchanged across both versions, so the expected cost is the manifest line rather than a base64 upgrade across three builtins the port PR has no other reason to touch. Carrying both majors is the third option and the worst one; `deny.toml` sets `multiple-versions = "warn"`, so `make audit` would not fail, but the tree has one major today. The choice goes to the PR thread as an open question. The plan also records that the other half of the #84 review, a patch component on the version spec (`sha2 = "0.11.0"`), is not this tree's convention: the workspace table uses `tokio = "1"`, `thiserror = "2"` and `hashbrown = "0.17"` throughout. Docs only, no product impact. - **In-tree port plan for the OCSF plugin into praxis-proxy/policy.** Teryl settled the two open decisions on 2026-09-08 after talking to Fred: the plugin lands under `reference/plugins/ocsf-audit` next to `audit-logger`, `publish = false` and not supported, and moves to `builtins/` after feedback; the source is this directory on `main` (v0.0.3, 34 tests, AID-EMIT-1 1.1.0), not cpex#128, and he will swap the link on praxis #12. `PRAXIS-INTREE-PLAN.md` records the decisions and everything PPE's own rules demand of the copy, read from `CONTRIBUTING.md`, `AGENTS.md`, the workspace lints, `clippy.toml`, `.markdownlint.yaml` and `deny.toml` at `3e7734e`: human-signed commits with no AI trailers, two-line SPDX headers, no history in comments (93 lines to restate), edition 2024, and a lint set that denies `unwrap_used`, `missing_assert_message` (159 sites), `print_stdout`, `missing_docs` and `doc_markdown`, with `allow-git = []` so `p256`, `sha2` and `base64` join `[workspace.dependencies]`. It sizes each pass, lists what is not carried (`run-demo.sh`, the measurement records, the lock), sets the acceptance bar (34 green, `emit_sample` byte-identical to the section 12 vector, zero mapping diff), and offers the history-preserving `git-filter-repo` import PPE documents in `docs/port-provenance.md` as the recommended landing. Gated on PR #84 merging; the earlier one-hour estimate becomes a day. Docs only, no product impact. diff --git a/mandate/app/constraints.py b/mandate/app/constraints.py new file mode 100644 index 00000000..4e03de75 --- /dev/null +++ b/mandate/app/constraints.py @@ -0,0 +1,121 @@ +"""Namespaced constraint types for the mandate document. + +Until now a mandate could express exactly one bound, `spend_limit`, as a bare +field with no type discriminator. Adding a second kind of bound to that shape +is not a one-line change: there is nothing to dispatch on, so every consumer +would need to infer a constraint's kind from which keys happen to be present. + +A constraint therefore carries a namespaced `type`, and the namespace is the +point. Types arrive from more than one place: ours, a standards body's, and a +counterparty's. `spend.ceiling` and `mastercard.payment.budget` can coexist +without either side having to win a naming argument first. + +Unknown types are REJECTED, not skipped. A mandate is an authorization +document, so a bound this version cannot evaluate may be the one that +restricts the grant; treating it as absent would widen authority past what +the issuer signed. That is the same rule the service applies to unknown +top-level fields and to unevaluated `conditions`, and the same one argued +upstream in ocsf#1756. + +Registered here is deliberately narrow. 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. +""" + +from __future__ import annotations + +import re +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +# --- The namespace --- + +#: Dotted lowercase segments: `.`, at least two deep. Two +#: segments minimum is what makes it a namespace rather than a bare word, +#: so a later `spend.ceiling` and someone else's `budget.ceiling` do not +#: collide on the unqualified noun. +NAMESPACE_PATTERN = r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$" +_NAMESPACE_RE = re.compile(NAMESPACE_PATTERN) + +#: Cumulative monetary ceiling over the mandate's life. Registered so the +#: vocabulary names the bound the document already enforces, but NOT valid +#: inside `constraints`: `spend_limit` is its one encoding, and two ways to +#: say the same thing is how the two disagree later. See `Constraint`. +TYPE_SPEND_CEILING = "spend.ceiling" + +#: Every type this version knows. Membership means "this name is real", not +#: "this version enforces it". See EVALUABLE_TYPES below. +REGISTERED_TYPES: frozenset[str] = frozenset({TYPE_SPEND_CEILING}) + +#: Types with an evaluator behind them. A registered type that is not here +#: is a name we have reserved and cannot yet check, which denies rather than +#: passes. +EVALUABLE_TYPES: frozenset[str] = frozenset() + +#: Types that may not appear in `constraints` because a dedicated field on +#: the document already carries them. +_FIELD_BACKED_TYPES: frozenset[str] = frozenset({TYPE_SPEND_CEILING}) + + +class UnknownConstraintTypeError(ValueError): + """Raised for a constraint type this version does not recognize.""" + + +class Constraint(BaseModel): + """One namespaced bound on a mandate's authority. + + Part of the signed grant. `params` is intentionally open, because the + shape of a bound varies by type and a closed union here would mean + every new type is a schema migration; the type's evaluator validates + its own params. + """ + + model_config = ConfigDict(extra="forbid") + + type: str = Field( + pattern=NAMESPACE_PATTERN, + description=( + "Namespaced constraint type, e.g. 'spend.ceiling'. At least two " + "dotted segments so an unqualified noun cannot collide with " + "another vocabulary's." + ), + ) + params: dict[str, Any] = Field( + default_factory=dict, + description="Type-specific parameters, validated by that type's evaluator.", + ) + + @field_validator("type") + @classmethod + def type_is_known(cls, v: str) -> str: + if v in _FIELD_BACKED_TYPES: + raise ValueError( + f"'{v}' is carried by its own field on the mandate, not by " + f"constraints[]. Two encodings of one bound is how the two " + f"disagree later." + ) + if v not in REGISTERED_TYPES: + raise UnknownConstraintTypeError( + f"Unknown constraint type '{v}'. A bound this version cannot " + f"evaluate may be the one that restricts the grant, so it is " + f"rejected rather than ignored. Registered: " + f"{sorted(REGISTERED_TYPES) or 'none'}." + ) + return v + + +def is_namespaced(type_name: str) -> bool: + """Whether a string is shaped like a constraint type name.""" + return bool(_NAMESPACE_RE.match(type_name)) + + +def unevaluable(constraints: list[Constraint]) -> list[str]: + """Types present on the mandate that this version cannot check. + + Non-empty means the grant carries a bound nobody evaluated, which denies. + Separate from validation because a type can be legitimately registered + before its evaluator lands, and the document should still parse so an + operator can see what it is holding. + """ + return sorted({c.type for c in constraints if c.type not in EVALUABLE_TYPES}) diff --git a/mandate/app/routers/mandates.py b/mandate/app/routers/mandates.py index db89fa54..439ea091 100644 --- a/mandate/app/routers/mandates.py +++ b/mandate/app/routers/mandates.py @@ -431,6 +431,7 @@ async def record_spend( # already consumed. conditions=mandate.conditions, context=body.context, + constraints=mandate.constraints, ) # Persist state changes (accepted spends and/or a status flip) diff --git a/mandate/app/schemas.py b/mandate/app/schemas.py index 626e18bb..c31db964 100644 --- a/mandate/app/schemas.py +++ b/mandate/app/schemas.py @@ -21,6 +21,10 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator +# Imported at runtime (not TYPE_CHECKING) for the same reason as datetime +# above: Pydantic resolves the annotation when the model is built. +from mandate.app.constraints import Constraint # noqa: TC001 + # ── Enums ───────────────────────────────────────────────────────────────── @@ -117,7 +121,7 @@ class MandateDocument(BaseModel): model_config = ConfigDict(extra="forbid") mandate_id: str = Field(description="Human-readable ID: mnd_<8-char-hex>") - schema_version: str = "1.1" + schema_version: str = "1.2" status: MandateStatus = MandateStatus.active issuer: MandateIssuer @@ -138,6 +142,17 @@ class MandateDocument(BaseModel): spend_limit: SpendLimit | None = Field( None, description="Monetary authority — part of the signed grant" ) + constraints: list[Constraint] = Field( + default_factory=list, + description=( + "Namespaced bounds on the grant beyond the spend ceiling, part of " + "the signed payload from schema 1.2. The ceiling itself stays on " + "`spend_limit`: one bound with two encodings is how the two " + "disagree later. An unrecognized type is rejected at parse rather " + "than skipped, because a bound this version cannot read may be the " + "one that restricts the grant." + ), + ) valid_from: datetime valid_until: datetime | None = None diff --git a/mandate/app/signing.py b/mandate/app/signing.py index ae7299b0..3a69c841 100644 --- a/mandate/app/signing.py +++ b/mandate/app/signing.py @@ -64,14 +64,25 @@ # canonical bytes would differ from what was signed. _POST_1_0_FIELDS = ("spend_limit", "spent_cents", "exceedance") +# Same rule one version on. `constraints` arrived at 1.2, so a 1.0 or 1.1 +# document re-serialized through the current model would gain an empty list +# it was never signed over, and every existing signature would stop +# verifying. Strip it for anything older than 1.2. +# +# This is the cost of putting a field inside the signed payload, and it is +# why the version gate exists rather than being a formality: the alternative +# is silently invalidating every mandate ever issued. +_POST_1_1_FIELDS = ("constraints",) + def _build_signable_payload(mandate: MandateDocument) -> bytes: """Extract the signable fields and canonicalize with RFC 8785. Always excluded: signatures (would be circular), updated_at (mutable metadata). For schema_version >= 1.1, runtime lifecycle state is also - excluded — see _RUNTIME_STATE_FIELDS. For 1.0 documents, fields that - postdate 1.0 are stripped so old signatures still verify byte-for-byte. + excluded — see _RUNTIME_STATE_FIELDS. Fields that postdate a document's + own version are stripped so old signatures still verify byte-for-byte: + _POST_1_0_FIELDS for 1.0, _POST_1_1_FIELDS for anything below 1.2. schema_version itself is inside the signed payload, so a verifier always knows which rule applied. """ @@ -84,6 +95,9 @@ def _build_signable_payload(mandate: MandateDocument) -> bytes: else: for field in _RUNTIME_STATE_FIELDS: d.pop(field, None) + if mandate.schema_version in ("1.0", "1.1"): + for field in _POST_1_1_FIELDS: + d.pop(field, None) # Convert datetime objects to ISO strings if model_dump left them as objects return rfc8785.dumps(d) diff --git a/mandate/app/spend.py b/mandate/app/spend.py index cf0cb2b9..bcbbb487 100644 --- a/mandate/app/spend.py +++ b/mandate/app/spend.py @@ -20,6 +20,7 @@ from typing import Any from common.policy.eval import evaluate_when +from mandate.app.constraints import Constraint, unevaluable from mandate.app.schemas import MandateStatus, SpendLimit # deny_reason vocabulary — flat strings, allowlisted in the audit sanitizer @@ -27,6 +28,7 @@ DENY_CURRENCY_MISMATCH = "currency_mismatch" DENY_LIMIT_EXCEEDED = "spend_limit_exceeded" DENY_CONDITIONS_UNMET = "mandate_conditions_unmet" +DENY_CONSTRAINT_UNEVALUABLE = "mandate_constraint_unevaluable" @dataclass(frozen=True) @@ -55,6 +57,7 @@ def evaluate_spend( settlement: bool, conditions: dict[str, Any] | None = None, context: dict[str, Any] | None = None, + constraints: list[Constraint] | None = None, ) -> SpendOutcome: """Evaluate one spend attempt. Pure — no side effects. @@ -84,6 +87,22 @@ def evaluate_spend( deny_reason=DENY_MANDATE_INACTIVE, ) + # Namespaced constraints. A type can be registered before its evaluator + # exists, so the document parses and an operator can see what the grant + # carries; but spending against a bound nobody checked would assert the + # grant permitted it. Denied, for the same reason an unknown type is + # rejected at parse. + unchecked = unevaluable(constraints or []) + if unchecked: + return SpendOutcome( + accepted=False, + exceeded=False, + audit_decision="deny", + new_spent_cents=spent_cents, + new_status=status, + deny_reason=DENY_CONSTRAINT_UNEVALUABLE, + ) + # Conditions. `evaluate_when` returns one result per condition in # declaration order and an empty list when there is nothing to check. results = [asdict(r) for r in evaluate_when(conditions, context or {})] diff --git a/mandate/tests/test_constraint_namespace.py b/mandate/tests/test_constraint_namespace.py new file mode 100644 index 00000000..598e099e --- /dev/null +++ b/mandate/tests/test_constraint_namespace.py @@ -0,0 +1,200 @@ +"""Namespaced constraint types, and the signature compatibility they risk. + +A mandate could express exactly one bound, `spend_limit`, as a bare field +with no type discriminator. Adding a second kind was not a one-line change, +because there was nothing to dispatch on. Constraints now carry a namespaced +`type`, so types from more than one vocabulary can coexist. + +The dangerous half of this change is not the vocabulary, it is that +`constraints` sits inside the signed payload. A field added naively would +give every re-serialized older document a value it was never signed over, +and every signature ever issued would stop verifying. The first test below +is the one that would have caught that. +""" + +import asyncio +from datetime import UTC, datetime, timedelta + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from pydantic import ValidationError + +from common.config.settings import settings +from mandate.app.constraints import ( + REGISTERED_TYPES, + TYPE_SPEND_CEILING, + Constraint, + is_namespaced, + unevaluable, +) +from mandate.app.schemas import ( + MandateDocument, + MandateIssuer, + MandateStatus, + MandateSubject, + SpendLimit, +) +from mandate.app.signing import _build_signable_payload, sign_mandate, verify_signature +from mandate.app.spend import DENY_CONSTRAINT_UNEVALUABLE, evaluate_spend + + +@pytest.fixture +def fresh_local_key(monkeypatch): + priv = ec.generate_private_key(ec.SECP256R1()) + pem = priv.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + monkeypatch.setattr(settings, "forensic_signing_key_pem", pem, raising=False) + monkeypatch.setattr(settings, "forensic_signing_key_id", "", raising=False) + return pem + + +def _doc(**overrides) -> MandateDocument: + now = datetime.now(UTC) + base = { + "mandate_id": "mnd_deadbeef", + "status": MandateStatus.active, + "issuer": MandateIssuer(org_id="org_test", user_id="user_test"), + "subject": MandateSubject(agent_id="agt_test", org_id="org_test"), + "scope": ["read:audit"], + "spend_limit": SpendLimit(limit_cents=10_000), + "valid_from": now, + "valid_until": now + timedelta(days=1), + "signatures": [], + "created_at": now, + "updated_at": now, + } + base.update(overrides) + return MandateDocument(**base) + + +# --- signature compatibility across the version bump --- + + +@pytest.mark.parametrize("version", ["1.0", "1.1"]) +def test_older_documents_keep_their_signable_bytes(version): + """The regression this change could have caused, pinned. + + `constraints` postdates both versions. A document at either must + canonicalize exactly as it did before the field existed, or every + signature ever issued at that version breaks. + """ + payload = _build_signable_payload(_doc(schema_version=version)) + assert b"constraints" not in payload + + +def test_current_version_does_sign_over_constraints(): + """The other direction: a 1.2 grant's bounds must be covered. + + Without this the namespace would be decorative, since an attacker could + add or drop a constraint without disturbing the signature. + """ + payload = _build_signable_payload(_doc(schema_version="1.2")) + assert b"constraints" in payload + + +def test_older_signature_still_verifies_after_the_field_was_added(fresh_local_key): + """End to end, not just the bytes: sign as 1.1, verify as 1.1.""" + mandate = _doc(schema_version="1.1") + mandate.signatures = [asyncio.run(sign_mandate(mandate))] + assert asyncio.run(verify_signature(mandate, mandate.signatures[0])) is True + + +def test_constraints_are_covered_by_the_signature(fresh_local_key): + """Adding a constraint after signing must break verification. + + Built with `model_construct` to skip validation, which is the faithful + shape of the attack: someone editing the stored document does not go + through our validator. It also means the signature, not the parser, is + what has to catch this. + """ + mandate = _doc(schema_version="1.2") + mandate.signatures = [asyncio.run(sign_mandate(mandate))] + tampered = mandate.model_copy( + update={"constraints": [Constraint.model_construct(type="test.reserved", params={})]} + ) + assert asyncio.run(verify_signature(tampered, mandate.signatures[0])) is not True + + +# --- the namespace itself --- + + +def test_namespace_requires_two_segments(): + """A bare noun is not a namespace: `ceiling` from two vocabularies + collides, `spend.ceiling` and `budget.ceiling` do not.""" + assert is_namespaced("spend.ceiling") is True + assert is_namespaced("mastercard.payment.budget") is True + assert is_namespaced("ceiling") is False + assert is_namespaced("Spend.Ceiling") is False + assert is_namespaced("spend..ceiling") is False + + +def test_unknown_type_is_rejected_not_skipped(): + """The whole point. A bound this version cannot read may be the one + restricting the grant, so it must not parse into silence.""" + with pytest.raises(ValidationError) as exc: + Constraint(type="someone.else.budget", params={"max": 5}) + assert "someone.else.budget" in str(exc.value) + + +def test_malformed_type_is_rejected(): + with pytest.raises(ValidationError): + Constraint(type="not_namespaced", params={}) + + +def test_spend_ceiling_is_registered_but_not_allowed_inline(): + """The vocabulary names the bound the document already enforces, while + `spend_limit` stays its only encoding. Two ways to say one thing is how + the two end up disagreeing.""" + assert TYPE_SPEND_CEILING in REGISTERED_TYPES + with pytest.raises(ValidationError) as exc: + Constraint(type=TYPE_SPEND_CEILING, params={"limit_cents": 500}) + assert "own field" in str(exc.value) + + +def test_mandate_rejects_an_unknown_constraint_at_parse(): + with pytest.raises(ValidationError): + _doc(constraints=[{"type": "unregistered.thing", "params": {}}]) + + +def test_mandate_without_constraints_is_unchanged(): + doc = _doc() + assert doc.constraints == [] + assert doc.schema_version == "1.2" + + +# --- registered but not yet evaluable --- + + +def test_unevaluable_reports_registered_types_with_no_evaluator(): + """A type can be named before it can be checked. The document should + still parse so an operator can see what the grant holds.""" + assert unevaluable([]) == [] + + +def test_spending_denies_on_a_bound_nobody_evaluated(monkeypatch): + """Naming a type is a promise to evaluate it. Until that promise is + kept, spending against the grant would assert a bound was satisfied + when nothing checked it. + """ + import mandate.app.constraints as c + + monkeypatch.setattr(c, "REGISTERED_TYPES", frozenset({"test.reserved"})) + monkeypatch.setattr(c, "_FIELD_BACKED_TYPES", frozenset()) + monkeypatch.setattr(c, "EVALUABLE_TYPES", frozenset()) + + outcome = evaluate_spend( + status=MandateStatus.active, + spend_limit=SpendLimit(limit_cents=10_000), + spent_cents=0, + amount_cents=100, + currency="USD", + settlement=False, + constraints=[Constraint.model_construct(type="test.reserved", params={})], + ) + assert outcome.accepted is False + assert outcome.deny_reason == DENY_CONSTRAINT_UNEVALUABLE + assert outcome.new_spent_cents == 0