--- 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::` 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.