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

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