Finish TEN-WP-0006-T02: implement guardrail domain model
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
0d1435c2d2
commit
33ceb882ee
6 changed files with 868 additions and 1 deletions
49
src/tenant_engine/guardrail/__init__.py
Normal file
49
src/tenant_engine/guardrail/__init__.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
182
src/tenant_engine/guardrail/model.py
Normal file
182
src/tenant_engine/guardrail/model.py
Normal file
|
|
@ -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
|
||||||
175
src/tenant_engine/guardrail/registry.py
Normal file
175
src/tenant_engine/guardrail/registry.py
Normal file
|
|
@ -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)
|
||||||
99
src/tenant_engine/guardrail/resolution.py
Normal file
99
src/tenant_engine/guardrail/resolution.py
Normal file
|
|
@ -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)
|
||||||
332
tests/test_guardrail_domain.py
Normal file
332
tests/test_guardrail_domain.py
Normal file
|
|
@ -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=" ")
|
||||||
|
|
@ -116,7 +116,7 @@ default lands immediately.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TEN-WP-0006-T02
|
id: TEN-WP-0006-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "1b28d836-11d9-4ef3-967b-05bfa74c304a"
|
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
|
with unit tests covering every grouping, the reserved identifiers, missing
|
||||||
plans, and conflicting overrides.
|
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
|
## T03 - Persist guardrails in both stores
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue