diff --git a/docs/tenant-guardrail-policy.md b/docs/tenant-guardrail-policy.md index 7af8294..9d67ade 100644 --- a/docs/tenant-guardrail-policy.md +++ b/docs/tenant-guardrail-policy.md @@ -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=` + +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. diff --git a/src/tenant_engine/app.py b/src/tenant_engine/app.py index 4ece03f..3e23d98 100644 --- a/src/tenant_engine/app.py +++ b/src/tenant_engine/app.py @@ -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}"' diff --git a/src/tenant_engine/authz.py b/src/tenant_engine/authz.py index a92ca4a..d677303 100644 --- a/src/tenant_engine/authz.py +++ b/src/tenant_engine/authz.py @@ -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", } diff --git a/tests/test_api_guardrails.py b/tests/test_api_guardrails.py new file mode 100644 index 0000000..77b8f59 --- /dev/null +++ b/tests/test_api_guardrails.py @@ -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 diff --git a/workplans/TEN-WP-0006-guardrail-quota-policy.md b/workplans/TEN-WP-0006-guardrail-quota-policy.md index 83599f0..37aa2ce 100644 --- a/workplans/TEN-WP-0006-guardrail-quota-policy.md +++ b/workplans/TEN-WP-0006-guardrail-quota-policy.md @@ -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