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)

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

View file

@ -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