diff --git a/workplans/TEN-WP-0010-mutable-grouping.md b/workplans/TEN-WP-0010-mutable-grouping.md new file mode 100644 index 0000000..813c098 --- /dev/null +++ b/workplans/TEN-WP-0010-mutable-grouping.md @@ -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::` 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. \ No newline at end of file