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:
parent
f631224ab5
commit
b6d016869f
5 changed files with 677 additions and 10 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue