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

@ -62,7 +62,7 @@
| task | TEN-WP-0009-T05 | todo | — | workplans/TEN-WP-0009-postgres-production-store.md | | task | TEN-WP-0009-T05 | todo | — | workplans/TEN-WP-0009-postgres-production-store.md |
| task | TEN-WP-0009-T06 | todo | — | workplans/TEN-WP-0009-postgres-production-store.md | | task | TEN-WP-0009-T06 | todo | — | workplans/TEN-WP-0009-postgres-production-store.md |
| task | TEN-WP-0010-T01 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md | | task | TEN-WP-0010-T01 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md |
| task | TEN-WP-0010-T02 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md | | task | TEN-WP-0010-T02 | done | — | workplans/TEN-WP-0010-mutable-grouping.md |
| task | TEN-WP-0010-T03 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md | | task | TEN-WP-0010-T03 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md |
| task | TEN-WP-0010-T04 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md | | task | TEN-WP-0010-T04 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md |
| task | TEN-WP-0010-T05 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md | | task | TEN-WP-0010-T05 | todo | — | workplans/TEN-WP-0010-mutable-grouping.md |

View file

@ -114,6 +114,15 @@ class LifecycleRequest(BaseModel):
correlation_id: str = Field(min_length=1) correlation_id: str = Field(min_length=1)
class SetGroupingRequest(BaseModel):
model_config = {"extra": "forbid"}
grouping: str = Field(min_length=1)
actor: str
reason: str = Field(min_length=1)
correlation_id: str = Field(min_length=1)
class GuardrailLimitBody(BaseModel): class GuardrailLimitBody(BaseModel):
"""A ceiling, as a consumer states it. """A ceiling, as a consumer states it.
@ -385,6 +394,35 @@ def create_app(
mutate=lambda tenant, at: tenant.reactivate(at=at), mutate=lambda tenant, at: tenant.reactivate(at=at),
) )
# -- Reclassification (TEN-WP-0010) -----------------------------------
# Separate from PATCH /tenants/{id} on purpose: grouping resolves spend
# ceilings, so policy must be able to permit a rename without permitting a
# reclassification, and the audit trail must show which one happened.
@app.post("/tenants/{tenant_id}/grouping")
async def set_grouping(
tenant_id: str,
payload: SetGroupingRequest,
response: Response,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
if_match: str | None = Header(default=None, alias="If-Match"),
) -> dict:
return _lifecycle_mutation(
store=store,
authorizer=authorizer,
action="tenant.grouping.set",
tenant_id=tenant_id,
actor=payload.actor,
reason=payload.reason,
correlation_id=payload.correlation_id,
idempotency_key=idempotency_key,
if_match=if_match,
response=response,
event_type="tenant_grouping_changed",
extra_fingerprint={"grouping": payload.grouping},
mutate=lambda tenant, at: tenant.with_grouping(payload.grouping, at=at),
)
# -- Guardrail API (TEN-WP-0006) -------------------------------------- # -- Guardrail API (TEN-WP-0006) --------------------------------------
# Reading a ceiling and changing one are distinct actions, so policy can # Reading a ceiling and changing one are distinct actions, so policy can
# give flex-auth the read without giving anything the write. # give flex-auth the read without giving anything the write.
@ -700,6 +738,10 @@ def _lifecycle_mutation(
) from exc ) from exc
except (InvalidLifecycleTransitionError, TenantRetiredError) as exc: except (InvalidLifecycleTransitionError, TenantRetiredError) as exc:
raise LifecycleError(409, "invalid_lifecycle_transition", str(exc), correlation_id) from exc raise LifecycleError(409, "invalid_lifecycle_transition", str(exc), correlation_id) from exc
except InvalidTenantIdentifierError as exc:
# TEN-WP-0010: an unknown grouping. Distinct from invalid_update so a
# caller can tell "not a grouping" from "nothing changed".
raise LifecycleError(400, "invalid_grouping", str(exc), correlation_id) from exc
except (ImmutableFieldError, EmptyUpdateError) as exc: except (ImmutableFieldError, EmptyUpdateError) as exc:
raise LifecycleError(400, "invalid_update", str(exc), correlation_id) from exc raise LifecycleError(400, "invalid_update", str(exc), correlation_id) from exc
except StoreUnavailableError as exc: except StoreUnavailableError as exc:

