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