Finish TEN-WP-0006-T02: implement guardrail domain model

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-16 02:10:38 +02:00
parent 0d1435c2d2
commit 33ceb882ee
6 changed files with 868 additions and 1 deletions

View 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",
]

View 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

View 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)

View 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)