View file

@ -21,6 +21,9 @@ _RESOURCE_TYPES: dict[str, str] = {
# A PDP needs the read; almost nothing needs the write. # A PDP needs the read; almost nothing needs the write.
"tenant.guardrail.read": "guardrail", "tenant.guardrail.read": "guardrail",
"tenant.guardrail.set": "guardrail", "tenant.guardrail.set": "guardrail",
# TEN-WP-0010: reclassification moves a tenant's spend ceiling, so it is
# separable from a metadata edit rather than folded into tenant.update.
"tenant.grouping.set": "tenant",
} }

View file

@ -174,6 +174,39 @@ class Tenant:
return replace(self, version=self.version + 1, updated_at=at, **changes) # type: ignore[arg-type] return replace(self, version=self.version + 1, updated_at=at, **changes) # type: ignore[arg-type]
def with_grouping(self, grouping: str, *, at: datetime) -> "Tenant":
"""Reclassify the tenant (TEN-WP-0010).
Deliberately *not* part of `with_metadata`. `display_name` and
`contact_email` are cosmetic; grouping resolves spend ceilings, so a
change here moves money. It gets its own method, its own route, and its
own flex-auth action so policy can permit a rename without permitting
a reclassification, and so the audit trail shows which one happened.
The identifier's grouping segment is *historical* -- onboarding-time,
immutable, and not authoritative for current grouping (ADR-0013
amendment proposed under TEN-WP-0010-T01). This field is the
authoritative one, which is why it may diverge from the identifier.
"""
if self.is_reserved:
# tenant:platform and tenant:coulomb are ungrouped by design and
# resolve guardrails through the reserved profile. Giving one a
# grouping would silently move the platform's own identity onto
# the grouping ladder.
raise ImmutableFieldError(
"reserved tenants are ungrouped and cannot be reclassified"
)
if self.lifecycle is not TenantLifecycle.ACTIVE:
raise InvalidLifecycleTransitionError(
"grouping of a retired tenant cannot be changed; reactivate first"
)
if grouping not in GROUPINGS:
raise InvalidTenantIdentifierError(f"Unknown tenant grouping: {grouping!r}")
if grouping == self.grouping:
raise EmptyUpdateError("update would not change the grouping")
return replace(self, grouping=grouping, version=self.version + 1, updated_at=at)
def retire(self, *, at: datetime) -> "Tenant": def retire(self, *, at: datetime) -> "Tenant":
if self.lifecycle is TenantLifecycle.RETIRED: if self.lifecycle is TenantLifecycle.RETIRED:
raise InvalidLifecycleTransitionError("tenant is already retired") raise InvalidLifecycleTransitionError("tenant is already retired")

View file

