372 lines
No EOL
17 KiB
Markdown
372 lines
No EOL
17 KiB
Markdown
---
|
|
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:<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
|
|
|
|
```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:<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
|
|
|
|
```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. |