tenant-engine/workplans/TEN-WP-0010-mutable-grouping.md
2026-08-17 22:53:52 +02:00

372 lines
No EOL
17 KiB
Markdown

---
id: TEN-WP-0010
type: workplan
title: "Make tenant grouping mutable and the identifier segment historical"
domain: infotech
repo: tenant-engine
status: finished
owner: claude
topic_slug: tenant-guardrails
created: "2026-08-17"
updated: "2026-08-17"
depends_on:
- TEN-WP-0006
unblocks: []
state_hub_workstream_id: "956d202f-e69d-4786-9525-6acf6e93116c"
---
# 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: done
priority: high
state_hub_task_id: "374626c0-5dad-458f-a595-e4e06fe47017"
```
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.
Status 2026-08-17: **proposed to `net-kingdom`, awaiting ratification.**
Argued for the current-classification reading, with the guardrail consequence
stated plainly so the alternative is chosen knowingly rather than inherited.
Carried T02's survey as evidence that the prohibition breaks nothing on day
one, and its limits so the evidence is not overread. Offered the frozen reading
as a real option, with the exception path it would then require.
Implementation proceeds in parallel per the note above.
Marked done because this task was to *propose* and record status, both of
which are complete. **Ratification itself is outstanding and is canon's, not
ours** — until `net-kingdom` rules, point 4 binds this repo's own behaviour but
not the fleet's. If they choose the frozen reading instead, the mutation built
here stays correct and the guardrail exception path becomes a new workplan.
## T02 - Find out who parses the grouping segment
```task
id: TEN-WP-0010-T02
status: done
priority: high
state_hub_task_id: "d98353d6-3116-4dc8-927a-8951889169cb"
```
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.
Done 2026-08-17. **Nobody parses the grouping segment. The list is empty.**
Swept every local checkout for identifier splitting, `parts[1]` indexing,
`GROUPINGS`-style vocabularies, and literal `tenant:<grouping>:` strings, across
Python, Go, TypeScript, Rust and Rego. Findings:
| Consumer | Verdict |
|---|---|
| `key-cape` | opaque. `Tenant` is a plain `string` field on the domain model; no `strings.Split` touches it. The two hits are doc comments citing the format. |
| `user-engine` | opaque. The one `parts[1]` is **URL path** splitting in `_tenant_lifecycle_route`, matching `retire`/`reactivate` — not identifier splitting. Checked because it was the most likely false positive, and it was one. |
| `flex-auth` | no hits at all. Consistent with treating the tenant as an opaque subject. |
| `adaptive-pricing` | no hits. Plan eligibility by grouping was the obvious risk and it does not exist. |
| `audit-core`, `net-kingdom` configs | whole identifiers used as opaque values in allowlists and fixtures. |
| `rapp-postgres` | confirmed unaffected by them; ADR-0001 forbids parsing. |
**OpenBao paths use the *name* segment, not grouping**`ops-warden`'s
delegation register shows custody at `tenants/binky/company-email/imap`. The
name segment is stable under this change, so those paths are unaffected.
Consequence for T01: the amendment **codifies existing behaviour** rather than
requiring anyone to change. That materially lowers its risk and should be said
plainly when proposing it — a prohibition nobody is currently violating is far
easier to ratify than one that breaks a live consumer.
Honest limit on this evidence: it covers repos checked out on this workstation
and searched by source pattern. It would not catch a consumer that reconstructs
the grouping from a stored copy, one whose repo is not present here, or one
written since. The prohibition in T01 is what makes the guarantee durable;
this survey only establishes that nothing breaks on day one.
## T03 - Implement the audited grouping mutation
```task
id: TEN-WP-0010-T03
status: done
priority: high
state_hub_task_id: "b6c0c54f-a961-412b-b2a8-c39e7a6d48c1"
```
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.
Done 2026-08-17. `Tenant.with_grouping()` plus `POST /tenants/{id}/grouping`,
routed through the existing `_lifecycle_mutation` spine — no new store surface,
as predicted, because `mutate_tenant()` already takes a callable. 22 new tests,
253 total, all passing.
**Deviation from this task as written: retired tenants cannot be reclassified
at all.** The task said to allow tightening and refuse loosening, reusing
`guard_guardrail_write`. On implementation that is the wrong call, for two
reasons. A retired tenant's guardrails already clamp to the floor, so a
reclassification changes nothing until reactivation — there is no operational
need being served. And `with_metadata` already refuses outright on a retired
tenant, so allowing a *ceiling-moving* change where a rename is refused would
be the inconsistency, not the safety. Refusing is the more restrictive
behaviour and loses nothing: reactivate, reclassify, and the ordinary rules
apply. The reduce-privilege exception stays where it earns its keep — role
revocation and guardrail tightening, both of which only ever reduce.
Other decisions:
- **`invalid_grouping` is a distinct error code** from `invalid_update`, so a
caller can tell "that is not a grouping" from "nothing changed".
`InvalidTenantIdentifierError` was previously uncaught in the mutation spine
and would have surfaced as a 500.
- **Reserved identifiers are refused** in the domain, not the route, so the
guarantee holds for any future caller of `with_grouping`.
- The audit event is `tenant_grouping_changed` — its own type, not folded into
`tenant_updated`, so "why did this ceiling move" is answerable from the event
log alone.
New flex-auth action `tenant.grouping.set` (resource type `tenant`) — takes the
package from nine actions to ten. Handoff in T05.
## T04 - Interactions with guardrails and grants
```task
id: TEN-WP-0010-T04
status: done
priority: high
state_hub_task_id: "807c34ab-2c04-4276-a872-e89b5b064967"
```
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.
Done 2026-08-17, in `tests/test_grouping_mutation.py`.
**Guardrails.** The ceiling follows the new grouping (`small` €250 →
`enterprise` €20 000); a per-tenant override still wins, so precedence is
unchanged — reclassification moves the grouping layer only; provenance still
reads `grouping` after the move. The defect this workplan exists to fix is now
covered directly: a `trial` tenant reads a zero ceiling, and reclassifying it
to `medium` lifts it to €1 000.
On the 2am concern — a ceiling that moves with no guardrail write against it —
the `tenant_grouping_changed` event carries actor, reason and correlation id,
so the move is reconstructible from the event log. Chose *not* to also emit a
synthetic guardrail event: it would be a second record of one act, and a
guardrail audit trail containing entries that no guardrail write produced is a
worse thing to hand an auditor than one that is complete but requires reading
two event types.
**Grants.** Existing `platform_default` grants survive a move off `trial`
untouched — the trail is append-only and reclassification is not revocation. A
*new* `platform_default` grant afterwards is refused with `InvalidGrantError`
carrying the grouping in its message, which is the ADR-0014 invariant doing its
job rather than an accident.
Note these run against the in-memory store rather than the parametrised
conformance fixture: grouping lives in the existing `tenants` row and moves
through `mutate_tenant()`, which the lifecycle conformance suite already
exercises over both backends. There is no new persistence behaviour for a
backend to diverge on.
## T05 - Ship, and tell the consumers
```task
id: TEN-WP-0010-T05
status: done
priority: medium
state_hub_task_id: "072cddc6-762a-4efc-8176-31176b9fded6"
```
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.
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
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.