From bdf9f5f6438ac1b2137abdc072ec2a1ccdd17934 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 17 Aug 2026 22:53:10 +0200 Subject: [PATCH] Finish TEN-WP-0010: mutable grouping, contract corrected, handoffs sent Co-Authored-By: Claude Opus 5 --- WORK-RECORDS.md | 4 +- docs/tenant-guardrail-policy.md | 21 +++++++++ docs/tenant-lifecycle-api.md | 54 +++++++++++++++++++++-- workplans/TEN-WP-0010-mutable-grouping.md | 37 +++++++++++++++- 4 files changed, 108 insertions(+), 8 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index fc40ecc..29de0bb 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -63,6 +63,6 @@ | task | TEN-WP-0009-T06 | todo | — | workplans/TEN-WP-0009-postgres-production-store.md | | task | TEN-WP-0010-T01 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md | | task | TEN-WP-0010-T02 | done | — | workplans/TEN-WP-0010-mutable-grouping.md | -| task | TEN-WP-0010-T03 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md | -| task | TEN-WP-0010-T04 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md | +| task | TEN-WP-0010-T03 | done | — | workplans/TEN-WP-0010-mutable-grouping.md | +| task | TEN-WP-0010-T04 | done | — | workplans/TEN-WP-0010-mutable-grouping.md | | task | TEN-WP-0010-T05 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md | diff --git a/docs/tenant-guardrail-policy.md b/docs/tenant-guardrail-policy.md index 9d67ade..62460a7 100644 --- a/docs/tenant-guardrail-policy.md +++ b/docs/tenant-guardrail-policy.md @@ -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 diff --git a/docs/tenant-lifecycle-api.md b/docs/tenant-lifecycle-api.md index a8b0e71..500cf84 100644 --- a/docs/tenant-lifecycle-api.md +++ b/docs/tenant-lifecycle-api.md @@ -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 diff --git a/workplans/TEN-WP-0010-mutable-grouping.md b/workplans/TEN-WP-0010-mutable-grouping.md index 6642505..38fcf36 100644 --- a/workplans/TEN-WP-0010-mutable-grouping.md +++ b/workplans/TEN-WP-0010-mutable-grouping.md @@ -4,7 +4,7 @@ type: workplan title: "Make tenant grouping mutable and the identifier segment historical" domain: infotech repo: tenant-engine -status: ready +status: finished owner: claude topic_slug: tenant-guardrails created: "2026-08-17" @@ -302,7 +302,7 @@ backend to diverge on. ```task id: TEN-WP-0010-T05 -status: todo +status: done priority: medium state_hub_task_id: "072cddc6-762a-4efc-8176-31176b9fded6" ``` @@ -317,6 +317,39 @@ as part of this workplan. Done when the contract is documented and the notes are sent. +Done 2026-08-17. + +**Documentation.** `docs/tenant-lifecycle-api.md` gains a reclassification +section — and a **correction**, because its Immutability section asserted +`grouping` was immutable, which this workplan made false. Correcting a +statement a consumer built against matters more than adding the new one. +`docs/tenant-guardrail-policy.md` gains the consequence: a ceiling can now move +with no guardrail write against it, and where the audit record lives. + +**Handoffs sent.** + +| To | Ask | +| --- | --- | +| `flex-auth` | add `tenant.grouping.set` (nine actions → ten) | +| `adaptive-pricing` | grouping now moves; eligibility keyed on it has a transition to handle | +| `user-engine` | contract correction, plus the new route for the operator UI | + +Each carried the point most likely to bite that specific consumer rather than +the same text three times. For `user-engine` that is the identifier/field +divergence appearing in a **template** — our survey greps source confidently +and templates much less so. For `adaptive-pricing` it is that a +reclassification is a commercial event on their side even though it happened on +ours, plus the standing offer on the plan-derived guardrail layer, which exists +in the resolver and still has no feed. For `flex-auth` it is why this is not +`tenant.update`: a subject permitted to rename must not thereby be permitted to +raise a spend ceiling by two orders of magnitude. + +Also told `adaptive-pricing` that the non-`trial` ceiling values remain +unsigned-off, since a tier making claims that map to those numbers would +otherwise inherit our provisional openers as though they were ratified. + +Not deployed, per the task. + ## Out of scope - **Renaming tenants.** Not now, not as a fallback. The identifier stays