@ -0,0 +1,280 @@
"""TEN-WP-0010-T03/T04: reclassification, and what it moves.
Grouping resolves spend ceilings, so these tests care as much about the
guardrail and grant consequences of a reclassification as about the mutation
itself.
"""
from datetime import UTC, datetime
import pytest
from fastapi.testclient import TestClient
from tenant_engine.app import create_app
from tenant_engine.authz import WriteAuthorizationDeniedError, WriteAuthorizer
from tenant_engine.domain import (
CapabilityRole,
EmptyUpdateError,
ImmutableFieldError,
InvalidLifecycleTransitionError,
InvalidTenantIdentifierError,
Tenant,
create_role_grant,
)
from tenant_engine.guardrail import Provenance, resolve_limit
from tenant_engine.store import InMemoryTenantStore
NOW = datetime(2026, 8, 17, 12, 0, tzinfo=UTC)
KEY = "spend.monthly"
BODY = {"actor": "ops", "reason": "grew past the band", "correlation_id": "corr-1"}
HEADERS = {"Idempotency-Key": "idem-1", "If-Match": '"1"'}
def tenant(identifier="tenant:small:acme") -> Tenant:
return Tenant.create(tenant_id="t-1", identifier=identifier, created_at=NOW)
# --- Domain -------------------------------------------------------------
def test_reclassification_changes_the_field_not_the_identifier():
moved = tenant().with_grouping("large", at=NOW)
assert moved.grouping == "large"
# the identifier's segment is historical and stays put
assert moved.identifier == "tenant:small:acme"
assert moved.version == 2
def test_the_field_and_the_identifier_are_allowed_to_diverge():
# this is the whole point of the change -- ADR-0013 amendment, T01
moved = tenant().with_grouping("enterprise", at=NOW)
assert moved.identifier.split(":")[1] != moved.grouping
def test_reserved_tenants_cannot_be_reclassified():
# giving tenant:platform a grouping would move the platform's own identity
# onto the grouping ladder and off the reserved guardrail profile
for identifier in ("tenant:platform", "tenant:coulomb"):
with pytest.raises(ImmutableFieldError):
tenant(identifier).with_grouping("large", at=NOW)
def test_an_unknown_grouping_is_rejected():
with pytest.raises(InvalidTenantIdentifierError):
tenant().with_grouping("enormous", at=NOW)
def test_a_no_op_reclassification_is_rejected():
with pytest.raises(EmptyUpdateError):
tenant().with_grouping("small", at=NOW)
def test_a_retired_tenant_cannot_be_reclassified():
with pytest.raises(InvalidLifecycleTransitionError):
tenant().retire(at=NOW).with_grouping("large", at=NOW)
# --- Guardrail consequences (T04) ---------------------------------------
def test_the_spend_ceiling_follows_the_new_grouping():
before = resolve_limit(KEY, tenant=tenant())
after = resolve_limit(KEY, tenant=tenant().with_grouping("enterprise", at=NOW))
assert before.value.amount == 25_000
assert after.value.amount == 2_000_000
assert after.provenance is Provenance.GROUPING
def test_reclassification_off_trial_lifts_the_zero_ceiling():
# the defect this workplan exists to fix: a trial tenant was stuck at zero
trial = tenant("tenant:trial:acme")
assert resolve_limit(KEY, tenant=trial).value.amount == 0
grown = trial.with_grouping("medium", at=NOW)
assert resolve_limit(KEY, tenant=grown).value.amount == 100_000
def test_an_override_still_wins_after_reclassification():
# precedence is unchanged: reclassification moves the grouping layer only
from tenant_engine.guardrail import LimitKind, LimitValue
override = {KEY: LimitValue(kind=LimitKind.SPEND, amount=7_000, currency="EUR", period="P1M")}
effective = resolve_limit(
KEY, tenant=tenant().with_grouping("enterprise", at=NOW), overrides=override
)
assert effective.provenance is Provenance.OVERRIDE
assert effective.value.amount == 7_000
# --- Grant consequences (T04) -------------------------------------------
def test_existing_platform_default_grants_survive_a_move_off_trial():
trial = tenant("tenant:trial:acme")
grant = create_role_grant(
tenant=trial,
grant_id="g-1",
role=CapabilityRole.PLTF,
grant_reason="platform_default",
plan_id=None,
granted_by="ops",
correlation_id="corr-0",
granted_at=NOW,
)
store = InMemoryTenantStore()
store.create_tenant(trial)
store.grant_role(grant)
# the trail is append-only; reclassifying does not revoke history
assert store.active_roles("t-1") == frozenset({CapabilityRole.PLTF})
def test_a_new_platform_default_grant_is_refused_after_moving_off_trial():
from tenant_engine.domain import InvalidGrantError
grown = tenant("tenant:trial:acme").with_grouping("medium", at=NOW)
with pytest.raises(InvalidGrantError):
create_role_grant(
tenant=grown,
grant_id="g-2",
role=CapabilityRole.PLTF,
grant_reason="platform_default",
plan_id=None,
granted_by="ops",
correlation_id="corr-1",
granted_at=NOW,
)
# --- API ----------------------------------------------------------------
class _AllowAll(WriteAuthorizer):
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
return None
class _Scoped(WriteAuthorizer):
def __init__(self, *allowed: str) -> None:
self._allowed = set(allowed)
def authorize(self, *, action: str, tenant_id: str, actor: str) -> None:
if action not in self._allowed:
raise WriteAuthorizationDeniedError(action, "not permitted")
def make_client(authorizer=None, identifier="tenant:small:acme") -> TestClient:
app = create_app(store=InMemoryTenantStore(), authorizer=authorizer or _AllowAll())
client = TestClient(app)
client.post(
"/tenants",
json={"tenant_id": "t-1", "identifier": identifier, "actor": "ops"},
)
return client
def post_grouping(client, grouping="large", headers=None):
return client.post(
"/tenants/t-1/grouping",
json={"grouping": grouping, **BODY},
headers=headers or HEADERS,
)
def test_the_route_reclassifies_and_bumps_the_version():
client = make_client()
response = post_grouping(client)
assert response.status_code == 200
assert response.json()["grouping"] == "large"
assert response.json()["identifier"] == "tenant:small:acme"
assert response.headers["ETag"] == '"2"'
def test_the_new_ceiling_is_visible_through_the_guardrail_read():
client = make_client(identifier="tenant:trial:acme")
assert client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()[
"limits"
][KEY]["amount"] == 0
post_grouping(client, "medium")
assert client.get("/tenants/t-1/guardrails", params={"actor": "flex-auth"}).json()[
"limits"
][KEY]["amount"] == 100_000
def test_reclassification_is_authorized_separately_from_a_rename():
# policy can permit a display-name edit without permitting a move that
# changes the spend ceiling
client = make_client(_Scoped("tenant.create", "tenant.update"))
assert post_grouping(client).status_code == 403
assert client.patch(
"/tenants/t-1",
json={"metadata": {"display_name": "Acme"}, **BODY},
headers=HEADERS,
).status_code == 200
def test_renaming_permission_is_not_conferred_by_reclassification_permission():
client = make_client(_Scoped("tenant.create", "tenant.grouping.set"))
assert post_grouping(client).status_code == 200
assert client.patch(
"/tenants/t-1",
json={"metadata": {"display_name": "Acme"}, **BODY},
headers={"Idempotency-Key": "idem-2", "If-Match": '"2"'},
).status_code == 403
def test_an_unknown_grouping_is_a_distinct_error_code():
response = post_grouping(make_client(), "enormous")
assert response.status_code == 400
assert response.json()["error_code"] == "invalid_grouping"
def test_a_no_op_reclassification_is_rejected_over_http():
response = post_grouping(make_client(), "small")
assert response.status_code == 400
assert response.json()["error_code"] == "invalid_update"
def test_reclassifying_a_reserved_tenant_is_refused():
client = make_client(identifier="tenant:platform")
assert post_grouping(client).status_code == 400
def test_reclassification_replays_idempotently():
client = make_client()
first = post_grouping(client)
second = post_grouping(client)
assert second.headers["Idempotent-Replay"] == "true"
assert second.json()["version"] == first.json()["version"] == 2
def test_a_stale_if_match_conflicts():
client = make_client()
post_grouping(client)
conflict = post_grouping(client, "medium", {"Idempotency-Key": "idem-2", "If-Match": '"1"'})
assert conflict.status_code == 409
assert conflict.json()["error_code"] == "version_conflict"
def test_reclassifying_a_retired_tenant_is_refused():
client = make_client()
client.post(
"/tenants/t-1/retire",
json=BODY,
headers={"Idempotency-Key": "retire-1", "If-Match": '"1"'},
)
response = post_grouping(client, "large", {"Idempotency-Key": "idem-3", "If-Match": '"2"'})
assert response.status_code == 409
assert response.json()["error_code"] == "invalid_lifecycle_transition"
def test_the_change_is_auditable_as_its_own_event():
# a ceiling that moves with no guardrail write against it must still be
# reconstructible -- this event is how
client = make_client()
post_grouping(client)
store = client.app.state.store
events = [e for e in store.events() if e.event_type == "tenant_grouping_changed"]
assert len(events) == 1
assert events[0].payload["actor"] == "ops"
assert events[0].payload["reason"] == "grew past the band"
assert events[0].payload["correlation_id"] == "corr-1"

View file

@ -167,7 +167,7 @@ this survey only establishes that nothing breaks on day one.
```task ```task
id: TEN-WP-0010-T03 id: TEN-WP-0010-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "b6c0c54f-a961-412b-b2a8-c39e7a6d48c1" 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 Done when the mutation is authorized, versioned, idempotent, correlated, and
audited, with the reserved-identifier and retired-tenant cases tested. 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 ## T04 - Interactions with guardrails and grants
```task ```task
id: TEN-WP-0010-T04 id: TEN-WP-0010-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "807c34ab-2c04-4276-a872-e89b5b064967" 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 Done when the interactions are covered in the conformance suites over all
backends. 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 ## T05 - Ship, and tell the consumers
```task ```task