Plan mutable grouping (TEN-WP-0010)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-17 21:41:05 +02:00
parent 5a9e78f657
commit 20979040b2

View file

@ -0,0 +1,227 @@
---
id: TEN-WP-0010
type: workplan
title: "Make tenant grouping mutable and the identifier segment historical"
domain: infotech
repo: tenant-engine
status: ready
owner: claude
topic_slug: tenant-guardrails
created: "2026-08-17"
updated: "2026-08-17"
depends_on:
- TEN-WP-0006
unblocks: []
---
# TEN-WP-0010 - Mutable grouping
`tenant:<grouping>:<name>` embeds a **mutable** attribute in an **immutable**
identifier. Several groupings are headcount bands — `small` ≤10, `medium` ≤100,
`large` ≤1000 — and headcount changes.
Raised by `rapp-postgres` while researching identifier design, and correctly
handed to us rather than folded into their framework: the identifier format is
NetKingdom canon's (ADR-0013) and the consequences are ours.
## Why this is now urgent rather than tidy
Until last week grouping was a classification label and a stale one was a
cosmetic problem. **TEN-WP-0006 made grouping load-bearing for money.**
Guardrail spend ceilings resolve from grouping:
| Grouping | `spend.monthly` |
|---|---|
| `trial` | **0** |
| `small` | €250 |
| `enterprise` | €20 000 |
So a frozen grouping now means a tenant onboarded as `small` keeps a €250
ceiling after growing past a thousand people, and a tenant onboarded as `trial`
keeps a **zero** spend ceiling permanently. That is a commercial defect, it is
live in production as of TEN-WP-0007, and it gets more expensive per tenant
onboarded.
Renaming is not the fix and will not be attempted. The identifier is the IAM
Profile `tenant` claim value that `key-cape` mints into tokens, it is the key
shared with `user-engine`, and it appears in OpenBao paths. Renaming is an
estate-wide migration.
## The fix, and why it is small
`Tenant.grouping` is **already a separate field** on the record, parsed from
the identifier once at creation. Nothing needs to be added to the model and
nothing needs to be renamed. Four changes:
1. The identifier's middle segment is **historical** — onboarding-time,
immutable, and explicitly *not* authoritative for current grouping.
2. The grouping **field** becomes mutable through an audited mutation, in the
same shape as the lifecycle operations.
3. Guardrail resolution already reads the field, so it picks up a change with
no change on the resolution path.
4. ADR-0013 states that no consumer may parse grouping from the identifier and
treat it as current.
Point 4 is the one with fleet reach, and it is why this cannot be done quietly:
**any consumer parsing the grouping segment today is reading a value that is
about to start going stale, and does not know it.**
## T01 - Propose the ADR-0013 amendment to NetKingdom canon
```task
id: TEN-WP-0010-T01
status: todo
priority: high
```
The identifier format is canon's, not ours. Propose rather than decide.
The amendment should state which of the two readings is intended — grouping
frozen at onboarding, or grouping as current classification — and we are
arguing for the second with the identifier segment demoted to historical.
State the guardrail consequence plainly, because it is what makes the choice
non-neutral: under the frozen reading, canon is choosing that a growing tenant
keeps its onboarding spend ceiling forever, and somebody should choose that
knowingly rather than inherit it.
Include the explicit prohibition on parsing grouping from the identifier, and
that `parse_tenant_identifier` keeps validating the segment at creation —
demoting it to historical does not make it free-form.
Done when the amendment is proposed and its ratification status is recorded
here. **Implementation may proceed in parallel** — the field already exists and
making our own record mutable does not require canon's permission — but the
prohibition in point 4 is not fleet-binding until ratified.
## T02 - Find out who parses the grouping segment
```task
id: TEN-WP-0010-T02
status: todo
priority: high
```
Survey the estate for consumers that split the identifier and read
`parts[1]`. Known parsers of the identifier exist by construction — the format
is canon and the string is everywhere.
Candidates worth checking specifically: `key-cape` (mints the claim),
`user-engine` (shares the key, has an operator UI that may display a grouping),
`flex-auth` (policy conditions could match on it), `adaptive-pricing` (plan
eligibility by grouping would be the obvious use), and OpenBao path
construction.
`rapp-postgres` is confirmed unaffected — ADR-0001 forbids them from parsing
tenant identifiers.
For each parser found: it is not necessarily wrong to read the segment, but it
must be reading it as *onboarding-time*, not as *current*. Anything using it
for a live decision needs to call us instead.
Done when the list is complete and each parser has either confirmed
onboarding-time semantics or been given the read API to use instead.
## T03 - Implement the audited grouping mutation
```task
id: TEN-WP-0010-T03
status: todo
priority: high
```
Add `POST /tenants/{tenant_id}/grouping` — deliberately **not** an addition to
`MUTABLE_METADATA_FIELDS`. `display_name` and `contact_email` are cosmetic;
grouping determines spend ceilings. A change that moves money must not travel
the same path as a rename, must not share its authorization action, and must
be visible as its own thing in the audit trail.
It fits the existing machinery: `mutate_tenant()` already takes a
`Callable[[Tenant], Tenant]`, so this is a new domain method plus a route, not
new store surface.
- validate the target against `GROUPINGS`;
- **reject on reserved identifiers**`tenant:platform` and `tenant:coulomb`
are ungrouped (`grouping is None`) and resolve guardrails through the
reserved profile; giving them a grouping would silently move them onto the
grouping ladder;
- reject a no-op, consistent with `with_metadata`;
- require `Idempotency-Key`, `If-Match`, actor, reason, correlation id;
- new flex-auth action `tenant.grouping.set`, resource type `tenant`.
That action is a policy-package change outside this repo — the same tracked
handoff as TEN-WP-0006-T04. It takes `tenant-engine.write-api.mutate` from nine
actions to ten.
**Retired tenants:** follow the TEN-WP-0005 reduce-privilege precedent. A
grouping change alters the ceiling, so the same asymmetry applies as for
guardrail overrides — a change that would loosen the resolved ceiling on
reactivation is refused, a tightening one is allowed. `guard_guardrail_write`
already computes exactly this comparison; reuse it rather than writing a second
notion of "loosening" that can drift from the first.
Done when the mutation is authorized, versioned, idempotent, correlated, and
audited, with the reserved-identifier and retired-tenant cases tested.
## T04 - Interactions with guardrails and grants
```task
id: TEN-WP-0010-T04
status: todo
priority: high
```
Two places where grouping is load-bearing beyond the ceiling table.
**Guardrails.** A grouping change silently changes every grouping-derived
ceiling. Prove: the effective limit follows the new grouping; an existing
per-tenant override still wins (precedence is unchanged); provenance still
reads `grouping` after the move; and the audit trail lets someone reconstruct
why a ceiling changed on a date when no guardrail write happened. That last one
is the reason this task exists — a ceiling that moves with no guardrail event
against it is exactly the kind of thing that looks like a bug at 2am.
Consider whether the grouping mutation should emit a guardrail-visible event as
well as a tenant event, so the ceiling's history is complete from either side.
**Grants.** `create_role_grant` restricts `platform_default` grants to
`trial`-grouped or reserved tenants (ADR-0014). So a tenant moving *off*
`trial` can no longer receive new `platform_default` grants, while its existing
ones remain — correctly, since the trail is append-only. Test that the existing
grants survive untouched and that the next `platform_default` attempt is
refused with a comprehensible error rather than an invariant violation.
Done when the interactions are covered in the conformance suites over all
backends.
## T05 - Ship, and tell the consumers
```task
id: TEN-WP-0010-T05
status: todo
priority: medium
```
Contract documentation, then the handoff: `flex-auth` needs
`tenant.grouping.set` in the policy package; every parser found in T02 needs to
know the segment is historical; `adaptive-pricing` needs to know grouping can
now change under a tenant if plan eligibility depends on it.
Rollout is a separate operator-authorized step, per TEN-WP-0007. Do not deploy
as part of this workplan.
Done when the contract is documented and the notes are sent.
## Out of scope
- **Renaming tenants.** Not now, not as a fallback. The identifier stays
immutable.
- **Automatic reclassification.** Nothing here infers a grouping from headcount
or usage. The mutation is deliberate, audited, and authorized — an automatic
version would move spend ceilings with no actor to attribute it to.
- **Revisiting the ceiling values.** The non-`trial` defaults remain
conservative openers pending product sign-off (TEN-WP-0006-T01). This
workplan makes the *grouping* correct; whether €250 is right for `small` is a
separate question.
- **Backfill.** Every existing tenant's field already matches its identifier
segment, so there is nothing to migrate — the two only diverge once someone
uses the new mutation.