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.