Plan mutable grouping (TEN-WP-0010)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5a9e78f657
commit
20979040b2
1 changed files with 227 additions and 0 deletions
227
workplans/TEN-WP-0010-mutable-grouping.md
Normal file
227
workplans/TEN-WP-0010-mutable-grouping.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue