227 lines
No EOL
9.2 KiB
Markdown
227 lines
No EOL
9.2 KiB
Markdown
---
|
|
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. |