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 |
|
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:
- The identifier's middle segment is historical — onboarding-time, immutable, and explicitly not authoritative for current grouping.
- The grouping field becomes mutable through an audited mutation, in the same shape as the lifecycle operations.
- Guardrail resolution already reads the field, so it picks up a change with no change on the resolution path.
- 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: todo
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.
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 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
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:platformandtenant:coulombare 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 typetenant.
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_groupingis a distinct error code frominvalid_update, so a caller can tell "that is not a grouping" from "nothing changed".InvalidTenantIdentifierErrorwas 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 intotenant_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-
trialdefaults remain conservative openers pending product sign-off (TEN-WP-0006-T01). This workplan makes the grouping correct; whether €250 is right forsmallis 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.