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

@ -114,6 +114,15 @@ class LifecycleRequest(BaseModel):
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):
"""A ceiling, as a consumer states it.
@ -385,6 +394,35 @@ def create_app(
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) --------------------------------------
# Reading a ceiling and changing one are distinct actions, so policy can
# give flex-auth the read without giving anything the write.
@ -700,6 +738,10 @@ def _lifecycle_mutation(
) from exc
except (InvalidLifecycleTransitionError, TenantRetiredError) as 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:
raise LifecycleError(400, "invalid_update", str(exc), correlation_id) from 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.
"tenant.guardrail.read": "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]
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":
if self.lifecycle is TenantLifecycle.RETIRED:
raise InvalidLifecycleTransitionError("tenant is already retired")