tenant-engine/workplans/TEN-WP-0010-mutable-grouping.md
2026-08-17 22:53:52 +02:00

17 KiB

id type title domain repo status owner topic_slug created updated depends_on unblocks state_hub_workstream_id
TEN-WP-0010 workplan Make tenant grouping mutable and the identifier segment historical infotech tenant-engine finished claude tenant-guardrails 2026-08-17 2026-08-17
TEN-WP-0006
956d202f-e69d-4786-9525-6acf6e93116c

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

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

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:<grouping>: 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 groupingops-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

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 identifierstenant: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

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

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.