From aec519200fb1da7d0065b96e246d79537cdb2918 Mon Sep 17 00:00:00 2001 From: arpan Date: Sun, 13 Sep 2026 06:38:25 +0530 Subject: [PATCH 1/3] The budget in the document: parse, contain, hash. Nothing counts yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC-v0.9 §2. A grant carries budgets: a metric, a limit and a window. No ledger, no counter, no reservation touched: item 4 builds the store side and item 5 spends it. No guarantee id either; §8 says why. The window axis is the whole risk, and it reads backwards. Over the same limit a SHORTER window is a HIGHER rate and therefore more authority: a parent of 100,000 per rolling day is widened by a child of 100,000 per rolling hour, which is 2,400,000 a day. A draft of §2.6 compared <= on the window and would have accepted that child at 24x while rejecting the child of 100,000 per week, which is one seventh the rate. T401 and T402 are written in both directions with the arithmetic on the page. Containment is existential, not positional. §2.2 makes budgets a list because two budgets on one metric over two windows is the first thing an operator asks for, and that makes "the child's budget" ambiguous. The rule is: for every parent budget there must exist a child budget on the same metric with limit <= and window >=. One child budget may discharge several parent budgets, which T402a's second row exercises: a 30-day cap of 50,000 implies a 24-hour cap of 50,000. A metric value is a non-negative int that is not a bool. PlainValue admits bool and isinstance(True, int) is True, so amount: false would be a "non-negative integer" worth zero, which is §2.3's own absence-as-zero sentence wearing a costume. authority.py and verify/scenarios.py already guard the trap; the budget loader uses the same predicate rather than a third spelling. Negative values are refused because they would reduce the rolling sum and void §2.6's monotonicity proof, not merely because they are odd. Two couplings item 1 paid for once and this item paid for again, both predicted by §8.0. grant_to_json carries budgets or a delegation reads back unbudgeted and §5.6 refuses it authority_escalation forever; and DIMENSIONS grows to eight, so G9's _narrowed and _widen carry the field and the shipped example budgets or G9 raises VerifyInternalError. The window renders as integer seconds in the canonical form, exactly as _canonical_envelope renders max_ttl: a timedelta is not a PlainValue and cannot go through canonical_bytes. Signed-off-by: arpan --- examples/authority/payments.yaml | 7 + src/ctrlrun/authority.py | 215 ++++++++++++++++++++++++++++++- src/ctrlrun/policy.py | 4 + src/ctrlrun/verify/scenarios.py | 14 ++ tests/test_budget_document.py | 214 ++++++++++++++++++++++++++++++ tests/test_cli_store.py | 9 ++ tests/test_verify_authority.py | 2 + 7 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 tests/test_budget_document.py diff --git a/examples/authority/payments.yaml b/examples/authority/payments.yaml index 0b253c07..2d331200 100644 --- a/examples/authority/payments.yaml +++ b/examples/authority/payments.yaml @@ -40,6 +40,13 @@ authority: # unchanged; naming one here binds this authority, and every delegation beneath it, to # the refund runs it was granted for. `ctrlrun verify` grades G24 against this grant. tasks: ["refund-run:*"] + # SPEC-v0.9 §2. How much, over which metric, in how long. `amount` names an action + # argument and is summed; the limit is in minor units like every band above, so this is + # €1,000.00 of refunds in any rolling 24 hours, across this grant and every delegation + # beneath it (§2.7). A grant that omits `budgets:` budgets nothing, which is what the two + # grants below do and why they are unchanged. `ctrlrun verify` grades G22 against this. + budgets: + - { metric: amount, limit: 100000, window: PT24H } # €1,000.00 a day # 2. A service account that reconciles, and may never move money. It holds no `delegable` # key at all, which is the default and means it cannot create a delegation. diff --git a/src/ctrlrun/authority.py b/src/ctrlrun/authority.py index 2fe4f73e..219bc335 100644 --- a/src/ctrlrun/authority.py +++ b/src/ctrlrun/authority.py @@ -32,7 +32,7 @@ from dataclasses import dataclass, field, replace from datetime import datetime, timedelta from types import MappingProxyType -from typing import Any, Final, Literal +from typing import Any, Final, Literal, cast from .action import Action, PlainValue, Principal from .errors import AuthorityEscalation, IdentityError, InvalidArgument, PolicyError @@ -153,6 +153,7 @@ # `_narrowed` carrying the field raises `VerifyInternalError`; adding the containment row # without growing it reports "6 of 6" and exercises neither, which is the silent failure. "tasks", + "budgets", ) #: §5.4 — how a child operand must compare with its parent's, per operator. `neq` is equality @@ -189,6 +190,9 @@ # the action on every task, so `policy.py` refuses it in a `v6` document rather than # ignoring it (§10.1). "tasks", + # SPEC-v0.9 §2.2, the other `v7` key, gated for the same reason: an older reader would + # ignore the limit and spend without bound. + "budgets", } ) _SUBJECT_KEYS: Final = frozenset({"agent", "user"}) @@ -348,6 +352,53 @@ def matches(self, principal: Principal) -> bool: return True +def _is_int(value: object) -> bool: + """A real integer: `bool` is an `int` in Python and is not one here (SPEC-v0.9 §2.3). + + `verify/scenarios.py` and `_parse_max_delegation_depth` already guard this trap; the budget + loader uses the same predicate rather than a third spelling of it. `amount: false` would + otherwise be a "non-negative integer" whose value is zero, which is §2.3's own sentence about + absence-as-zero wearing a bool's clothes. + """ + return isinstance(value, int) and not isinstance(value, bool) + + +@dataclass(frozen=True) +class Budget: + """How much, over what, in how long (SPEC-v0.9 §2.2). + + A metric names where the number comes from, a limit is what the sum may reach, and a window + is what the sum is taken over. The kernel **does not know what any metric means**: there is no + branch on a metric name anywhere, no ranking of two metrics, and no default limit for one the + kernel thinks it recognises (§2.3, and §12 carries it as a do-not-build line). + + Validated here as well as in the loader, on `Grant.__post_init__`'s rule: `Control.delegate` + takes a `Grant` built in Python, and §2.6's containment relation is undefined on a budget + whose window is negative or whose limit is a string. + """ + + metric: str + limit: int + window: timedelta + + def __post_init__(self) -> None: + if not isinstance(self.metric, str) or not self.metric.strip(): + raise InvalidArgument( + f"a budget metric must be a non-empty string, got {self.metric!r}" + ) + if not _is_int(self.limit) or self.limit < 0: + # §2.3: a float would drift, a bool is an int wearing a costume, and a string that + # looks like a number is a decimal in disguise. None of the three is coerced. + raise InvalidArgument( + f"budget {self.metric!r}: 'limit' must be a non-negative integer, got " + f"{self.limit!r}. Money is budgeted in minor units (SPEC-v0.9 §2.3)" + ) + if not isinstance(self.window, timedelta) or self.window <= timedelta(0): + raise InvalidArgument( + f"budget {self.metric!r}: 'window' must be a positive duration, got {self.window!r}" + ) + + @dataclass(frozen=True) class Grant: """One permission: this subject may propose these actions, under these limits (§4.2). @@ -371,6 +422,11 @@ class Grant: #: is `v0.3 §4.2`'s rule for `resources` and the reason every existing grant upgrades #: untouched. A child that omits it under a parent that names it is rejected (§6.2). tasks: tuple[str, ...] | None = None + #: SPEC-v0.9 §2.2 — how much this grant may spend, over which metric, in how long. A list and + #: not a mapping: two budgets on one metric over two windows is the first thing an operator + #: asks for, and a mapping keyed by metric cannot express it. `None` budgets nothing, which is + #: every grant written before v0.9 and why they all upgrade untouched. + budgets: tuple[Budget, ...] | None = None def __post_init__(self) -> None: # An empty id is legal only on the `Control.delegate` path and only until the call @@ -415,6 +471,19 @@ def __post_init__(self) -> None: ) for pattern in self.tasks: validate_pattern(pattern, separator=TASK_SEPARATOR, where=f"grant {self.id!r} task") + if self.budgets is not None: + object.__setattr__(self, "budgets", tuple(self.budgets)) + if not self.budgets: + raise InvalidArgument( + f"grant {self.id!r}: 'budgets' must be a non-empty list, or absent — " + "an absent 'budgets' is what budgets nothing (SPEC-v0.9 §2.2)" + ) + for budget in self.budgets: + if not isinstance(budget, Budget): + raise InvalidArgument( + f"grant {self.id!r}: a budget must be an authority.Budget, " + f"got {_type_name(budget)}" + ) for key, condition in self.constraints.items(): if not isinstance(condition, Condition): raise InvalidArgument( @@ -601,6 +670,23 @@ def grant_to_json(grant: Grant) -> str: # §5.6's re-check would then refuse it `authority_escalation` on `tasks` forever. The # round trip is the containment, not a convenience. "tasks": None if grant.tasks is None else list(grant.tasks), + # SPEC-v0.9 §2.7, and item 1's lesson repeated: a delegation is stored as this JSON and + # read back on **every** evaluation (`v0.3 §5.6`), so a dimension missing here reads back + # as `None`. The child would be unbudgeted while its parent carried a limit, and §5.6's + # re-check would refuse it `authority_escalation` on `budgets` forever. The round trip is + # the containment, not a convenience. + "budgets": ( + None + if grant.budgets is None + else [ + { + "metric": budget.metric, + "limit": budget.limit, + "window": int(budget.window.total_seconds()), + } + for budget in grant.budgets + ] + ), } return json.dumps(document, sort_keys=True, separators=(",", ":"), ensure_ascii=False) @@ -645,11 +731,43 @@ def grant_from_json(text: str, *, delegation_id: str) -> Grant: expires_at=None if expires_at is None else datetime.fromisoformat(str(expires_at)), delegable=bool(document.get("delegable", False)), tasks=_optional_tuple(document.get("tasks"), delegation_id), + budgets=_budgets_from_json(document.get("budgets"), delegation_id), ) except (InvalidArgument, PolicyError, TypeError, ValueError) as exc: raise _UnreadableError(delegation_id, str(exc)) from exc +def _budgets_from_json(value: object, delegation_id: str) -> tuple[Budget, ...] | None: + """Read a delegation's budgets back, validating exactly what the loader validated. + + `grant_from_json`'s rule for every other dimension: a row read back is re-validated, because + a store is a place an attacker with write access reaches and `v0.3 §5.2` requires reading a + grant back to check what loading one from YAML checks. + """ + if value is None: + return None + if not isinstance(value, list) or not value: + raise _UnreadableError(delegation_id, "grant_json 'budgets' is not a non-empty list") + parsed: list[Budget] = [] + for entry in value: + if not isinstance(entry, Mapping): + raise _UnreadableError(delegation_id, "grant_json has a budget that is not an object") + try: + # `cast` and not a check: `Budget.__post_init__` validates all three, and a second + # copy of that grammar here is the drift §2.2 forbids. The types are the store's + # word, which is exactly what re-validating exists to distrust. + parsed.append( + Budget( + metric=cast("str", entry.get("metric")), + limit=cast("int", entry.get("limit")), + window=timedelta(seconds=cast("float", entry.get("window", -1))), + ) + ) + except (InvalidArgument, TypeError, ValueError) as exc: + raise _UnreadableError(delegation_id, f"grant_json budget: {exc}") from exc + return tuple(parsed) + + def _optional_tuple(value: object, delegation_id: str) -> tuple[str, ...] | None: """A list of strings, or `None`. Takes the row's id because it raises past `grant_from_json`'s `except` clause, and evidence that cannot name the corrupted row is @@ -723,9 +841,49 @@ def contained_dimension(parent: Grant, child: Grant) -> str | None: or not _patterns_contained(parent.tasks, child.tasks, separator=TASK_SEPARATOR) ): return "tasks" + if not _budgets_contained(parent.budgets, child.budgets): + return "budgets" return None +def _budgets_contained(parent: tuple[Budget, ...] | None, child: tuple[Budget, ...] | None) -> bool: + """§2.6.1: for every parent budget there must exist a child budget that discharges it. + + **The window axis reads backwards on first encounter, and the backwards reading is the + dangerous one**, so it is spelled out rather than left to the comparison. Over the same limit + a *shorter* window is a *higher rate*, and therefore more authority: a parent of 100,000 per + rolling day is widened, not narrowed, by a child of 100,000 per rolling hour, which is + 2,400,000 a day. A draft of this rule compared `<=` on the window and would have accepted that + child at 24x while rejecting the child of 100,000 per week, which is one seventh the rate. + + The proof, given §2.3's non-negative values: take any interval of length `window_p`; it sits + inside some interval of length `window_c`, whose sum is at most `limit_c`, which is at most + `limit_p`. So every spend pattern the child permits, the parent permits. Non-negativity is + what makes the sum monotonic over nested intervals, and without it none of this holds. + + **Existential, not positional**: one child budget may discharge several parent budgets, and a + child may add budgets on metrics the parent does not budget. Matching by metric alone is + undecidable the moment a parent carries two budgets on `amount`, which is the case §2.2 exists + for; matching by `(metric, window)` would make the window axis vacuous. + """ + if parent is None: + # A parent that budgets nothing constrains nothing here, and a child may add its own. + return True + for outer in parent: + if not any( + inner.metric == outer.metric + and inner.limit <= outer.limit + and inner.window >= outer.window + for inner in (child or ()) + ): + # `v0.3 §5.4`: omission never means unlimited. A child that drops the parent's budget + # is rejected rather than inheriting it, for that section's reason: a child that + # silently inherited would look, in the file and in the receipt, like one authorized + # for what it says. + return False + return True + + def _subject_contained(parent: Subject, child: Subject) -> bool: """§5.4's `subject` row: two things that look like naming the grantee are widening. @@ -1529,6 +1687,25 @@ def _canonical_grant(grant: Grant) -> PlainValue: # SPEC-v0.9 §6.7 — a dimension outside the hash is one an operator widens without the # hash moving, which is `SPEC-v0.8 §5.2`'s reason for `max_ttl` in this same field list. "tasks": None if grant.tasks is None else list(grant.tasks), + # SPEC-v0.9 §2.8, the same reason and the sharper case: an operator widens a budget from + # 10,000 to 10,000,000, the hash does not move, and every approval bound to it by + # `v0.6 §7.1` stays valid against a document that now permits a thousand times more. + # Rendered in document order, which §2.2 makes meaningful. + "budgets": ( + None + if grant.budgets is None + else [ + { + "metric": budget.metric, + "limit": budget.limit, + # Integer seconds, exactly as `_canonical_envelope` renders `max_ttl`: a + # `timedelta` is not a `PlainValue` (`action.py:19`) and so cannot go through + # `canonical_bytes`, and seconds is the spelling this file already uses. + "window": int(budget.window.total_seconds()), + } + for budget in grant.budgets + ] + ), } @@ -1776,6 +1953,7 @@ def _parse_grant(entry: object, where: str, *, unassigned: bool = False) -> Gran expires_at=_parse_expires_at(entry, where), delegable=delegable, tasks=(_parse_patterns(entry["tasks"], "tasks", where) if "tasks" in entry else None), + budgets=(_parse_budgets(entry["budgets"], where) if "budgets" in entry else None), ) except InvalidArgument as exc: # The model refuses what the loader refuses (§4.8), so the loader delegates the @@ -1783,6 +1961,41 @@ def _parse_grant(entry: object, where: str, *, unassigned: bool = False) -> Gran raise PolicyError(f"{where}: {exc}") from exc +_BUDGET_KEYS: Final = frozenset({"metric", "limit", "window"}) + + +def _parse_budgets(value: object, where: str) -> tuple[Budget, ...]: + """SPEC-v0.9 §2.2. A list of `{metric, limit, window}`, and nothing else. + + The grammar is closed for `v0.1 §3.1`'s reason: a key this loader silently dropped would be a + limit an operator wrote and nothing enforced. Errors carry the index, because a document with + two budgets on one metric is the case §2.2 exists for and "one of them is wrong" is not an + error message somebody can act on. + """ + if not isinstance(value, list) or not value: + raise PolicyError( + f"{where}: 'budgets' must be a non-empty list of " + "{metric, limit, window} mappings, or absent" + ) + parsed: list[Budget] = [] + for index, entry in enumerate(value): + spot = f"{where}: budgets[{index}]" + if not isinstance(entry, Mapping): + raise PolicyError(f"{spot}: must be a mapping, got {_type_name(entry)}") + _reject_unknown_keys(entry, _BUDGET_KEYS, spot) + for key in sorted(_BUDGET_KEYS): + if key not in entry: + raise PolicyError(f"{spot}: {key!r} is required") + window = _parse_duration(entry["window"], f"{spot}: window") + try: + parsed.append(Budget(metric=entry["metric"], limit=entry["limit"], window=window)) + except InvalidArgument as exc: + # §2.2 — the model refuses what the loader refuses, so the loader delegates the + # grammar to it rather than keeping a second copy that can drift. + raise PolicyError(f"{spot}: {exc}") from exc + return tuple(parsed) + + def _parse_subject(value: object, where: str) -> Subject: if not isinstance(value, Mapping): raise PolicyError( diff --git a/src/ctrlrun/policy.py b/src/ctrlrun/policy.py index 95743afc..b1a6a051 100644 --- a/src/ctrlrun/policy.py +++ b/src/ctrlrun/policy.py @@ -1166,6 +1166,10 @@ def require_v4(document: Mapping[Any, Any], schema: str, source: str) -> None: "an older reader would ignore the binding and authorise the grant on every task, which " "is the whole of what the key restricts" ), + "budgets": ( + "an older reader would ignore the limit and let the grant spend without bound, which is " + "the whole of what the key restricts" + ), } diff --git a/src/ctrlrun/verify/scenarios.py b/src/ctrlrun/verify/scenarios.py index 0e1e186f..b9749d3b 100644 --- a/src/ctrlrun/verify/scenarios.py +++ b/src/ctrlrun/verify/scenarios.py @@ -4278,6 +4278,9 @@ def _narrow(parent: Grant, selection: _Selection) -> tuple[Grant, Principal, dic # SPEC-v0.9 §8.0 — carried, or `_narrowed`'s own guard below raises # `VerifyInternalError` on any document that names tasks, before a single widening runs. tasks=None if parent.tasks is None else parent.tasks, + # SPEC-v0.9 §8.0, the same coupling `tasks` has: carried, or `_narrowed`'s own guard + # raises `VerifyInternalError` on any document that budgets, before a widening runs. + budgets=None if parent.budgets is None else parent.budgets, ) offending = contained_dimension(parent, child) if offending is not None: @@ -4325,6 +4328,15 @@ def _widen(parent: Grant, narrowed: Grant, dimension: str) -> Grant | None: if parent.tasks is None: return None return replace(narrowed, tasks=(DEEP_WILDCARD,)) + if dimension == "budgets": + # SPEC-v0.9 §2.6. Widened on the **limit**, which is the axis that reads forwards; the + # window axis reads backwards and a widening there would be a *shorter* window, which is + # the case a draft of the rule got wrong. One axis is enough to exercise the dimension, + # and the loud one is the one an operator would recognise in a counterexample. + if not parent.budgets: + return None + widened = tuple(replace(budget, limit=budget.limit + 1) for budget in parent.budgets) + return replace(narrowed, budgets=widened) raise VerifyInternalError(f"G9: unknown containment dimension {dimension!r}") @@ -4351,4 +4363,6 @@ def _omit(narrowed: Grant, parent: Grant, dimension: str) -> Grant | None: return None if parent.expires_at is None else replace(narrowed, expires_at=None) if dimension == "tasks": return None if parent.tasks is None else replace(narrowed, tasks=None) + if dimension == "budgets": + return None if parent.budgets is None else replace(narrowed, budgets=None) raise VerifyInternalError(f"G9: unknown containment dimension {dimension!r}") diff --git a/tests/test_budget_document.py b/tests/test_budget_document.py new file mode 100644 index 00000000..948a9ed6 --- /dev/null +++ b/tests/test_budget_document.py @@ -0,0 +1,214 @@ +"""T399 to T407: the budget in the document (SPEC-v0.9 §2). + +Parse, validate, canonicalise into the policy hash, and contain. **Nothing counts anything in +this item**: no ledger, no reservation, no execution. Item 4 builds the counter and item 5 +spends it. + +The window axis is the one to get right. A draft of §2.6 had it inverted, and the rule as written +would have accepted a child spending 24x its parent's authority while rejecting the child that was +genuinely narrower. T401 and T402 are written in both directions for that reason. +""" + +from __future__ import annotations + +from datetime import timedelta + +import pytest + +from ctrlrun.authority import ( + Authority, + Budget, + Grant, + Subject, + canonical_grants, + contained_dimension, +) +from ctrlrun.errors import InvalidArgument, PolicyError +from ctrlrun.policy import Policy + +pytestmark = pytest.mark.authority + +DAY = timedelta(hours=24) +HOUR = timedelta(hours=1) +WEEK = timedelta(days=7) +MONTH = timedelta(days=30) + +DOCUMENT = """ +schema: ctrlrun.policy/v7 +environment: prod +actions: + payments.refund: + decision: allow +authority: + grants: + - id: payments-agent + subject: {agent: "payer"} + actions: ["payments.*"] + budgets: + - metric: amount + limit: 100000 + window: PT24H +""" + + +def _grant(*budgets: Budget, **overrides) -> Grant: + fields = dict( + id=overrides.pop("id", "g"), + subject=Subject(agent="payer", user="ada"), + actions=("payments.refund",), + budgets=budgets or None, + ) + fields.update(overrides) + return Grant(**fields) + + +def test_T399_a_well_formed_budget_loads_and_renders() -> None: + authority = Authority.from_yaml(DOCUMENT, source="") + grant = authority.grants["payments-agent"] + assert grant.budgets is not None + budget = grant.budgets[0] + assert (budget.metric, budget.limit, budget.window) == ("amount", 100000, DAY) + + +def test_T400_a_child_limit_above_its_parents_is_rejected() -> None: + parent = _grant(Budget("amount", 100000, DAY)) + child = _grant(Budget("amount", 100001, DAY)) + assert contained_dimension(parent, child) == "budgets" + + +def test_T401_a_child_window_SHORTER_than_its_parents_is_rejected() -> None: + """§2.6's axis that reads backwards, with the 24x case on the page beside the assertion. + + Parent: 100,000 per rolling day. Child: 100,000 per rolling **hour**, which is 2,400,000 a + day. A draft of the rule accepted exactly this. + """ + parent = _grant(Budget("amount", 100000, DAY)) + child = _grant(Budget("amount", 100000, HOUR)) + assert contained_dimension(parent, child) == "budgets" + + +def test_T402_a_child_window_LONGER_than_its_parents_is_accepted() -> None: + """The same limit over a week is one seventh the rate: narrower, and contained.""" + parent = _grant(Budget("amount", 100000, DAY)) + child = _grant(Budget("amount", 100000, WEEK)) + assert contained_dimension(parent, child) is None + + +def test_T402a_the_pairing_rule_over_the_two_budget_parent_SS2_2_exists_for() -> None: + """§2.6.1's four worked rows. A mapping keyed by metric could not express this parent.""" + parent = _grant(Budget("amount", 100000, DAY), Budget("amount", 500000, MONTH)) + # Row 1: each parent budget discharged by its own child budget. + both = _grant(Budget("amount", 50000, DAY), Budget("amount", 100000, MONTH)) + assert contained_dimension(parent, both) is None + # Row 2: ONE child budget discharges both, being under each limit and at least as long as + # each window. A 30-day cap of 50,000 implies a 24-hour cap of 50,000. + one = _grant(Budget("amount", 50000, MONTH)) + assert contained_dimension(parent, one) is None + # Row 3: the monthly parent budget is discharged by nothing. 50,000/day x 30 is 1,500,000. + daily_only = _grant(Budget("amount", 50000, DAY)) + assert contained_dimension(parent, daily_only) == "budgets" + # Row 4: shorter window, higher rate. + hourly = _grant(Budget("amount", 100000, HOUR)) + assert contained_dimension(parent, hourly) == "budgets" + + +def test_T403_a_child_omitting_a_budget_its_parent_carries_is_rejected() -> None: + """`v0.3 §5.4`, unchanged and with no exception for budgets.""" + parent = _grant(Budget("amount", 100000, DAY)) + assert contained_dimension(parent, _grant()) == "budgets" + + +def test_T404_a_child_budget_on_a_metric_the_parent_does_not_budget_is_an_addition() -> None: + parent = _grant(Budget("amount", 100000, DAY)) + child = _grant(Budget("amount", 50000, DAY), Budget("count", 10, DAY)) + assert contained_dimension(parent, child) is None + + +def test_T405_a_budget_changed_in_the_document_moves_the_policy_hash() -> None: + one = canonical_grants(Authority.from_yaml(DOCUMENT, source="")) + widened = canonical_grants( + Authority.from_yaml(DOCUMENT.replace("limit: 100000", "limit: 10000000"), source="") + ) + assert one != widened, "a budget outside the canonical render is one outside the hash" + + +def test_T405a_a_budget_in_a_break_glass_envelope_moves_the_hash_too() -> None: + """`canonical_grants` walks envelopes through `_canonical_grant`; §2.8's reason for both.""" + envelope = """ +schema: ctrlrun.policy/v7 +environment: prod +actions: + payments.refund: + decision: allow +authority: + grants: + - id: everyday + subject: {agent: "ops"} + actions: ["payments.read"] + break_glass: + incident: + subject: {agent: "oncall-*"} + actions: ["payments.*"] + max_ttl: PT4H + budgets: + - {metric: amount, limit: 50000, window: PT24H} +""" + one = canonical_grants(Authority.from_yaml(envelope, source="")) + two = canonical_grants( + Authority.from_yaml(envelope.replace("limit: 50000", "limit: 5000000"), source="") + ) + assert one != two + + +def test_T406_two_budgets_on_one_metric_load_in_document_order() -> None: + text = DOCUMENT.replace( + " - metric: amount\n limit: 100000\n window: PT24H\n", + " - {metric: amount, limit: 100000, window: PT24H}\n" + " - {metric: amount, limit: 500000, window: P30D}\n", + ) + grant = Authority.from_yaml(text, source="").grants["payments-agent"] + assert grant.budgets is not None + assert [b.window for b in grant.budgets] == [DAY, MONTH], "list order is the document's" + + +@pytest.mark.parametrize( + ("bad", "why"), + [ + ("limit: 1.5", "a float limit"), + ('limit: "100.50"', "a decimal string limit"), + ("limit: -1", "a negative limit"), + ("limit: true", "a bool limit, which is an int in Python"), + ("window: PT0S", "a zero window"), + ("window: -PT1H", "a negative window"), + ("metric: 7", "a non-string metric"), + ], +) +def test_T407_the_loader_and_the_constructor_refuse_the_same_shapes(bad: str, why: str) -> None: + """§2.2's rule: the model refuses exactly what the loader refuses. + + The decimal **string** is the one to write first: YAML hands a quoted number back as a `str`, + so `"100.50"` is the shape an operator actually produces, and coercing it would put the drift + back through the door `v0.1 §2.3` closed. + """ + key = bad.split(":")[0] + text = DOCUMENT.replace(f"{key}: 100000" if key == "limit" else f"{key}: PT24H", bad) + if key == "metric": + text = DOCUMENT.replace("metric: amount", bad) + with pytest.raises(PolicyError): + Policy.from_yaml(text, source="") + + +@pytest.mark.parametrize( + ("limit", "window"), + [(1.5, DAY), (-1, DAY), (True, DAY), (100, timedelta(0)), (100, -HOUR)], +) +def test_T407a_the_constructor_refuses_what_the_loader_refuses(limit, window) -> None: + with pytest.raises(InvalidArgument): + Budget("amount", limit, window) + + +def test_T407b_a_budget_is_refused_in_a_v6_document() -> None: + older = DOCUMENT.replace("ctrlrun.policy/v7", "ctrlrun.policy/v6") + with pytest.raises(PolicyError) as caught: + Policy.from_yaml(older, source="") + assert "budgets" in str(caught.value) diff --git a/tests/test_cli_store.py b/tests/test_cli_store.py index d752f1a8..275701bb 100644 --- a/tests/test_cli_store.py +++ b/tests/test_cli_store.py @@ -219,6 +219,15 @@ def _delegable_child(parent) -> str: } if parent.expires_at is not None: document["expires_at"] = parent.expires_at.isoformat() + if parent.budgets is not None: + document["budgets"] = [ + { + "metric": budget.metric, + "limit": budget.limit, + "window": f"PT{int(budget.window.total_seconds())}S", + } + for budget in parent.budgets + ] if parent.tasks is not None: # SPEC-v0.9 §6.2 — one more dimension under the same structural rule this docstring # states. A helper that restated five of six would silently stop being "every dimension diff --git a/tests/test_verify_authority.py b/tests/test_verify_authority.py index a53d81af..694b02ab 100644 --- a/tests/test_verify_authority.py +++ b/tests/test_verify_authority.py @@ -60,6 +60,8 @@ delegable: true expires_at: "2027-01-01T00:00:00Z" tasks: ["refund-run:*"] + budgets: + - {metric: amount, limit: 500000, window: PT24H} """ #: Grants that name actions the policy does not list: nothing is authorized, which is a From 0349eddfa9e0158f79bf7c764ea862dc290ac73f Mon Sep 17 00:00:00 2001 From: arpan Date: Sun, 13 Sep 2026 06:39:26 +0530 Subject: [PATCH 2/3] Changelog for the budget document Signed-off-by: arpan --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b93a9a..ff896e25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,23 @@ any change to one appears here. The task reaches the authority decision and the receipt, and **never the action hash**: a field on `Action` would move every hash in existence and invalidate every stored approval. +- **Consequence budgets, in the document** (SPEC-v0.9 §2). A grant may carry `budgets:`, each a + `metric`, a `limit` and a `window`. They load, validate, render into the policy hash, and + attenuate down a delegation chain. **Nothing counts yet**: the ledger and the spending are + separate items, so this release note describes a contract and not an enforcement. + + **The window axis reads backwards, and it is worth stating plainly.** Over the same limit a + *shorter* window is a *higher rate*: a child of 100,000 per hour under a parent of 100,000 per + day is 24 times the parent's authority, and is rejected. A child of 100,000 per week is one + seventh the rate, and is accepted. Containment is existential: for every parent budget there + must exist a child budget on the same metric with `limit <=` and `window >=`, so one child + budget may discharge several of its parent's. + + A metric names an action argument, or `count`. Its value must be a **non-negative integer that + is not a `bool`**, so money is budgeted in minor units, as `examples/authority/payments.yaml` + already does for every constraint. The kernel does not know what any metric means: there is no + branch on a metric name anywhere. + - **Scope providers** (SPEC-v0.9 §5). `Control.execute(scope=...)` and `@protect(scope=...)` take a callable that answers what the calling principal's assigned scope is; **the kernel matches** this action's resource into it, with the relation a grant's `resources:` already uses. It runs From 0822d263613fc0808c6f99a8be4917dc48d58259 Mon Sep 17 00:00:00 2001 From: arpan Date: Sun, 13 Sep 2026 07:10:21 +0530 Subject: [PATCH 3/3] Answer the review: a corrupt row could take down the whole deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The independent review validated the containment relation exhaustively and could not break it: 12,871 transitivity triples with zero non-transitive, and 2,515 contained pairs against 300 simulated spend timelines each with zero soundness violations. child.window >= parent .window is right. Four blocking findings, all outside that relation. An oversized stored window raised OverflowError out of Authority .evaluate, and _candidates reads EVERY delegation row on every evaluation, so one corrupt row denied nothing and crashed everything for every principal and every action, with no event and no receipt to find it by. OverflowError was in neither except tuple. Probed before and after: an unrelated principal's unrelated action went from an uncaught OverflowError to authority_unreadable. T407d drives that end to end. And timedelta(seconds=True) is a ONE-SECOND window. _is_int exists in this item precisely because isinstance(True, int), and it guarded limit while window went through a cast. The failure grants authority, since a shorter window is a higher rate, and the loader refuses the same input, so it was a direct violation of §2.2's "the model refuses exactly what the loader refuses". Same predicate now, plus a bound, plus the closed key set the reader's docstring already claimed. Two spec contradictions the item worked around instead of reporting. §2.8 said budgets render sorted "like constraints"; the code renders document order, which is the safer choice, so §2.8 is amended with the reason rather than the code changed to lose evidence. §10 said the canonical window is the document's ISO-8601 spelling, which is not implementable: canonical_grants renders parsed grants and a Budget keeps no source text. Both amendments are in the spec now, not only in a comment misattributing the justification to §2.2. §9.3's T401a and §11's fail-closed row still stated the load-error rule §2.4.1 overturned three drafts ago. A fail-closed table naming a check the code does not make is the documented-as-prevention problem in its literal form. Both now point at the execute-time rule and name item 5 as its owner. Two testing gaps worth more than the findings that surfaced them. test_T329_every_dimension_contained_dimension_knows_has_a_case filtered through the same hardcoded six names it then subtracted, so `missing` was empty by construction and the guard written to stop a dimension escaping could not fire. Both tasks and budgets walked past it. It derives from DIMENSIONS now, and a mutation confirms it fires. And G9's budget widening moved the limit, which is the axis that reads forwards. contained_dimension returns on the first axis violated, so the limit refusal masked the window one and G9 stayed green under an inverted window comparison, which is the one defect the dimension exists to catch. Window only now. Also: a Budget with a sub-second window was legal and could not round trip, storing as 0 and reading back dead for ever. §2.3 settles `count`, which is the one metric the kernel does branch on, against §12's do-not-build line, and says an argument named count does not win. And the shipped example no longer claims verify grades G22 today. Signed-off-by: arpan --- docs/SPEC-v0.9.md | 45 +++++++++--- examples/authority/payments.yaml | 3 +- src/ctrlrun/authority.py | 50 +++++++++++++- tests/test_budget_document.py | 115 ++++++++++++++++++++++++++++++- 4 files changed, 200 insertions(+), 13 deletions(-) diff --git a/docs/SPEC-v0.9.md b/docs/SPEC-v0.9.md index a27c7915..c3124335 100644 --- a/docs/SPEC-v0.9.md +++ b/docs/SPEC-v0.9.md @@ -182,6 +182,19 @@ checked. `count` is always available and always means one per action. It is the budget an operator reaches for first and the only one whose meaning does not depend on the document. +**Which makes it the one metric the kernel does branch on, and §12's do-not-build line has to say +so rather than be contradicted by it.** The line forbids a branch that *ranks* or *classifies* +metrics: a taxonomy, a default limit for a name the kernel thinks it recognises, an opinion about +which of two is more serious. `count` is none of those. It is a second **source** for the number, +the way a metric naming an argument is a source, and item 5 resolves it in one place with no +ordering over names. + +**An action argument literally named `count` does not win**, and it is worth saying which does: the +kernel-supplied meaning takes it, because an operator writing `metric: count` means "how many", and +a document that could silently retarget it at an argument would make the one metric whose meaning +does not depend on the document depend on it. An operator who wants to sum an argument called +`count` renames the argument. + Every other metric names an **action argument**, by name, and its value is summed. `metric: amount` sums `action.arguments["amount"]`. @@ -377,8 +390,21 @@ hash does not move, and every approval bound to that hash by `v0.6 §7.1` stays document that now permits a thousand times more. `SPEC-v0.8.md` §5.2 gives this reason for `max_ttl` and it is the same defect in the same field list. -Rendered in sorted order, like `constraints`, so two documents differing only in list order hash -alike. +**Rendered in document order, not sorted, and this amends an earlier draft of this sentence.** The +draft said sorted "like `constraints`, so two documents differing only in list order hash alike", +and `constraints` really does behave that way. Budgets do not, for two reasons. §2.2 gives the list +order meaning, because the first budget to refuse is the one named in the refusal, so two documents +differing in list order differ in what an operator is told. And the hash's job is to move when the +document changes: sorting makes two different documents hash alike, which is the direction that +loses evidence rather than the direction that creates it. + +**The window renders as integer seconds, not as the document's ISO-8601 spelling**, which amends +§10's `Budget` row as originally written. That row said the document's own spelling was the +canonical one; it is not implementable, because `canonical_grants` renders parsed grants and +`Budget` keeps a `timedelta` rather than the source text. Seconds is also what +`_canonical_envelope` already renders `max_ttl` as. The consequence is stated rather than hidden: +`PT24H` and `P1D` hash alike, which is correct, since they are the same window and a hash that +distinguished them would move for a document that changed nothing. --- @@ -1472,9 +1498,12 @@ defects will be: and the test is written with the parent at `PT24H` and the child at `PT1H` so the 24x figure is on the page next to the assertion. - T402 a child window **longer** than its parent's is accepted, same limit. -- T401a a budget on a grant none of whose actions carries an `effect:` template is a **load error** - naming the grant, the budget and the actions (§2.4.1). The case is - `examples/authority/payments.yaml`'s `reconciliation` grant, whose actions are reads. +- T401a **belongs to the item that owns §2.4.1's check, and that is not item 3.** §2.4.1 was a load + error in a draft and is not one now: the probes in that section show a loader cannot see a + decorator-supplied `effect=` and cannot run at all on the standalone-authority path. The check is + at execute time, after the effect key resolves and before `_secure`, so it is **item 5's**, beside + the charge it protects. Listed here rather than deleted, because a test id that vanished would + look like an oversight. - T402a the matching rule of §2.6.1, over the two-budget parent §2.2 exists for: each of the four rows of that section's worked table is a case. - T403 a child omitting a budget its parent carries is rejected. @@ -1572,8 +1601,8 @@ One justification per row. Anything not here is a spec amendment before it is co | Addition | Why an existing name does not serve | |---|---| -| `Budget` (`metric: str`, `limit: int`, `window: timedelta`) | nothing in `authority.py` carries a quantity over a period. **`window` is a `timedelta` in Python and an ISO-8601 duration in the document and in `_canonical_grant`**, because a `timedelta` is not a `PlainValue` (`action.py:19`) and so cannot render through `canonical_bytes`; the document's own spelling is the canonical one, which is what §2.8's hash covers | -| `Grant.budgets` | `constraints` decides one action and cannot count | +| `Budget` (`metric: str`, `limit: int`, `window: timedelta`) | nothing in `authority.py` carries a quantity over a period. `window` is a `timedelta` in Python, an ISO-8601 duration in the document, and **integer seconds in `_canonical_grant`**, because a `timedelta` is not a `PlainValue` (`action.py:19`) and cannot render through `canonical_bytes`. An earlier draft of this row said the document's spelling was canonical; it is not implementable, since `canonical_grants` renders parsed grants and a `Budget` keeps no source text. §2.8 carries the consequence | +| `Grant.budgets` | `constraints` decides one action and cannot count. A `Budget`'s window is a **whole number of seconds**, bounded, because it is stored and hashed as integer seconds: a sub-second window would round to `0` on the way into a delegation row and read back unreadable, dead for ever | | `Grant.tasks` | no dimension names a unit of work | | `DIMENSIONS` grows from six entries to eight | **an exported public name whose value changes** (`authority.py:126`, `__all__` at `authority.py:1787`). `verify/scenarios.py` iterates it for G9 and prints `len(DIMENSIONS)`, so this is not a private constant. §9.6 has the test | | `Charge` | the store needs a value object for what a reservation spends, carrying the whole predicate (§3.3.1) | @@ -1632,7 +1661,7 @@ No new module. Budgets and tasks are `authority.py`; the ledger is `state.py`, ` | Situation | Outcome | |---|---| | A budget's metric names an argument the action does not carry | **refused** (§2.3). Never zero | -| A budget sits on a grant none of whose actions carries an `effect:` template | **load error** (§2.4.1), naming the grant, the budget and the actions | +| An action whose effect key resolved to `None`, under a grant that budgets | **refused at execute** (§2.4.1), after the key resolves and before the reservation. **Not a load error**: §2.4.1's probes show a loader cannot see a decorator-supplied `effect=`, and cannot run at all on the standalone-authority path | | A budget limit is a float, a `Decimal`, a decimal string, negative, or not a number | **load error**, at the loader and at the constructor (§2.2, §2.3) | | A child grant's budget exceeds its parent's limit, **or shortens its window** | **rejected at delegation** (§2.6): a shorter window over the same limit is a higher rate | | A parent budget is matched by no child budget on that metric | **rejected at delegation** (§2.6.1) | diff --git a/examples/authority/payments.yaml b/examples/authority/payments.yaml index 2d331200..73c44ef9 100644 --- a/examples/authority/payments.yaml +++ b/examples/authority/payments.yaml @@ -44,7 +44,8 @@ authority: # argument and is summed; the limit is in minor units like every band above, so this is # €1,000.00 of refunds in any rolling 24 hours, across this grant and every delegation # beneath it (§2.7). A grant that omits `budgets:` budgets nothing, which is what the two - # grants below do and why they are unchanged. `ctrlrun verify` grades G22 against this. + # grants below do and why they are unchanged. Nothing sums a metric yet: the ledger and + # the spending are later items, and `ctrlrun verify` grades G22 against this once they land. budgets: - { metric: amount, limit: 100000, window: PT24H } # €1,000.00 a day diff --git a/src/ctrlrun/authority.py b/src/ctrlrun/authority.py index 219bc335..d37ea55f 100644 --- a/src/ctrlrun/authority.py +++ b/src/ctrlrun/authority.py @@ -397,6 +397,20 @@ def __post_init__(self) -> None: raise InvalidArgument( f"budget {self.metric!r}: 'window' must be a positive duration, got {self.window!r}" ) + # **Whole seconds, because a `Budget` that cannot round-trip is not a legal one.** + # `grant_to_json` renders integer seconds, so `timedelta(milliseconds=500)` would store as + # `0` and read back as an unreadable delegation, dead for ever. `Control.delegate` takes a + # `Grant` built in Python, which is §2.2's whole reason for validating here as well as in + # the loader, and the loader's grammar is integer-only anyway. + if self.window.microseconds: + raise InvalidArgument( + f"budget {self.metric!r}: 'window' must be a whole number of seconds, got " + f"{self.window!r}; it is stored and hashed as integer seconds (SPEC-v0.9 §2.8)" + ) + if self.window.total_seconds() > _MAX_WINDOW_SECONDS: + raise InvalidArgument( + f"budget {self.metric!r}: 'window' may not exceed {_MAX_WINDOW_SECONDS} seconds" + ) @dataclass(frozen=True) @@ -733,10 +747,17 @@ def grant_from_json(text: str, *, delegation_id: str) -> Grant: tasks=_optional_tuple(document.get("tasks"), delegation_id), budgets=_budgets_from_json(document.get("budgets"), delegation_id), ) - except (InvalidArgument, PolicyError, TypeError, ValueError) as exc: + except (ArithmeticError, InvalidArgument, PolicyError, TypeError, ValueError) as exc: raise _UnreadableError(delegation_id, str(exc)) from exc +#: SPEC-v0.9 §2.2 — the widest window a budget may carry, in seconds: a hundred years, which is +#: past any rolling window an operator means and well inside what `timedelta` can hold. It exists +#: so a stored row cannot raise `OverflowError` out of `Authority.evaluate`, which `_candidates` +#: would turn into a deployment-wide outage rather than one unreadable delegation. +_MAX_WINDOW_SECONDS: Final = 100 * 365 * 24 * 60 * 60 + + def _budgets_from_json(value: object, delegation_id: str) -> tuple[Budget, ...] | None: """Read a delegation's budgets back, validating exactly what the loader validated. @@ -752,6 +773,29 @@ def _budgets_from_json(value: object, delegation_id: str) -> tuple[Budget, ...] for entry in value: if not isinstance(entry, Mapping): raise _UnreadableError(delegation_id, "grant_json has a budget that is not an object") + # The loader's key set is closed (`_BUDGET_KEYS`) and this docstring claims to validate + # exactly what the loader validates, so it is closed here too. An independent review found + # the two disagreeing: a stored row could carry a key the document could not. + unknown = set(entry) - _BUDGET_KEYS + if unknown: + raise _UnreadableError( + delegation_id, f"grant_json budget has unknown keys {sorted(unknown)}" + ) + window = entry.get("window") + # **The bool trap, one field over from where `limit` closes it**, and an independent + # review found it: `timedelta(seconds=True)` is a ONE-SECOND window, and a shorter window + # is a higher rate, so the failure grants authority. `0.5` is a sub-second window the + # loader's integer-only grammar cannot express. Same predicate as `limit`, not a second + # spelling of it. The bound is what keeps `timedelta` from raising `OverflowError` out of + # `Authority.evaluate`, which `_candidates` makes a deployment-wide outage: it reads every + # delegation row on every evaluation, so one corrupt row would deny nothing and crash + # everything, for every principal and every action. + if not _is_int(window) or not 0 < cast("int", window) <= _MAX_WINDOW_SECONDS: + raise _UnreadableError( + delegation_id, + f"grant_json budget 'window' must be a positive whole number of seconds up to " + f"{_MAX_WINDOW_SECONDS}, got {window!r}", + ) try: # `cast` and not a check: `Budget.__post_init__` validates all three, and a second # copy of that grammar here is the drift §2.2 forbids. The types are the store's @@ -760,10 +804,10 @@ def _budgets_from_json(value: object, delegation_id: str) -> tuple[Budget, ...] Budget( metric=cast("str", entry.get("metric")), limit=cast("int", entry.get("limit")), - window=timedelta(seconds=cast("float", entry.get("window", -1))), + window=timedelta(seconds=cast("int", window)), ) ) - except (InvalidArgument, TypeError, ValueError) as exc: + except (ArithmeticError, InvalidArgument, TypeError, ValueError) as exc: raise _UnreadableError(delegation_id, f"grant_json budget: {exc}") from exc return tuple(parsed) diff --git a/tests/test_budget_document.py b/tests/test_budget_document.py index 948a9ed6..c00536ef 100644 --- a/tests/test_budget_document.py +++ b/tests/test_budget_document.py @@ -11,7 +11,7 @@ from __future__ import annotations -from datetime import timedelta +from datetime import UTC, datetime, timedelta import pytest @@ -212,3 +212,116 @@ def test_T407b_a_budget_is_refused_in_a_v6_document() -> None: with pytest.raises(PolicyError) as caught: Policy.from_yaml(older, source="") assert "budgets" in str(caught.value) + + +# --- reading a stored budget back (SPEC-v0.9 §2.2, `v0.3 §5.2`) ------------------------------- + + +@pytest.mark.parametrize( + ("window", "why"), + [ + (10**15, "an oversized int, which timedelta answers with OverflowError"), + (float("inf"), "an infinity, same"), + (True, "a bool, which timedelta reads as ONE SECOND"), + (0.5, "a sub-second float the loader's grammar cannot express"), + (0, "a zero window"), + (-1, "a negative window"), + ("PT24H", "the document's spelling, which is not what is stored"), + ], +) +def test_T407c_a_corrupt_stored_window_is_unreadable_and_never_an_exception(window, why) -> None: + """An independent review found two of these escaping, and one of them deployment-wide. + + `OverflowError` is in neither `except` tuple, so an oversized window raised **out of** + `Authority.evaluate`. `_candidates` reads every delegation row on every evaluation, so one + corrupt row denied nothing and crashed everything, for every principal and every action, with + no event and no receipt for an operator to find. + + And `timedelta(seconds=True)` is a one-second window: the bool trap one field over from where + `limit` closes it, failing in the direction that **grants** authority, since a shorter window + is a higher rate. + """ + from ctrlrun.authority import _budgets_from_json + + with pytest.raises(Exception) as caught: + _budgets_from_json([{"metric": "a", "limit": 5, "window": window}], "dlg_" + "0" * 32) + assert type(caught.value).__name__ == "_UnreadableError", ( + f"{why}: must be unreadable, not {type(caught.value).__name__}" + ) + + +def test_T407d_a_corrupt_row_denies_an_unrelated_principal_rather_than_crashing() -> None: + """The blast radius, driven end to end rather than argued. + + `Authority._candidates` reads every delegation row on every evaluation, so the failure mode + for an unreadable row has to be a refusal. Before the fix this raised `OverflowError`. + """ + import json + + from ctrlrun.action import Action, Principal + from ctrlrun.state import DelegationRecord, InMemoryStateStore + + document = """ +schema: ctrlrun.policy/v7 +authority: + grants: + - id: other + subject: {agent: "reconciliation-agent"} + actions: ["payments.read"] +""" + authority = Authority.from_yaml(document, source="", standalone=True) + store = InMemoryStateStore() + grant = { + "subject": {"agent": "payer", "user": "ada"}, + "actions": ["payments.refund"], + "resources": None, + "constraints": {}, + "environments": None, + "expires_at": None, + "delegable": False, + "tasks": None, + "budgets": [{"metric": "amount", "limit": 5, "window": 10**15}], + } + now = datetime(2026, 9, 13, tzinfo=UTC) + store.put_delegation( + DelegationRecord( + delegation_id="dlg_" + "0" * 32, + parent_id="other", + depth=1, + grant_json=json.dumps(grant), + created_by_agent="x", + created_by_user=None, + created_via="api", + created_at=now, + revoked_at=None, + revoked_by=None, + ) + ) + unrelated = Action( + name="payments.read", arguments={}, principal=Principal(agent="reconciliation-agent") + ) + result = authority.evaluate(unrelated, now=now, store=store) + assert not result.passed + assert result.reason == "authority_unreadable", ( + "one corrupt row must deny with a reason, not raise out of evaluate for the deployment" + ) + + +def test_T407e_a_stored_budget_may_not_carry_a_key_the_document_could_not() -> None: + """§2.2's rule in the direction the review found it broken: the loader's key set is closed, + so the reader's is too, or a stored row carries what no document can express.""" + from ctrlrun.authority import _budgets_from_json + + with pytest.raises(Exception) as caught: + _budgets_from_json( + [{"metric": "a", "limit": 5, "window": 86400, "surprise": 1}], "dlg_" + "0" * 32 + ) + assert type(caught.value).__name__ == "_UnreadableError" + + +def test_T407f_a_sub_second_window_is_refused_at_construction() -> None: + """A `Budget` that cannot round-trip is not a legal one: `grant_to_json` renders integer + seconds, so `timedelta(milliseconds=500)` would store as `0` and read back dead for ever.""" + with pytest.raises(InvalidArgument) as caught: + Budget("amount", 100, timedelta(milliseconds=500)) + assert "whole number of seconds" in str(caught.value)