Finish TEN-WP-0010-T03/T04: audited grouping mutation
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 1m5s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-17 22:09:24 +02:00
parent 2e11b6a155
commit b998ca2332
6 changed files with 422 additions and 3 deletions

View file

@ -167,7 +167,7 @@ this survey only establishes that nothing breaks on day one.
```task
id: TEN-WP-0010-T03
status: todo
status: done
priority: high
state_hub_task_id: "b6c0c54f-a961-412b-b2a8-c39e7a6d48c1"
```
@ -205,11 +205,43 @@ 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: todo
status: done
priority: high
state_hub_task_id: "807c34ab-2c04-4276-a872-e89b5b064967"
```
@ -237,6 +269,35 @@ 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