Finish TEN-WP-0006-T03: persist guardrails in both stores

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-16 02:14:31 +02:00
parent 33ceb882ee
commit f631224ab5
7 changed files with 701 additions and 3 deletions

View file

@ -27,7 +27,8 @@ from tenant_engine.guardrail.registry import (
LimitRegistry,
UnknownLimitKeyError,
)
from tenant_engine.guardrail.resolution import resolve_limit, resolve_limits
from tenant_engine.guardrail.resolution import is_loosening, resolve_limit, resolve_limits
from tenant_engine.guardrail.serde import dump_limit, load_limit
__all__ = [
"DEFAULT_REGISTRY",
@ -44,6 +45,9 @@ __all__ = [
"Provenance",
"Unlimited",
"UnknownLimitKeyError",
"dump_limit",
"is_loosening",
"load_limit",
"resolve_limit",
"resolve_limits",
]

View file

@ -83,6 +83,22 @@ def resolve_limits(
}
def is_loosening(previous: LimitValue, candidate: LimitValue) -> bool:
"""Whether `candidate` raises the ceiling relative to `previous`.
Used to decide what a retired tenant may still change. The lifecycle clamp
already pins a retired tenant's *effective* values to the floor, so a
loosening override looks inert -- but it would take effect the moment the
tenant is reactivated. That deferred loosening is the thing to refuse.
"""
if candidate.is_unlimited:
return not previous.is_unlimited
if previous.is_unlimited:
return False
assert isinstance(previous.amount, int) and isinstance(candidate.amount, int)
return candidate.amount > previous.amount
def _clamp(effective: EffectiveLimit, tenant: Tenant, floor: LimitValue) -> EffectiveLimit:
"""Apply the lifecycle clamp. Applied after precedence, and may only reduce.

View file

@ -0,0 +1,39 @@
from __future__ import annotations
from typing import Any, Mapping
from tenant_engine.guardrail.model import (
UNLIMITED,
InvalidLimitError,
LimitKind,
LimitValue,
)
# The `unlimited` sentinel is serialised as an explicit token, never as NULL
# or a missing field. A stored NULL that meant "unlimited" would make an open
# ceiling the result of absence -- exactly what the contract forbids.
UNLIMITED_TOKEN = "unlimited"
def dump_limit(value: LimitValue) -> dict[str, Any]:
return {
"kind": value.kind.value,
"amount": UNLIMITED_TOKEN if value.is_unlimited else str(value.amount),
"currency": value.currency,
"period": value.period,
}
def load_limit(raw: Mapping[str, Any]) -> LimitValue:
amount_text = raw["amount"]
if amount_text is None:
# A row that lost its amount is not an unlimited tenant. Fail loudly
# rather than resolving to the most permissive possible answer.
raise InvalidLimitError("stored limit has no amount")
amount = UNLIMITED if amount_text == UNLIMITED_TOKEN else int(amount_text)
return LimitValue(
kind=LimitKind(raw["kind"]),
amount=amount,
currency=raw["currency"],
period=raw["period"],
)

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import json
import sqlite3
from collections.abc import Callable
from dataclasses import replace
from datetime import datetime
from threading import RLock
from typing import Any
@ -15,6 +16,13 @@ from tenant_engine.domain import (
TenantLifecycle,
TenantRetiredError,
)
from tenant_engine.guardrail import (
DEFAULT_REGISTRY,
GuardrailChange,
LimitValue,
dump_limit,
load_limit,
)
from tenant_engine.store import (
DomainEvent,
GrantNotFoundError,
@ -22,6 +30,7 @@ from tenant_engine.store import (
TenantAlreadyExistsError,
TenantNotFoundError,
VersionConflictError,
guard_guardrail_write,
)
@ -57,6 +66,18 @@ class SQLiteTenantStore:
recorded_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, idempotency_key)
);
CREATE TABLE IF NOT EXISTS guardrail_overrides (
tenant_id TEXT NOT NULL, limit_key TEXT NOT NULL,
kind TEXT NOT NULL, amount TEXT NOT NULL,
currency TEXT, period TEXT,
PRIMARY KEY (tenant_id, limit_key)
);
CREATE TABLE IF NOT EXISTS guardrail_changes (
change_id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
limit_key TEXT NOT NULL, previous TEXT, current TEXT,
changed_by TEXT NOT NULL, reason TEXT NOT NULL,
correlation_id TEXT NOT NULL, changed_at TEXT NOT NULL
);
""")
self._migrate_tenant_lifecycle()
@ -177,6 +198,150 @@ class SQLiteTenantStore:
self._db.commit()
return updated, False
def guardrail_overrides(self, tenant_id: str) -> dict[str, LimitValue]:
tenant = self.get_tenant(tenant_id)
with self._lock:
rows = self._db.execute(
"SELECT * FROM guardrail_overrides WHERE tenant_id = ?", (tenant.tenant_id,)
).fetchall()
return {row["limit_key"]: load_limit(row) for row in rows}
def guardrail_changes(self, tenant_id: str) -> list[GuardrailChange]:
tenant = self.get_tenant(tenant_id)
with self._lock:
rows = self._db.execute(
"SELECT * FROM guardrail_changes WHERE tenant_id = ? "
"ORDER BY changed_at, change_id",
(tenant.tenant_id,),
).fetchall()
return [
GuardrailChange(
change_id=row["change_id"],
tenant_id=row["tenant_id"],
limit_key=row["limit_key"],
previous=load_limit(json.loads(row["previous"])) if row["previous"] else None,
current=load_limit(json.loads(row["current"])) if row["current"] else None,
changed_by=row["changed_by"],
reason=row["reason"],
correlation_id=row["correlation_id"],
changed_at=datetime.fromisoformat(row["changed_at"]),
)
for row in rows
]
def set_guardrail_override(
self,
*,
tenant_id: str,
expected_version: int,
limit_key: str,
value: LimitValue | None,
change_id: str,
changed_by: str,
reason: str,
correlation_id: str,
idempotency_key: str,
request_fingerprint: str,
at: datetime,
) -> tuple[Tenant, GuardrailChange | None, bool]:
tenant = self.get_tenant(tenant_id)
DEFAULT_REGISTRY.get(limit_key)
with self._lock:
# Same shape as mutate_tenant: BEGIN IMMEDIATE takes the write lock
# before the reads, receipt lookup precedes the version check, and
# override write + audit row + receipt + version bump commit
# together. A crash must never leave a raised ceiling with no
# record of who raised it.
self._db.execute("BEGIN IMMEDIATE")
try:
receipt = self._db.execute(
"SELECT request_fingerprint, result FROM idempotency_receipts "
"WHERE tenant_id = ? AND idempotency_key = ?",
(tenant.tenant_id, idempotency_key),
).fetchone()
if receipt is not None:
if receipt["request_fingerprint"] != request_fingerprint:
raise IdempotencyConflictError(idempotency_key)
replayed = _tenant(json.loads(receipt["result"]))
self._db.rollback()
prior = next(
(c for c in self.guardrail_changes(tenant.tenant_id)
if c.change_id == change_id),
None,
)
return replayed, prior, True
current = _tenant(
self._db.execute(
"SELECT * FROM tenants WHERE tenant_id = ?", (tenant.tenant_id,)
).fetchone()
)
if current.version != expected_version:
raise VersionConflictError(expected=expected_version, actual=current.version)
override_rows = self._db.execute(
"SELECT * FROM guardrail_overrides WHERE tenant_id = ?", (current.tenant_id,)
).fetchall()
overrides = {row["limit_key"]: load_limit(row) for row in override_rows}
guard_guardrail_write(
tenant=current, overrides=overrides, limit_key=limit_key, value=value
)
change = GuardrailChange(
change_id=change_id,
tenant_id=current.tenant_id,
limit_key=limit_key,
previous=overrides.get(limit_key),
current=value,
changed_by=changed_by,
reason=reason,
correlation_id=correlation_id,
changed_at=at,
)
if value is None:
self._db.execute(
"DELETE FROM guardrail_overrides WHERE tenant_id = ? AND limit_key = ?",
(current.tenant_id, limit_key),
)
else:
dumped = dump_limit(value)
self._db.execute(
"INSERT OR REPLACE INTO guardrail_overrides VALUES (?, ?, ?, ?, ?, ?)",
(current.tenant_id, limit_key, dumped["kind"], dumped["amount"],
dumped["currency"], dumped["period"]),
)
self._db.execute(
"INSERT INTO guardrail_changes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(change.change_id, change.tenant_id, change.limit_key,
json.dumps(dump_limit(change.previous)) if change.previous else None,
json.dumps(dump_limit(change.current)) if change.current else None,
change.changed_by, change.reason, change.correlation_id, _iso(at)),
)
updated = replace(current, version=current.version + 1, updated_at=at)
self._db.execute(
"UPDATE tenants SET version = ?, updated_at = ? WHERE tenant_id = ?",
(updated.version, _iso(updated.updated_at), updated.tenant_id),
)
self._db.execute(
"INSERT INTO idempotency_receipts VALUES (?, ?, ?, ?, ?)",
(updated.tenant_id, idempotency_key, request_fingerprint,
json.dumps(_row(updated)), datetime.now().astimezone().isoformat()),
)
self._emit("guardrail_changed", updated.tenant_id, {
"limit_key": limit_key, "change_id": change_id,
"cleared": value is None, "correlation_id": correlation_id,
"version": updated.version,
})
except BaseException:
self._db.rollback()
raise
self._db.commit()
return updated, change, False
def grant_role(self, grant: RoleGrant) -> None:
tenant = self.get_tenant(grant.tenant_id)
self._require_active(tenant, "grant a role")

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from typing import Any, Protocol
@ -13,6 +13,13 @@ from tenant_engine.domain import (
TenantLifecycle,
TenantRetiredError,
)
from tenant_engine.guardrail import (
DEFAULT_REGISTRY,
GuardrailChange,
LimitValue,
is_loosening,
resolve_limit,
)
class TenantNotFoundError(KeyError):
@ -109,6 +116,82 @@ class TenantStore(Protocol):
"""
...
def guardrail_overrides(self, tenant_id: str) -> dict[str, LimitValue]:
"""The tenant's explicit per-tenant overrides (precedence layer 1).
Absence of a key here is not "unlimited" -- it means resolution falls
through to the plan, then the grouping default. See
`guardrail.resolve_limits`.
"""
...
def guardrail_changes(self, tenant_id: str) -> list[GuardrailChange]: ...
def set_guardrail_override(
self,
*,
tenant_id: str,
expected_version: int,
limit_key: str,
value: LimitValue | None,
change_id: str,
changed_by: str,
reason: str,
correlation_id: str,
idempotency_key: str,
request_fingerprint: str,
at: datetime,
) -> tuple[Tenant, GuardrailChange | None, bool]:
"""Set (or, with `value=None`, clear) one override. Returns
`(tenant, change, replayed)`.
Carries the same four concerns as `mutate_tenant` in one transaction --
idempotency replay, version CAS, the override write, and the audit
record -- for the same reason: a crash must never leave a changed
ceiling with no audit record of who raised it.
The tenant's version is bumped, so a guardrail change invalidates the
record's ETag exactly as a metadata edit does. A caller holding a
stale ETag must re-read rather than write blind.
"""
...
def guard_guardrail_write(
*,
tenant: Tenant,
overrides: dict[str, LimitValue],
limit_key: str,
value: LimitValue | None,
) -> None:
"""Refuse a guardrail change that loosens a retired tenant's ceiling.
Follows the TEN-WP-0005 precedent: while retired, operations that only
*reduce* privilege stay available, loosening ones do not. Retirement does
not freeze the record outright -- tightening a retired tenant's limit is
still allowed, because refusing it would be the fail-open choice.
The comparison is made as if the tenant were active. A retired tenant's
effective limits are already clamped to the floor, so every candidate
looks inert today; what matters is the value that would take effect the
moment it is reactivated.
"""
if tenant.lifecycle is TenantLifecycle.ACTIVE:
return
as_active = replace(tenant, lifecycle=TenantLifecycle.ACTIVE)
before = resolve_limit(limit_key, tenant=as_active, overrides=overrides).value
candidate = dict(overrides)
if value is None:
candidate.pop(limit_key, None)
else:
candidate[limit_key] = value
after = resolve_limit(limit_key, tenant=as_active, overrides=candidate).value
if is_loosening(before, after):
raise TenantRetiredError("cannot loosen a guardrail on a retired tenant")
class InMemoryTenantStore:
def __init__(self) -> None:
@ -119,6 +202,8 @@ class InMemoryTenantStore:
self._events: list[DomainEvent] = []
# (tenant_id, idempotency_key) -> (request_fingerprint, result snapshot)
self._receipts: dict[tuple[str, str], tuple[str, Tenant]] = {}
self._overrides: dict[str, dict[str, LimitValue]] = {}
self._guardrail_changes: list[GuardrailChange] = []
def create_tenant(self, tenant: Tenant) -> None:
if tenant.tenant_id in self._tenants:
@ -212,6 +297,88 @@ class InMemoryTenantStore:
self._emit(event_type, resolved, {**evidence, "version": updated.version})
return updated, False
def guardrail_overrides(self, tenant_id: str) -> dict[str, LimitValue]:
return dict(self._overrides.get(self._resolve(tenant_id), {}))
def guardrail_changes(self, tenant_id: str) -> list[GuardrailChange]:
resolved = self._resolve(tenant_id)
return [c for c in self._guardrail_changes if c.tenant_id == resolved]
def set_guardrail_override(
self,
*,
tenant_id: str,
expected_version: int,
limit_key: str,
value: LimitValue | None,
change_id: str,
changed_by: str,
reason: str,
correlation_id: str,
idempotency_key: str,
request_fingerprint: str,
at: datetime,
) -> tuple[Tenant, GuardrailChange | None, bool]:
resolved = self._resolve(tenant_id)
# Registry check first: an unregistered key is an error, not a silent
# write of a ceiling nothing will ever read.
DEFAULT_REGISTRY.get(limit_key)
receipt = self._receipts.get((resolved, idempotency_key))
if receipt is not None:
fingerprint, snapshot = receipt
if fingerprint != request_fingerprint:
raise IdempotencyConflictError(idempotency_key)
replayed_change = next(
(c for c in self._guardrail_changes if c.change_id == change_id), None
)
return snapshot, replayed_change, True
current = self._tenants[resolved]
if current.version != expected_version:
raise VersionConflictError(expected=expected_version, actual=current.version)
overrides = self._overrides.setdefault(resolved, {})
guard_guardrail_write(
tenant=current, overrides=overrides, limit_key=limit_key, value=value
)
previous = overrides.get(limit_key)
change = GuardrailChange(
change_id=change_id,
tenant_id=resolved,
limit_key=limit_key,
previous=previous,
current=value,
changed_by=changed_by,
reason=reason,
correlation_id=correlation_id,
changed_at=at,
)
if value is None:
overrides.pop(limit_key, None)
else:
overrides[limit_key] = value
updated = replace(current, version=current.version + 1, updated_at=at)
self._tenants[resolved] = updated
self._guardrail_changes.append(change)
self._receipts[(resolved, idempotency_key)] = (request_fingerprint, updated)
self._emit(
"guardrail_changed",
resolved,
{
"limit_key": limit_key,
"change_id": change_id,
"cleared": value is None,
"correlation_id": correlation_id,
"version": updated.version,
},
)
return updated, change, False
def _require_active(self, resolved_id: str, what: str) -> None:
tenant = self._tenants[resolved_id]
if tenant.lifecycle is not TenantLifecycle.ACTIVE: