Finish TEN-WP-0006-T04: expose guardrail read and write APIs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-16 02:18:01 +02:00
parent f631224ab5
commit b6d016869f
5 changed files with 677 additions and 10 deletions

View file

@ -229,21 +229,106 @@ by accident.
}
```
Endpoint shapes, headers, error codes, and the flex-auth actions that gate
them are specified in T04 and documented here on completion.
---
## Endpoints
### `GET /tenants/{tenant_id}/guardrails?actor=<actor>`
Every registered key, resolved, with provenance — the shape above. `actor` is
required: the read is authorized, so there is no anonymous caller.
### `PUT /tenants/{tenant_id}/guardrails/{limit_key}`
```http
PUT /tenants/t-1/guardrails/spend.monthly
Idempotency-Key: 4f1c…
If-Match: "1"
{"limit": {"kind": "spend", "amount": "9000", "currency": "EUR", "period": "P1M"},
"actor": "ops", "reason": "raised for pilot", "correlation_id": "corr-1"}
```
`amount` is a **string** so the `unlimited` sentinel and an integer share one
field, and so a spend amount in minor units never round-trips through a float.
### `DELETE /tenants/{tenant_id}/guardrails/{limit_key}`
Same headers; body is `{"actor", "reason", "correlation_id"}`. Clears the
override so resolution falls back to the plan, then the grouping default.
Both mutations return the new effective limit plus `ETag`, `change_id`, and
`Idempotent-Replay: true|false`:
```json
{"tenant_id": "t-1", "limit_key": "spend.monthly", "version": 2,
"change_id": "9f2c…", "cleared": false,
"effective": {"kind": "spend", "amount": 9000, "currency": "EUR",
"period": "P1M", "provenance": "override"}}
```
A guardrail change **bumps the tenant's version**, so it invalidates the
record ETag exactly as a metadata edit does. There is no separately versioned
guardrail resource to race against the tenant record.
`change_id` is derived from `(tenant_id, limit_key, Idempotency-Key)`, not
random, so a genuine retry replays the original audit record rather than
minting a second one for a mutation that happened once.
---
## Authorization
Two **distinct** actions, so policy can give `flex-auth` the read without
giving anything the write:
| Action | Resource type |
|---|---|
| `tenant.guardrail.read` | `guardrail` |
| `tenant.guardrail.set` | `guardrail` |
These extend package `tenant-engine.write-api.mutate` alongside the existing
seven actions. **Both are new and require a policy-package change in
`flex-auth` before this surface functions in production** — until then every
check correctly resolves to deny.
Authorization runs **before** the store is touched on every guardrail route,
so an unauthorized caller cannot use status codes or timing to probe which
tenants exist or which limit keys are registered. A denied read of a real
tenant and a denied read of a nonexistent one are byte-identical.
---
## Errors
| `error_code` | Cause |
|---|---|
| `unknown_limit_key` | key is not in the registry |
| `guardrail_registry_invalid` | startup validation failed — a grouping has no default |
| Status | `error_code` | Cause |
|---|---|---|
| 400 | `idempotency_key_required` | `Idempotency-Key` header missing |
| 400 | `invalid_if_match` | `If-Match` is `*` or not a version ETag |
| 400 | `invalid_limit` | malformed limit — negative amount, spend without currency, wrong shape |
| 403 | `write_denied` | flex-auth denied the action |
| 404 | `tenant_not_found` | unknown tenant |
| 404 | `unknown_limit_key` | key is not in the registry |
| 409 | `version_conflict` | stale `If-Match` — re-read and retry |
| 409 | `idempotency_key_conflict` | key reused for a different request |
| 409 | `guardrail_loosening_denied` | change would raise a retired tenant's ceiling |
| 422 | *(schema)* | unknown body field, or empty `reason`/`correlation_id` |
| 428 | `if_match_required` | `If-Match` header missing |
| 503 | `tenant_authority_unavailable` | store or authority unavailable |
| — | `guardrail_registry_invalid` | startup validation failed — a grouping has no default |
Lifecycle, authorization, concurrency, and idempotency errors are unchanged
from `tenant-lifecycle-api.md`; guardrail writes use the same mutation
contract (`Idempotency-Key`, `If-Match`, actor, reason, correlation id).
`guardrail_registry_invalid` is a **startup** failure, not a response: the
service refuses to come up rather than serving an incomplete registry.
Errors are redacted: never a policy internal, a store path, or a registry
dump in `detail`.
---
## What is not wired yet
The **plan-derived layer has no feed.** Precedence layer 2 exists, resolves,
and is tested, but nothing populates it: `adaptive-pricing` owns plan terms
and does not yet expose plan-derived ceilings. Until it does, a tenant's
limits come from its override or its grouping default. Inventing a plan→limit
mapping here would duplicate terms this repo does not own.

View file

@ -30,6 +30,19 @@ from tenant_engine.domain import (
create_role_grant,
)
from tenant_engine.flex_auth import FlexAuthCheckClient
from tenant_engine.guardrail import (
ConflictingLimitError,
EffectiveLimit,
InvalidLimitError,
LimitKind,
LimitValue,
UnknownLimitKeyError,
dump_limit,
load_limit,
resolve_limit,
resolve_limits,
)
from tenant_engine.guardrail.serde import UNLIMITED_TOKEN
from tenant_engine.store import (
GrantNotFoundError,
IdempotencyConflictError,
@ -101,6 +114,39 @@ class LifecycleRequest(BaseModel):
correlation_id: str = Field(min_length=1)
class GuardrailLimitBody(BaseModel):
"""A ceiling, as a consumer states it.
`amount` is a string so the `unlimited` sentinel and an integer share one
field without a union that JSON would blur -- and so a spend amount in
minor units never round-trips through a float.
"""
model_config = {"extra": "forbid"}
kind: LimitKind
amount: str = Field(min_length=1)
currency: str | None = None
period: str | None = None
class SetGuardrailRequest(BaseModel):
model_config = {"extra": "forbid"}
limit: GuardrailLimitBody
actor: str
reason: str = Field(min_length=1)
correlation_id: str = Field(min_length=1)
class ClearGuardrailRequest(BaseModel):
model_config = {"extra": "forbid"}
actor: str
reason: str = Field(min_length=1)
correlation_id: str = Field(min_length=1)
class LifecycleError(Exception):
"""A lifecycle-endpoint failure rendered in the stable error schema.
@ -339,9 +385,208 @@ def create_app(
mutate=lambda tenant, at: tenant.reactivate(at=at),
)
# -- Guardrail API (TEN-WP-0006) --------------------------------------
# Reading a ceiling and changing one are distinct actions, so policy can
# give flex-auth the read without giving anything the write.
@app.get("/tenants/{tenant_id}/guardrails")
async def read_guardrails(tenant_id: str, actor: str) -> dict:
authorizer.authorize(action="tenant.guardrail.read", tenant_id=tenant_id, actor=actor)
try:
tenant = store.get_tenant(tenant_id)
overrides = store.guardrail_overrides(tenant_id)
except TenantNotFoundError as exc:
raise LifecycleError(404, "tenant_not_found", "tenant_not_found", "") from exc
except StoreUnavailableError as exc:
# Fail closed: a PDP must never read unavailability as "no limits".
raise LifecycleError(
503, "tenant_authority_unavailable", "tenant_authority_unavailable", ""
) from exc
# plan_limits is empty until adaptive-pricing exposes plan-derived
# ceilings. The precedence layer exists and is tested; the feed does
# not, and inventing one here would duplicate plan terms this repo
# does not own.
resolved = resolve_limits(tenant=tenant, overrides=overrides)
return {
"tenant_id": tenant.tenant_id,
"identifier": tenant.identifier,
"lifecycle": tenant.lifecycle.value,
"limits": {key: _limit_response(limit) for key, limit in resolved.items()},
}
@app.put("/tenants/{tenant_id}/guardrails/{limit_key}")
async def set_guardrail(
tenant_id: str,
limit_key: str,
payload: SetGuardrailRequest,
response: Response,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
if_match: str | None = Header(default=None, alias="If-Match"),
) -> dict:
try:
value = load_limit(payload.limit.model_dump())
except (InvalidLimitError, ValueError) as exc:
raise LifecycleError(
400, "invalid_limit", str(exc), payload.correlation_id
) from exc
return _guardrail_mutation(
store=store,
authorizer=authorizer,
tenant_id=tenant_id,
limit_key=limit_key,
value=value,
actor=payload.actor,
reason=payload.reason,
correlation_id=payload.correlation_id,
idempotency_key=idempotency_key,
if_match=if_match,
response=response,
)
@app.delete("/tenants/{tenant_id}/guardrails/{limit_key}")
async def clear_guardrail(
tenant_id: str,
limit_key: str,
payload: ClearGuardrailRequest,
response: Response,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
if_match: str | None = Header(default=None, alias="If-Match"),
) -> dict:
return _guardrail_mutation(
store=store,
authorizer=authorizer,
tenant_id=tenant_id,
limit_key=limit_key,
value=None,
actor=payload.actor,
reason=payload.reason,
correlation_id=payload.correlation_id,
idempotency_key=idempotency_key,
if_match=if_match,
response=response,
)
return app
def _limit_response(limit: EffectiveLimit) -> dict:
return {
"kind": limit.value.kind.value,
"amount": UNLIMITED_TOKEN if limit.value.is_unlimited else limit.value.amount,
"currency": limit.value.currency,
"period": limit.value.period,
"provenance": limit.provenance.value,
}
def _guardrail_mutation(
*,
store: TenantStore,
authorizer: WriteAuthorizer,
tenant_id: str,
limit_key: str,
value: LimitValue | None,
actor: str,
reason: str,
correlation_id: str,
idempotency_key: str | None,
if_match: str | None,
response: Response,
) -> dict:
"""Shared spine for setting and clearing an override.
Mirrors `_lifecycle_mutation`: header checks, then authorization, then the
store. Authorization runs before the store is touched, so an unauthorized
caller cannot probe which tenants exist or which keys are registered.
"""
if idempotency_key is None:
raise LifecycleError(
400, "idempotency_key_required", "Idempotency-Key header is required", correlation_id
)
expected_version = _parse_if_match(if_match, correlation_id)
authorizer.authorize(action="tenant.guardrail.set", tenant_id=tenant_id, actor=actor)
fingerprint = hashlib.sha256(
json.dumps(
{
"action": "tenant.guardrail.set",
"tenant": tenant_id,
"limit_key": limit_key,
"version": expected_version,
"value": dump_limit(value) if value is not None else None,
},
sort_keys=True,
default=str,
).encode()
).hexdigest()
# Derived, not random: a genuine retry must produce the same change_id so
# the replay path returns the original audit record rather than minting a
# second one for a mutation that happened once.
change_id = hashlib.sha256(
f"{tenant_id}:{limit_key}:{idempotency_key}".encode()
).hexdigest()[:32]
try:
tenant, change, replayed = store.set_guardrail_override(
tenant_id=tenant_id,
expected_version=expected_version,
limit_key=limit_key,
value=value,
change_id=change_id,
changed_by=actor,
reason=reason,
correlation_id=correlation_id,
idempotency_key=idempotency_key,
request_fingerprint=fingerprint,
at=datetime.now(UTC),
)
except UnknownLimitKeyError as exc:
raise LifecycleError(404, "unknown_limit_key", "unknown_limit_key", correlation_id) from exc
except TenantNotFoundError as exc:
raise LifecycleError(404, "tenant_not_found", "tenant_not_found", correlation_id) from exc
except IdempotencyConflictError as exc:
raise LifecycleError(
409,
"idempotency_key_conflict",
"Idempotency-Key was reused for a different request",
correlation_id,
) from exc
except VersionConflictError as exc:
raise LifecycleError(
409,
"version_conflict",
f"record version is {exc.actual}, not {exc.expected}",
correlation_id,
) from exc
except TenantRetiredError as exc:
raise LifecycleError(
409, "guardrail_loosening_denied", str(exc), correlation_id
) from exc
except (InvalidLimitError, ConflictingLimitError) as exc:
raise LifecycleError(400, "invalid_limit", str(exc), correlation_id) from exc
except StoreUnavailableError as exc:
raise LifecycleError(
503, "tenant_authority_unavailable", "tenant_authority_unavailable", correlation_id
) from exc
response.headers["ETag"] = _etag(tenant.version)
response.headers["Idempotent-Replay"] = "true" if replayed else "false"
effective = resolve_limit(
limit_key, tenant=tenant, overrides=store.guardrail_overrides(tenant.tenant_id)
)
return {
"tenant_id": tenant.tenant_id,
"limit_key": limit_key,
"version": tenant.version,
"change_id": change.change_id if change else None,
"cleared": value is None,
"effective": _limit_response(effective),
}
def _etag(version: int) -> str:
return f'"{version}"'

View file

@ -17,6 +17,10 @@ _RESOURCE_TYPES: dict[str, str] = {
"tenant.update": "tenant",
"tenant.retire": "tenant",
"tenant.reactivate": "tenant",
# TEN-WP-0006: reading a ceiling and changing one are separate privileges.
# A PDP needs the read; almost nothing needs the write.
"tenant.guardrail.read": "guardrail",
"tenant.guardrail.set": "guardrail",
}

View file

@ -0,0 +1,292 @@
"""TEN-WP-0006-T04: HTTP contract for the guardrail surface."""
import pytest
from fastapi.testclient import TestClient
from tenant_engine.app import create_app
from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer
from tenant_engine.store import InMemoryTenantStore, StoreUnavailableError
KEY = "spend.monthly"
HEADERS = {"Idempotency-Key": "idem-1", "If-Match": '"1"'}
LIMIT = {"kind": "spend", "amount": "9000", "currency": "EUR", "period": "P1M"}
BODY = {"actor": "ops", "reason": "raised for pilot", "correlation_id": "corr-1"}
class _AllowAllAuthorizer(WriteAuthorizer):
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
return None
class _ScopedAuthorizer(WriteAuthorizer):
def __init__(self, *allowed: str) -> None:
self._allowed = set(allowed)
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
if action not in self._allowed:
raise WriteAuthorizationDeniedError(action, "not permitted")
class _BrokenStore(InMemoryTenantStore):
def get_tenant(self, tenant_id: str):
raise StoreUnavailableError("connection to /var/lib/tenant-engine/tenant.db refused")
def make_client(authorizer=None, store=None) -> TestClient:
app = create_app(
store=store or InMemoryTenantStore(), authorizer=authorizer or _AllowAllAuthorizer()
)
client = TestClient(app)
client.post(
"/tenants",
json={"tenant_id": "t-1", "identifier": "tenant:small:acme", "actor": "ops"},
)
return client
@pytest.fixture
def client() -> TestClient:
return make_client()
def read(client, actor="flex-auth"):
return client.get("/tenants/t-1/guardrails", params={"actor": actor})
def put(client, *, limit=None, headers=None, body=None, key=KEY):
return client.put(
f"/tenants/t-1/guardrails/{key}",
json={"limit": limit or LIMIT, **(body or BODY)},
headers=headers or HEADERS,
)
# --- Read -----------------------------------------------------------------
def test_read_returns_effective_limits_with_provenance(client):
response = read(client)
assert response.status_code == 200
limit = response.json()["limits"][KEY]
assert limit["provenance"] == "grouping"
assert limit["amount"] == 25_000
assert limit["currency"] == "EUR"
def test_a_trial_tenant_reads_a_zero_spend_ceiling(client):
client.post(
"/tenants",
json={"tenant_id": "t-2", "identifier": "tenant:trial:pilot", "actor": "ops"},
)
response = client.get("/tenants/t-2/guardrails", params={"actor": "flex-auth"})
assert response.json()["limits"][KEY]["amount"] == 0
def test_read_is_authorized_separately_from_write():
# a PDP gets the read and nothing else
client = make_client(_ScopedAuthorizer("tenant.create", "tenant.guardrail.read"))
assert read(client).status_code == 200
assert put(client).status_code == 403
def test_write_permission_does_not_confer_read_permission():
client = make_client(_ScopedAuthorizer("tenant.create", "tenant.guardrail.set"))
assert read(client).status_code == 403
assert put(client).status_code == 200
def test_an_unauthorized_read_cannot_probe_tenant_existence():
client = make_client(_ScopedAuthorizer("tenant.create"))
known = client.get("/tenants/t-1/guardrails", params={"actor": "nobody"})
unknown = client.get("/tenants/t-404/guardrails", params={"actor": "nobody"})
assert known.status_code == unknown.status_code == 403
assert known.json() == unknown.json()
def test_a_store_outage_fails_closed_on_read():
client = make_client(store=_BrokenStore())
response = read(client)
assert response.status_code == 503
assert response.json()["error_code"] == "tenant_authority_unavailable"
assert "tenant.db" not in response.text
# --- Write ----------------------------------------------------------------
def test_setting_an_override_returns_the_new_effective_limit(client):
response = put(client)
assert response.status_code == 200
body = response.json()
assert body["effective"]["amount"] == 9_000
assert body["effective"]["provenance"] == "override"
assert body["version"] == 2
assert response.headers["ETag"] == '"2"'
assert response.headers["Idempotent-Replay"] == "false"
def test_the_override_shows_up_in_a_subsequent_read(client):
put(client)
assert read(client).json()["limits"][KEY]["provenance"] == "override"
def test_clearing_falls_back_to_the_grouping_default(client):
put(client)
response = client.request(
"DELETE",
f"/tenants/t-1/guardrails/{KEY}",
json=BODY,
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
)
assert response.status_code == 200
assert response.json()["cleared"] is True
assert response.json()["effective"]["provenance"] == "grouping"
def test_replay_returns_the_same_result_without_reapplying(client):
first = put(client)
second = put(client)
assert second.headers["Idempotent-Replay"] == "true"
assert second.json()["version"] == first.json()["version"] == 2
assert second.json()["change_id"] == first.json()["change_id"]
def test_reusing_a_key_for_a_different_limit_conflicts(client):
put(client)
response = put(
client,
limit={**LIMIT, "amount": "1000"},
headers={"Idempotency-Key": "idem-1", "If-Match": '"2"'},
)
assert response.status_code == 409
assert response.json()["error_code"] == "idempotency_key_conflict"
def test_a_stale_if_match_conflicts(client):
put(client)
response = put(client, headers={"Idempotency-Key": "idem-2", "If-Match": '"1"'})
assert response.status_code == 409
assert response.json()["error_code"] == "version_conflict"
def test_if_match_and_idempotency_key_are_required(client):
assert put(client, headers={"Idempotency-Key": "idem-9"}).status_code == 428
missing_key = put(client, headers={"If-Match": '"1"'})
assert missing_key.status_code == 400
assert missing_key.json()["error_code"] == "idempotency_key_required"
def test_if_match_star_is_rejected(client):
response = put(client, headers={"Idempotency-Key": "idem-9", "If-Match": "*"})
assert response.status_code == 400
assert response.json()["error_code"] == "invalid_if_match"
def test_an_unregistered_limit_key_is_rejected(client):
response = put(client, key="spend.weekly")
assert response.status_code == 404
assert response.json()["error_code"] == "unknown_limit_key"
def test_a_malformed_limit_is_rejected(client):
# a spend limit without a currency is not a spend limit
response = put(client, limit={"kind": "spend", "amount": "100", "period": "P1M"})
assert response.status_code == 400
assert response.json()["error_code"] == "invalid_limit"
def test_a_negative_amount_is_rejected(client):
response = put(client, limit={**LIMIT, "amount": "-1"})
assert response.status_code == 400
def test_unknown_body_fields_are_rejected_by_the_schema(client):
response = client.put(
f"/tenants/t-1/guardrails/{KEY}",
json={"limit": LIMIT, **BODY, "surprise": 1},
headers=HEADERS,
)
assert response.status_code == 422
def test_reason_and_correlation_id_are_required(client):
response = client.put(
f"/tenants/t-1/guardrails/{KEY}",
json={"limit": LIMIT, "actor": "ops", "reason": "", "correlation_id": "c"},
headers=HEADERS,
)
assert response.status_code == 422
def test_an_unlimited_override_must_be_stated_explicitly(client):
response = put(client, limit={**LIMIT, "amount": "unlimited"})
assert response.status_code == 200
assert response.json()["effective"]["amount"] == "unlimited"
def test_an_unknown_tenant_is_not_found(client):
response = client.put(
f"/tenants/t-404/guardrails/{KEY}",
json={"limit": LIMIT, **BODY},
headers=HEADERS,
)
assert response.status_code == 404
assert response.json()["error_code"] == "tenant_not_found"
# --- Retired tenants ------------------------------------------------------
def retire(client):
return client.post(
"/tenants/t-1/retire",
json={"actor": "ops", "reason": "test", "correlation_id": "corr-r"},
headers={"Idempotency-Key": "retire-1", "If-Match": '"1"'},
)
def test_a_retired_tenant_still_reads_its_guardrails(client):
retire(client)
response = read(client)
assert response.status_code == 200
assert response.json()["limits"][KEY]["provenance"] == "lifecycle"
assert response.json()["limits"][KEY]["amount"] == 0
def test_loosening_a_retired_tenants_guardrail_is_refused(client):
retire(client)
# 90 000 is above small's 25 000 default -- inert while retired, but it
# would take effect the moment the tenant is reactivated
response = put(
client,
limit={**LIMIT, "amount": "90000"},
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
)
assert response.status_code == 409
assert response.json()["error_code"] == "guardrail_loosening_denied"
def test_tightening_a_retired_tenants_guardrail_is_allowed(client):
retire(client)
response = put(
client,
limit={**LIMIT, "amount": "100"},
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
)
assert response.status_code == 200
# --- Compatibility --------------------------------------------------------
def test_existing_endpoints_are_unaffected(client):
assert client.get("/tenants/t-1").status_code == 200
assert client.get("/tenants/t-1/roles").status_code == 200
assert client.get("/tenants/t-1/roles/live").status_code == 200
assert client.get("/health").status_code == 200
def test_the_guardrail_routes_are_in_the_openapi_document(client):
paths = client.get("/openapi.json").json()["paths"]
assert "/tenants/{tenant_id}/guardrails" in paths
assert "/tenants/{tenant_id}/guardrails/{limit_key}" in paths

View file

@ -235,7 +235,7 @@ absence — the one thing the contract forbids.
```task
id: TEN-WP-0006-T04
status: todo
status: done
priority: high
state_hub_task_id: "4a256517-2773-4679-ad57-2909f22ac8a4"
```
@ -263,6 +263,47 @@ TEN-WP-0005-T05.
Done when the routes are authorized, versioned, idempotent, correlated, and
provider-neutral, and the OpenAPI document makes the semantics unambiguous.
Done 2026-08-16. Three routes: `GET /tenants/{id}/guardrails` (effective
limits plus provenance), `PUT` and `DELETE
/tenants/{id}/guardrails/{limit_key}`. 26 API tests; 227 total, all passing.
Contract documented in `docs/tenant-guardrail-policy.md`.
**Required flex-auth actions — the tracked handoff this task exists to make
explicit:**
| Action | Resource type | Purpose |
| --- | --- | --- |
| `tenant.guardrail.read` | `guardrail` | read a tenant's effective ceilings |
| `tenant.guardrail.set` | `guardrail` | set or clear a per-tenant override |
Both are **new** and need a policy-package change in `flex-auth` before this
surface functions in production. Until then every check correctly resolves to
deny. See the regression note below — these land on a package that is
currently *behind*, not merely one that needs extending.
Decisions:
- **The read is authorized too**, unlike the existing `/roles` and
`/roles/live` reads. A ceiling is policy about a tenant's commercial
exposure, not a capability claim, and the task's whole point was that policy
must be able to permit reading a limit without permitting a change. `actor`
is a required query parameter, so there is no anonymous caller. Tested both
ways: read-only permission cannot write, write-only permission cannot read.
- **`amount` is a string on the wire.** It lets the `unlimited` sentinel and
an integer share one field without a JSON union, and keeps a spend amount in
minor units from ever round-tripping through a float.
- **`change_id` is derived** from `(tenant_id, limit_key, Idempotency-Key)`
rather than random, so a genuine retry replays the original audit record
instead of minting a second one for a mutation that happened once.
- **Authorization precedes every store touch**, so a denied read of a real
tenant and of a nonexistent one are byte-identical — no probing existence or
registry contents through status codes.
- A retired tenant's refused loosening gets its own code,
`guardrail_loosening_denied`, rather than reusing
`invalid_lifecycle_transition`: the tenant's lifecycle is not in transition,
and a caller should be able to tell "your tenant is retired, tighten instead"
from "your retire/reactivate call was invalid".
## T05 - Conformance and consumer handoff
```task