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
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:
parent
2e11b6a155
commit
b998ca2332
6 changed files with 422 additions and 3 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
280
tests/test_grouping_mutation.py
Normal file
280
tests/test_grouping_mutation.py
Normal 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"
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue