Finish TEN-WP-0010: mutable grouping, contract corrected, handoffs sent

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-17 22:53:10 +02:00
parent 2e1c4e15e6
commit bdf9f5f643
4 changed files with 108 additions and 8 deletions

View file

@ -145,6 +145,27 @@ deployment's canonical currency (`GUARDRAIL_CURRENCY`, default `EUR`).
budget far faster than a human operator can notice, so its default ceiling is
set for the blast radius, not the buying power.
### Grouping can change (TEN-WP-0010)
A tenant's grouping is **mutable**, through
`POST /tenants/{id}/grouping` (action `tenant.grouping.set`). Reclassifying a
tenant moves every grouping-derived ceiling with it — a `trial` tenant
reclassified to `medium` goes from a zero spend budget to €1 000.
Two consequences for anyone reading guardrails:
- **A ceiling can change with no guardrail write against it.** The audit record
is the `tenant_grouping_changed` event on the tenant, not an entry in the
guardrail change log. That is deliberate: a guardrail trail containing
entries no guardrail write produced would be worse than one that is complete
but needs two event types read together.
- **Precedence is unchanged.** A per-tenant override still wins over the new
grouping default. Reclassification moves the grouping layer only.
Grouping is read from the tenant record, never parsed from the identifier's
middle segment — that segment is historical, and the two are expected to
diverge after a reclassification.
### Currency
Spend limits carry their currency explicitly. Because precedence resolves

View file

@ -39,15 +39,60 @@ state — those stay exactly as retirement left them.
## Immutability
`tenant_id`, `identifier`, and `grouping` are immutable. The identifier is the
IAM Profile `tenant` claim value that `key-cape` mints into tokens and
`flex-auth` authorizes against; mutating it would silently invalidate every
issued token that references it.
`tenant_id` and `identifier` are immutable. The identifier is the IAM Profile
`tenant` claim value that `key-cape` mints into tokens and `flex-auth`
authorizes against; mutating it would silently invalidate every issued token
that references it.
Mutable metadata is exactly: `display_name`, `contact_email`. Unknown fields
are rejected by the request schema (`extra: forbid`), so the allow-list is
visible in the OpenAPI document rather than discovered from a 400.
**`grouping` is mutable as of TEN-WP-0010**, but not through `PATCH` — see
below. This document previously said it was immutable; that is no longer true.
---
## Reclassification (`grouping`)
### `POST /tenants/{tenant_id}/grouping`
```http
POST /tenants/t-1/grouping
Idempotency-Key: 4f1c…
If-Match: "1"
{"grouping": "large", "actor": "ops",
"reason": "grew past the band", "correlation_id": "corr-1"}
```
Returns the full tenant record, `ETag`, and `Idempotent-Replay`, like the other
mutations.
**Why this is not a `PATCH` field.** `display_name` and `contact_email` are
cosmetic. `grouping` resolves spend ceilings (see
`tenant-guardrail-policy.md`), so a reclassification moves money. It gets its
own route, its own flex-auth action `tenant.grouping.set`, and its own audit
event `tenant_grouping_changed`, so policy can permit a rename without
permitting a reclassification and the audit trail shows which one happened.
**The identifier's grouping segment is historical.** A tenant created as
`tenant:small:acme` and reclassified to `large` keeps the identifier
`tenant:small:acme` and reports `"grouping": "large"`. The two are *expected*
to diverge.
> **Do not parse grouping out of the identifier.** The segment records the
> grouping at onboarding and is not authoritative afterwards. Read the
> `grouping` field from this API. (ADR-0013 amendment proposed under
> TEN-WP-0010; the identifier itself remains immutable either way.)
| Rule | Behaviour |
|---|---|
| unknown grouping | `400 invalid_grouping` |
| no-op | `400 invalid_update` |
| reserved tenant (`tenant:platform`, `tenant:coulomb`) | `400` — ungrouped by design; they resolve guardrails through the reserved profile |
| retired tenant | `409 invalid_lifecycle_transition` — reactivate first |
---
## Endpoints
@ -163,6 +208,7 @@ thereby granting a retirement:
| `tenant.update` | `tenant` |
| `tenant.retire` | `tenant` |
| `tenant.reactivate` | `tenant` |
| `tenant.grouping.set` | `tenant` |
These sit alongside the existing `tenant.create`, `tenant.role.grant`,
`tenant.role.revoke`, and `tenant.plan.assign` actions in package