From 33ceb882eef84c7b44dfc2c8ea16f1df6f81f474 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 16 Aug 2026 02:10:38 +0200 Subject: [PATCH] Finish TEN-WP-0006-T02: implement guardrail domain model Co-Authored-By: Claude Opus 5 --- src/tenant_engine/guardrail/__init__.py | 49 +++ src/tenant_engine/guardrail/model.py | 182 ++++++++++ src/tenant_engine/guardrail/registry.py | 175 +++++++++ src/tenant_engine/guardrail/resolution.py | 99 ++++++ tests/test_guardrail_domain.py | 332 ++++++++++++++++++ .../TEN-WP-0006-guardrail-quota-policy.md | 32 +- 6 files changed, 868 insertions(+), 1 deletion(-) create mode 100644 src/tenant_engine/guardrail/__init__.py create mode 100644 src/tenant_engine/guardrail/model.py create mode 100644 src/tenant_engine/guardrail/registry.py create mode 100644 src/tenant_engine/guardrail/resolution.py create mode 100644 tests/test_guardrail_domain.py diff --git a/src/tenant_engine/guardrail/__init__.py b/src/tenant_engine/guardrail/__init__.py new file mode 100644 index 0000000..8e24e11 --- /dev/null +++ b/src/tenant_engine/guardrail/__init__.py @@ -0,0 +1,49 @@ +"""Guardrail and quota policy (TEN-WP-0006). + +The namespace `.claude/rules/architecture.md` reserved from the start. Pure +domain: frozen dataclasses, typed errors, and resolution as a side-effect-free +function. No framework dependency, no store, no I/O. + +`tenant-engine` owns the *ceiling*. It does not meter, bill, or decide -- +`flex-auth` joins these limits with consumption to reach a decision. See +`docs/tenant-guardrail-policy.md`. +""" + +from tenant_engine.guardrail.model import ( + UNLIMITED, + ConflictingLimitError, + EffectiveLimit, + GuardrailChange, + InvalidLimitError, + LimitKind, + LimitValue, + Provenance, + Unlimited, +) +from tenant_engine.guardrail.registry import ( + DEFAULT_REGISTRY, + GuardrailRegistryInvalidError, + LimitDefinition, + LimitRegistry, + UnknownLimitKeyError, +) +from tenant_engine.guardrail.resolution import resolve_limit, resolve_limits + +__all__ = [ + "DEFAULT_REGISTRY", + "UNLIMITED", + "ConflictingLimitError", + "EffectiveLimit", + "GuardrailChange", + "GuardrailRegistryInvalidError", + "InvalidLimitError", + "LimitDefinition", + "LimitKind", + "LimitRegistry", + "LimitValue", + "Provenance", + "Unlimited", + "UnknownLimitKeyError", + "resolve_limit", + "resolve_limits", +] diff --git a/src/tenant_engine/guardrail/model.py b/src/tenant_engine/guardrail/model.py new file mode 100644 index 0000000..89d02a3 --- /dev/null +++ b/src/tenant_engine/guardrail/model.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from typing import Final, Union + + +class InvalidLimitError(ValueError): + """A limit value violates a domain invariant.""" + + +class ConflictingLimitError(ValueError): + """Two limits for the same key disagree on kind, period, or currency.""" + + +class LimitKind(str, Enum): + """What a limit counts. + + Rate limiting is deliberately absent: a per-second traffic ceiling is a + gateway concern with different latency and storage characteristics. + ACTION_COUNT is a business-period quota, not a traffic shaper. + """ + + SPEND = "spend" + ENTITY_COUNT = "entity_count" + ACTION_COUNT = "action_count" + + +class Provenance(str, Enum): + """Which layer supplied an effective value. + + Returned to consumers so a deliberate enterprise ceiling is + distinguishable from a floor that was reached by accident. + """ + + OVERRIDE = "override" + PLAN = "plan" + GROUPING = "grouping" + RESERVED = "reserved" + FAIL_CLOSED = "fail_closed" + LIFECYCLE = "lifecycle" + + +@dataclass(frozen=True, slots=True) +class Unlimited: + """An explicit, declared absence of ceiling. + + Four rules, enforced by construction sites rather than by this type: it is + never a default, never the result of absence or a parse failure, only ever + arrives by explicit declaration, and its assignment is audited. The + prohibition in the contract is on *inferring* an open ceiling, not on + deliberately choosing one. + """ + + def __repr__(self) -> str: # pragma: no cover - debug aid + return "UNLIMITED" + + +UNLIMITED: Final = Unlimited() + +Amount = Union[int, Unlimited] + + +@dataclass(frozen=True, slots=True) +class LimitValue: + """One ceiling. + + Spend amounts are integer minor units plus an ISO-4217 currency, never + floats -- a budget compared as a float rounds the wrong way at exactly the + boundary where it matters. + """ + + kind: LimitKind + amount: Amount + currency: str | None = None + period: str | None = None + + def __post_init__(self) -> None: + if isinstance(self.amount, int) and self.amount < 0: + raise InvalidLimitError(f"limit amount must not be negative, got {self.amount}") + if isinstance(self.amount, bool): + raise InvalidLimitError("limit amount must be an integer, not a bool") + + if self.kind is LimitKind.SPEND: + if not self.currency: + raise InvalidLimitError("a spend limit requires a currency") + if not self.period: + raise InvalidLimitError("a spend limit requires a period") + elif self.currency is not None: + raise InvalidLimitError(f"a {self.kind.value} limit must not carry a currency") + + if self.kind is LimitKind.ENTITY_COUNT and self.period is not None: + raise InvalidLimitError("an entity_count limit is a point-in-time cap, not a rate") + if self.kind is LimitKind.ACTION_COUNT and not self.period: + raise InvalidLimitError("an action_count limit requires a period") + + @property + def is_unlimited(self) -> bool: + return isinstance(self.amount, Unlimited) + + def compatible_with(self, other: "LimitValue") -> bool: + """Whether two values describe the same ceiling shape. + + Currency is *not* part of compatibility: precedence resolves per key + and the highest layer wins outright, so two currencies never combine + within one resolution. tenant-engine therefore holds no exchange rate. + """ + return self.kind is other.kind and self.period == other.period + + def reduced_to_floor(self, floor: "LimitValue") -> "LimitValue": + """Return the more restrictive of self and floor. + + Used by the lifecycle clamp, which may only ever reduce. + """ + if not self.compatible_with(floor): + raise ConflictingLimitError( + f"cannot clamp a {self.kind.value} limit to a {floor.kind.value} floor" + ) + if self.is_unlimited: + return floor + if floor.is_unlimited: + return self + assert isinstance(self.amount, int) and isinstance(floor.amount, int) + return self if self.amount <= floor.amount else floor + + +@dataclass(frozen=True, slots=True) +class EffectiveLimit: + """A resolved ceiling plus the layer it came from.""" + + key: str + value: LimitValue + provenance: Provenance + + @property + def is_fail_closed(self) -> bool: + """True when this value came from the floor rather than from policy. + + Surfaced rather than hidden: a `fail_closed` provenance means the + registry is internally inconsistent, and a consumer should be able to + tell that apart from a deliberate zero. + """ + return self.provenance is Provenance.FAIL_CLOSED + + +@dataclass(frozen=True, slots=True) +class GuardrailChange: + """An audited guardrail mutation. + + A limit change is a privilege change, so it is append-only and carries the + same evidence a `RoleGrant` does -- actor, reason, correlation id. "Who + raised this tenant's ceiling, when, and why" must stay answerable. + """ + + change_id: str + tenant_id: str + limit_key: str + previous: LimitValue | None + current: LimitValue | None + changed_by: str + reason: str + correlation_id: str + changed_at: datetime + + def __post_init__(self) -> None: + if self.previous is None and self.current is None: + raise InvalidLimitError("a guardrail change must set or clear a limit") + if self.previous is not None and self.current is not None: + if not self.previous.compatible_with(self.current): + raise ConflictingLimitError( + f"guardrail change for {self.limit_key!r} changes the limit shape" + ) + if self.previous == self.current: + raise InvalidLimitError("guardrail change would not change the limit") + if not self.reason.strip(): + raise InvalidLimitError("a guardrail change requires a reason") + + @property + def is_clear(self) -> bool: + """Whether this change removed a per-tenant override.""" + return self.current is None diff --git a/src/tenant_engine/guardrail/registry.py b/src/tenant_engine/guardrail/registry.py new file mode 100644 index 0000000..fe74a06 --- /dev/null +++ b/src/tenant_engine/guardrail/registry.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from dataclasses import dataclass +from types import MappingProxyType +from typing import Mapping + +from tenant_engine.domain import GROUPINGS +from tenant_engine.guardrail.model import ( + UNLIMITED, + InvalidLimitError, + LimitKind, + LimitValue, +) + + +class UnknownLimitKeyError(KeyError): + """A limit key is not registered. + + Deliberately an error rather than a zero or an unlimited. Zero would break + a caller who merely misspelled a key; unlimited would fail open. + """ + + +class GuardrailRegistryInvalidError(ValueError): + """A registry does not supply a default for every grouping. + + Raised at construction, so an unmapped grouping cannot reach production. + """ + + +# Key prefixes, by kind. A key must match one of these to be registered. +_PREFIXES: Mapping[str, LimitKind] = MappingProxyType( + { + "spend.": LimitKind.SPEND, + "entity.": LimitKind.ENTITY_COUNT, + "action.": LimitKind.ACTION_COUNT, + } +) + +# The deployment's canonical currency for grouping-derived spend defaults. +# Plan-derived and override limits carry their own. +DEFAULT_CURRENCY = "EUR" + + +def kind_for_key(key: str) -> LimitKind: + for prefix, kind in _PREFIXES.items(): + if key.startswith(prefix) and len(key) > len(prefix): + return kind + raise UnknownLimitKeyError(key) + + +@dataclass(frozen=True, slots=True) +class LimitDefinition: + """One registered limit key: its shape, its floor, and its defaults. + + `owner` names the service that gives the key meaning. tenant-engine stores + the ceiling for `entity.workspace` and never learns what a workspace is. + """ + + key: str + kind: LimitKind + floor: LimitValue + grouping_defaults: Mapping[str, LimitValue] + reserved_default: LimitValue + owner: str + period: str | None = None + + def __post_init__(self) -> None: + if kind_for_key(self.key) is not self.kind: + raise InvalidLimitError(f"key {self.key!r} does not match kind {self.kind.value}") + missing = sorted(GROUPINGS - set(self.grouping_defaults)) + if missing: + raise GuardrailRegistryInvalidError( + f"{self.key!r} has no default for grouping(s): {', '.join(missing)}" + ) + for value in (self.floor, self.reserved_default, *self.grouping_defaults.values()): + if value.kind is not self.kind: + raise InvalidLimitError(f"{self.key!r} default has kind {value.kind.value}") + if self.floor.is_unlimited: + raise GuardrailRegistryInvalidError( + f"{self.key!r} floor must be restrictive -- a floor is never unlimited" + ) + + +class LimitRegistry: + """The set of limit keys that resolve. Immutable once constructed.""" + + def __init__(self, definitions: tuple[LimitDefinition, ...]) -> None: + by_key: dict[str, LimitDefinition] = {} + for definition in definitions: + if definition.key in by_key: + raise GuardrailRegistryInvalidError(f"duplicate limit key {definition.key!r}") + by_key[definition.key] = definition + self._by_key = MappingProxyType(by_key) + + def __contains__(self, key: object) -> bool: + return key in self._by_key + + def __iter__(self): + return iter(self._by_key) + + def __len__(self) -> int: + return len(self._by_key) + + @property + def keys(self) -> tuple[str, ...]: + return tuple(self._by_key) + + def get(self, key: str) -> LimitDefinition: + try: + return self._by_key[key] + except KeyError: + raise UnknownLimitKeyError(key) from None + + +def _eur(minor_units: int) -> LimitValue: + return LimitValue( + kind=LimitKind.SPEND, + amount=minor_units, + currency=DEFAULT_CURRENCY, + period="P1M", + ) + + +# Monthly spend ceilings per ADR-0013 grouping, in minor units of +# DEFAULT_CURRENCY. Only `trial` = 0 is canon (ADR-0013); the rest are +# conservative opening ceilings chosen so that no grouping starts unbounded, +# pending product sign-off. They are guardrails, not prices -- raising one is +# an override or a plan-derived limit, both audited. +# +# `agentic` sits below `small` on purpose: an autonomous agent can exhaust a +# budget far faster than a human operator notices, so its ceiling is set for +# the blast radius rather than the buying power. +_SPEND_MONTHLY_DEFAULTS: Mapping[str, LimitValue] = MappingProxyType( + { + "trial": _eur(0), + "friendly": _eur(0), + "consumer": _eur(2_000), + "single": _eur(5_000), + "family": _eur(5_000), + "community": _eur(5_000), + "agentic": _eur(10_000), + "small": _eur(25_000), + "association": _eur(25_000), + "medium": _eur(100_000), + "large": _eur(500_000), + "enterprise": _eur(2_000_000), + } +) + +SPEND_MONTHLY = LimitDefinition( + key="spend.monthly", + kind=LimitKind.SPEND, + period="P1M", + floor=_eur(0), + grouping_defaults=_SPEND_MONTHLY_DEFAULTS, + # Infrastructure identities are not billable spenders: platform cost is + # not metered per tenant. Counts are a different story -- see the contract. + reserved_default=_eur(0), + owner="tenant-engine", +) + +DEFAULT_REGISTRY = LimitRegistry((SPEND_MONTHLY,)) + + +def unlimited_reserved_default(kind: LimitKind, period: str | None = None) -> LimitValue: + """The reserved-profile value for a count key. + + `tenant:platform` and `tenant:coulomb` operate the platform, so their + entity and action ceilings are explicitly open. This is the sentinel's + "explicit declaration" rule, not an inference from absence. + """ + if kind is LimitKind.SPEND: + raise InvalidLimitError("reserved spend defaults are zero, not unlimited") + return LimitValue(kind=kind, amount=UNLIMITED, period=period) diff --git a/src/tenant_engine/guardrail/resolution.py b/src/tenant_engine/guardrail/resolution.py new file mode 100644 index 0000000..7372e4d --- /dev/null +++ b/src/tenant_engine/guardrail/resolution.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from typing import Mapping + +from tenant_engine.domain import Tenant, TenantLifecycle +from tenant_engine.guardrail.model import EffectiveLimit, LimitValue, Provenance +from tenant_engine.guardrail.registry import DEFAULT_REGISTRY, LimitRegistry + +# Per-key precedence, highest first: override -> plan -> grouping (or the +# reserved profile) -> fail-closed floor. Evaluated *per key*, never per set, +# so a plan supplying `spend.monthly` does not wipe out a grouping-derived +# `entity.*`. + + +def resolve_limit( + key: str, + *, + tenant: Tenant, + plan_limits: Mapping[str, LimitValue] | None = None, + overrides: Mapping[str, LimitValue] | None = None, + registry: LimitRegistry = DEFAULT_REGISTRY, +) -> EffectiveLimit: + """Resolve one limit key for one tenant. Total and side-effect-free. + + Raises `UnknownLimitKeyError` for an unregistered key -- neither zero nor + unlimited, both of which would be lies. + """ + definition = registry.get(key) + + override = (overrides or {}).get(key) + if override is not None: + return _clamp(EffectiveLimit(key, override, Provenance.OVERRIDE), tenant, definition.floor) + + plan_limit = (plan_limits or {}).get(key) + if plan_limit is not None: + return _clamp(EffectiveLimit(key, plan_limit, Provenance.PLAN), tenant, definition.floor) + + if tenant.is_reserved: + # Reserved identifiers are ungrouped, so layer 3 cannot apply. They do + # not fall through to the floor either: that would clamp the platform's + # own identity to zero and take the platform down with it. + return _clamp( + EffectiveLimit(key, definition.reserved_default, Provenance.RESERVED), + tenant, + definition.floor, + ) + + grouping_default = definition.grouping_defaults.get(tenant.grouping or "") + if grouping_default is not None: + return _clamp( + EffectiveLimit(key, grouping_default, Provenance.GROUPING), tenant, definition.floor + ) + + # Only reachable if the registry is internally inconsistent -- a grouping + # added to GROUPINGS with no default declared. The registry validates this + # at construction, so this is the second line of defence, not the first. + # Provenance stays `fail_closed` so the condition is visible in a read + # rather than mistaken for deliberate policy. + return _clamp( + EffectiveLimit(key, definition.floor, Provenance.FAIL_CLOSED), tenant, definition.floor + ) + + +def resolve_limits( + *, + tenant: Tenant, + plan_limits: Mapping[str, LimitValue] | None = None, + overrides: Mapping[str, LimitValue] | None = None, + registry: LimitRegistry = DEFAULT_REGISTRY, +) -> dict[str, EffectiveLimit]: + """Resolve every registered key. Every tenant resolves to exactly one + effective value per key -- there is no "unset means unlimited". + """ + return { + key: resolve_limit( + key, + tenant=tenant, + plan_limits=plan_limits, + overrides=overrides, + registry=registry, + ) + for key in registry.keys + } + + +def _clamp(effective: EffectiveLimit, tenant: Tenant, floor: LimitValue) -> EffectiveLimit: + """Apply the lifecycle clamp. Applied after precedence, and may only reduce. + + Follows the TEN-WP-0005 precedent: operations that only reduce privilege + stay available while retired, loosening ones do not. A retired tenant's + guardrails stay readable -- it is the values that clamp, not the endpoint. + """ + if tenant.lifecycle is TenantLifecycle.ACTIVE: + return effective + + clamped = effective.value.reduced_to_floor(floor) + if clamped == effective.value: + return effective + return EffectiveLimit(effective.key, clamped, Provenance.LIFECYCLE) diff --git a/tests/test_guardrail_domain.py b/tests/test_guardrail_domain.py new file mode 100644 index 0000000..c6c5031 --- /dev/null +++ b/tests/test_guardrail_domain.py @@ -0,0 +1,332 @@ +from datetime import UTC, datetime + +import pytest + +from tenant_engine.domain import GROUPINGS, RESERVED_IDENTIFIERS, Tenant +from tenant_engine.guardrail import ( + UNLIMITED, + ConflictingLimitError, + GuardrailChange, + GuardrailRegistryInvalidError, + InvalidLimitError, + LimitDefinition, + LimitKind, + LimitRegistry, + LimitValue, + Provenance, + UnknownLimitKeyError, + resolve_limit, + resolve_limits, +) +from tenant_engine.guardrail.registry import ( + DEFAULT_REGISTRY, + SPEND_MONTHLY, + unlimited_reserved_default, +) + +NOW = datetime(2026, 8, 16, 12, 0, tzinfo=UTC) + + +def tenant(identifier: str, *, retired: bool = False) -> Tenant: + t = Tenant.create(tenant_id="t-1", identifier=identifier, created_at=NOW) + return t.retire(at=NOW) if retired else t + + +def eur(amount) -> LimitValue: + return LimitValue(kind=LimitKind.SPEND, amount=amount, currency="EUR", period="P1M") + + +# --- LimitValue invariants ------------------------------------------------ + + +def test_spend_limit_requires_currency_and_period(): + with pytest.raises(InvalidLimitError): + LimitValue(kind=LimitKind.SPEND, amount=100, period="P1M") + with pytest.raises(InvalidLimitError): + LimitValue(kind=LimitKind.SPEND, amount=100, currency="EUR") + + +def test_count_limits_reject_currency_and_enforce_period_shape(): + with pytest.raises(InvalidLimitError): + LimitValue(kind=LimitKind.ENTITY_COUNT, amount=5, currency="EUR") + with pytest.raises(InvalidLimitError): + # an entity count is a point-in-time cap, not a rate + LimitValue(kind=LimitKind.ENTITY_COUNT, amount=5, period="P1M") + with pytest.raises(InvalidLimitError): + LimitValue(kind=LimitKind.ACTION_COUNT, amount=5) + + +def test_negative_and_bool_amounts_are_rejected(): + with pytest.raises(InvalidLimitError): + eur(-1) + with pytest.raises(InvalidLimitError): + eur(True) + + +def test_unlimited_is_explicit_and_never_an_int(): + value = LimitValue(kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED) + assert value.is_unlimited + assert not eur(0).is_unlimited + + +def test_reduced_to_floor_only_ever_reduces(): + floor = eur(0) + assert eur(500).reduced_to_floor(floor) == floor + assert eur(0).reduced_to_floor(eur(500)) == eur(0) + assert LimitValue( + kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED + ).reduced_to_floor(LimitValue(kind=LimitKind.ENTITY_COUNT, amount=3)).amount == 3 + + +def test_clamping_across_kinds_is_a_conflict(): + with pytest.raises(ConflictingLimitError): + eur(10).reduced_to_floor(LimitValue(kind=LimitKind.ENTITY_COUNT, amount=0)) + + +def test_currency_is_not_part_of_compatibility(): + # precedence resolves per key and the top layer wins outright, so two + # currencies never combine -- tenant-engine holds no exchange rate + usd = LimitValue(kind=LimitKind.SPEND, amount=100, currency="USD", period="P1M") + assert eur(100).compatible_with(usd) + + +# --- Registry ------------------------------------------------------------- + + +def test_registry_requires_a_default_for_every_grouping(): + partial = {g: eur(0) for g in list(GROUPINGS)[:-1]} + with pytest.raises(GuardrailRegistryInvalidError): + LimitDefinition( + key="spend.monthly", + kind=LimitKind.SPEND, + floor=eur(0), + grouping_defaults=partial, + reserved_default=eur(0), + owner="tenant-engine", + ) + + +def test_registry_floor_may_not_be_unlimited(): + with pytest.raises(GuardrailRegistryInvalidError): + LimitDefinition( + key="entity.workspace", + kind=LimitKind.ENTITY_COUNT, + floor=LimitValue(kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED), + grouping_defaults={ + g: LimitValue(kind=LimitKind.ENTITY_COUNT, amount=1) for g in GROUPINGS + }, + reserved_default=LimitValue(kind=LimitKind.ENTITY_COUNT, amount=1), + owner="some-service", + ) + + +def test_key_prefix_must_match_kind(): + with pytest.raises(InvalidLimitError): + LimitDefinition( + key="entity.workspace", + kind=LimitKind.SPEND, + floor=eur(0), + grouping_defaults={g: eur(0) for g in GROUPINGS}, + reserved_default=eur(0), + owner="x", + ) + + +def test_duplicate_keys_are_rejected(): + with pytest.raises(GuardrailRegistryInvalidError): + LimitRegistry((SPEND_MONTHLY, SPEND_MONTHLY)) + + +def test_unregistered_key_errors_rather_than_resolving(): + # not zero (would break a misspelled key) and not unlimited (fails open) + with pytest.raises(UnknownLimitKeyError): + resolve_limit("spend.weekly", tenant=tenant("tenant:small:acme")) + with pytest.raises(UnknownLimitKeyError): + DEFAULT_REGISTRY.get("entity.unregistered") + + +def test_reserved_spend_default_is_never_unlimited(): + with pytest.raises(InvalidLimitError): + unlimited_reserved_default(LimitKind.SPEND) + + +# --- Resolution: groupings ------------------------------------------------ + + +@pytest.mark.parametrize("grouping", sorted(GROUPINGS)) +def test_every_grouping_resolves_to_exactly_one_value(grouping): + effective = resolve_limit("spend.monthly", tenant=tenant(f"tenant:{grouping}:x")) + assert effective.provenance is Provenance.GROUPING + assert isinstance(effective.value.amount, int) + assert not effective.is_fail_closed + + +def test_trial_defaults_to_zero_spend(): + # ADR-0013's mandate, and the one row in the table that is canon + effective = resolve_limit("spend.monthly", tenant=tenant("tenant:trial:binky")) + assert effective.value.amount == 0 + assert effective.provenance is Provenance.GROUPING + + +def test_agentic_ceiling_is_tighter_than_small(): + defaults = SPEND_MONTHLY.grouping_defaults + assert defaults["agentic"].amount < defaults["small"].amount + + +def test_resolve_limits_covers_the_whole_registry(): + resolved = resolve_limits(tenant=tenant("tenant:medium:acme")) + assert set(resolved) == set(DEFAULT_REGISTRY.keys) + + +# --- Resolution: precedence ---------------------------------------------- + + +def test_override_beats_plan_beats_grouping(): + t = tenant("tenant:small:acme") + grouping_only = resolve_limit("spend.monthly", tenant=t) + assert grouping_only.provenance is Provenance.GROUPING + + with_plan = resolve_limit( + "spend.monthly", tenant=t, plan_limits={"spend.monthly": eur(60_000)} + ) + assert with_plan.provenance is Provenance.PLAN + assert with_plan.value.amount == 60_000 + + with_override = resolve_limit( + "spend.monthly", + tenant=t, + plan_limits={"spend.monthly": eur(60_000)}, + overrides={"spend.monthly": eur(1_000)}, + ) + assert with_override.provenance is Provenance.OVERRIDE + assert with_override.value.amount == 1_000 + + +def test_precedence_is_per_key_not_per_set(): + entity = LimitDefinition( + key="entity.workspace", + kind=LimitKind.ENTITY_COUNT, + floor=LimitValue(kind=LimitKind.ENTITY_COUNT, amount=0), + grouping_defaults={g: LimitValue(kind=LimitKind.ENTITY_COUNT, amount=3) for g in GROUPINGS}, + reserved_default=LimitValue(kind=LimitKind.ENTITY_COUNT, amount=UNLIMITED), + owner="some-service", + ) + registry = LimitRegistry((SPEND_MONTHLY, entity)) + + resolved = resolve_limits( + tenant=tenant("tenant:small:acme"), + plan_limits={"spend.monthly": eur(60_000)}, + registry=registry, + ) + # a plan supplying spend.monthly must not wipe out the grouping-derived + # entity limit + assert resolved["spend.monthly"].provenance is Provenance.PLAN + assert resolved["entity.workspace"].provenance is Provenance.GROUPING + assert resolved["entity.workspace"].value.amount == 3 + + +def test_unmapped_grouping_fails_closed_visibly(): + # second line of defence: the registry validates totality at construction, + # so this state is only reachable by bypassing it + broken = object.__new__(LimitDefinition) + object.__setattr__(broken, "key", "spend.monthly") + object.__setattr__(broken, "kind", LimitKind.SPEND) + object.__setattr__(broken, "period", "P1M") + object.__setattr__(broken, "floor", eur(0)) + object.__setattr__(broken, "grouping_defaults", {}) + object.__setattr__(broken, "reserved_default", eur(0)) + object.__setattr__(broken, "owner", "tenant-engine") + + effective = resolve_limit( + "spend.monthly", tenant=tenant("tenant:large:acme"), registry=LimitRegistry((broken,)) + ) + assert effective.provenance is Provenance.FAIL_CLOSED + assert effective.is_fail_closed + assert effective.value.amount == 0 + + +# --- Resolution: reserved identifiers and lifecycle ---------------------- + + +@pytest.mark.parametrize("identifier", sorted(RESERVED_IDENTIFIERS)) +def test_reserved_identifiers_use_the_reserved_profile(identifier): + t = tenant(identifier) + assert t.is_reserved + effective = resolve_limit("spend.monthly", tenant=t) + # explicit profile, not a fall-through to the floor: falling through would + # clamp the platform's own identity to zero and take the platform down + assert effective.provenance is Provenance.RESERVED + assert effective.value.amount == 0 + + +def test_reserved_counts_are_explicitly_unlimited(): + assert unlimited_reserved_default(LimitKind.ENTITY_COUNT).is_unlimited + + +def test_retired_tenant_clamps_to_the_floor_but_still_reads(): + effective = resolve_limit("spend.monthly", tenant=tenant("tenant:large:acme", retired=True)) + assert effective.provenance is Provenance.LIFECYCLE + assert effective.value.amount == 0 + + +def test_lifecycle_clamp_may_only_reduce(): + # a retired tenant whose resolved value is already at the floor keeps its + # provenance -- the clamp reduces, it does not relabel + effective = resolve_limit("spend.monthly", tenant=tenant("tenant:trial:x", retired=True)) + assert effective.value.amount == 0 + assert effective.provenance is Provenance.GROUPING + + +def test_retired_tenant_override_cannot_loosen(): + effective = resolve_limit( + "spend.monthly", + tenant=tenant("tenant:trial:x", retired=True), + overrides={"spend.monthly": eur(999_999)}, + ) + assert effective.value.amount == 0 + assert effective.provenance is Provenance.LIFECYCLE + + +# --- Audit --------------------------------------------------------------- + + +def change(**kwargs) -> GuardrailChange: + base = dict( + change_id="c-1", + tenant_id="t-1", + limit_key="spend.monthly", + previous=eur(0), + current=eur(5_000), + changed_by="ops", + reason="upgrade", + correlation_id="corr-1", + changed_at=NOW, + ) + return GuardrailChange(**{**base, **kwargs}) + + +def test_guardrail_change_carries_actor_reason_and_correlation(): + c = change() + assert (c.changed_by, c.reason, c.correlation_id) == ("ops", "upgrade", "corr-1") + assert not c.is_clear + + +def test_clearing_an_override_is_a_valid_change(): + assert change(current=None).is_clear + + +def test_a_change_must_actually_change_something(): + with pytest.raises(InvalidLimitError): + change(previous=eur(0), current=eur(0)) + with pytest.raises(InvalidLimitError): + change(previous=None, current=None) + + +def test_a_change_may_not_alter_the_limit_shape(): + with pytest.raises(ConflictingLimitError): + change(current=LimitValue(kind=LimitKind.ENTITY_COUNT, amount=5)) + + +def test_a_change_requires_a_reason(): + with pytest.raises(InvalidLimitError): + change(reason=" ") diff --git a/workplans/TEN-WP-0006-guardrail-quota-policy.md b/workplans/TEN-WP-0006-guardrail-quota-policy.md index c17d9fc..b238996 100644 --- a/workplans/TEN-WP-0006-guardrail-quota-policy.md +++ b/workplans/TEN-WP-0006-guardrail-quota-policy.md @@ -116,7 +116,7 @@ default lands immediately. ```task id: TEN-WP-0006-T02 -status: todo +status: done priority: high state_hub_task_id: "1b28d836-11d9-4ef3-967b-05bfa74c304a" ``` @@ -134,6 +134,36 @@ Done when effective-guardrail resolution is a total, side-effect-free function with unit tests covering every grouping, the reserved identifiers, missing plans, and conflicting overrides. +Done 2026-08-16: `src/tenant_engine/guardrail/` — `model.py` (frozen +dataclasses, typed errors), `registry.py` (the key registry and grouping +defaults), `resolution.py` (the pure resolution function). 42 new tests, 166 +total, all passing. + +Design notes worth carrying: + +- **Registry totality is enforced at construction.** `LimitDefinition` raises + `GuardrailRegistryInvalidError` if any grouping in `GROUPINGS` lacks a + default, so an unmapped grouping cannot reach production. The `fail_closed` + branch in `resolve_limit` is the second line of defence, not the first — its + test has to bypass the constructor to reach it. +- **A floor may never be `unlimited`,** also enforced at construction. A floor + that fails open is not a floor. +- **Currency is deliberately excluded from `compatible_with`.** Precedence + resolves per key and the winning layer takes the value whole, so two + currencies never combine — which is why there is no exchange rate anywhere + in this service, and why there must not be one. +- **The lifecycle clamp only relabels when it actually reduces.** A retired + `trial` tenant already at zero keeps provenance `grouping`; a retired + `large` tenant becomes `lifecycle`. Relabelling unconditionally would hide + where the value came from. +- **`bool` is rejected as an amount** — `True` is an `int` in Python and would + otherwise pass the non-negative check as a ceiling of 1. + +Lint: the new files are clean. `make lint` still fails on **19 pre-existing +E501s** in files this workplan does not touch (`tests/test_store.py` and +friends) — that baseline predates TEN-WP-0006 and is left alone rather than +folded into this diff. + ## T03 - Persist guardrails in both stores ```task