From 47a58d5cc21fcd3156d691e993720eb69a3bf9fc Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 18 Aug 2026 10:56:54 +0200 Subject: [PATCH] Report tenant grouping from the authority record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tenant-engine has made grouping mutable through its own reclassification route, so a tenant created as tenant:small:acme can report grouping "large". The identifier's grouping segment is now historical and must not be parsed. TenantRecord dropped the field entirely, so the portal read discarded the one safe source of a tenant's classification and left an operator with nothing but the identifier to infer from — exactly the mistake the change creates. The record and adapter now carry grouping, the operator screen shows it with a note that the identifier segment is not the grouping, and the OpenAPI schema documents where to read it. Also corrects the UpdateTenant description, which still claimed grouping was immutable. It is mutable, but never as metadata, because it resolves a tenant's spend ceiling. No reclassification control is offered here: that route is not deployed yet and, per tenant-engine, wants its own permission rather than riding on rename. Full suite: 149 tests, 3 provider-gated skips. Co-Authored-By: Claude Opus 5 --- WORK-RECORDS.md | 4 ++++ openapi/portal-v1.yaml | 13 +++++++++++-- src/user_engine/adapters/tenant_management.py | 1 + src/user_engine/ports.py | 3 +++ src/user_engine/web.py | 1 + tests/test_tenant_management_adapter.py | 10 ++++++++++ tests/test_web.py | 1 + 7 files changed, 31 insertions(+), 2 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index b9d5085..df2aa04 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -30,6 +30,7 @@ | workplan | USER-WP-0020 | finished | — | workplans/USER-WP-0020-self-service-and-user-administration-portal.md | | workplan | USER-WP-0021 | active | — | workplans/USER-WP-0021-portal-product-expansion.md | | workplan | USER-WP-0022 | blocked | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md | +| workplan | USER-WP-0023 | active | — | workplans/USER-WP-0023-flex-auth-caller-identity.md | | task | USER-WP-0001-T1 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md | | task | USER-WP-0001-T2 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md | | task | USER-WP-0001-T3 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md | @@ -165,3 +166,6 @@ | task | USER-WP-0022-T03 | cancel | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md | | task | USER-WP-0022-T04 | cancel | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md | | task | USER-WP-0022-T05 | progress | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md | +| task | USER-WP-0023-T01 | done | — | workplans/USER-WP-0023-flex-auth-caller-identity.md | +| task | USER-WP-0023-T02 | done | — | workplans/USER-WP-0023-flex-auth-caller-identity.md | +| task | USER-WP-0023-T03 | wait | — | workplans/USER-WP-0023-flex-auth-caller-identity.md | diff --git a/openapi/portal-v1.yaml b/openapi/portal-v1.yaml index 47bbb71..42e9c2d 100644 --- a/openapi/portal-v1.yaml +++ b/openapi/portal-v1.yaml @@ -379,8 +379,10 @@ components: metadata: type: object description: >- - Mutable tenant metadata. tenant_id, identifier, and grouping are - immutable at the authority and are rejected here. + Mutable tenant metadata. tenant_id and identifier are immutable at + the authority and are rejected here. grouping is mutable but only + through the authority's own reclassification route, never as + metadata, because it resolves a tenant's spend ceiling. minProperties: 1 properties: display_name: {type: string, minLength: 1, maxLength: 200} @@ -396,6 +398,13 @@ components: external_ref: {type: string} lifecycle: {type: string, enum: [active, retired, unknown]} version: {type: integer, minimum: 0} + grouping: + type: string + nullable: true + description: >- + Authoritative classification, reported by the tenant authority. + Read it from here; never parse it from the identifier, whose own + grouping segment is historical once a tenant is reclassified. display_name: {type: string, nullable: true} contact_email: {type: string, nullable: true} retired_at: {type: string, nullable: true} diff --git a/src/user_engine/adapters/tenant_management.py b/src/user_engine/adapters/tenant_management.py index 7986d97..ca1ac11 100644 --- a/src/user_engine/adapters/tenant_management.py +++ b/src/user_engine/adapters/tenant_management.py @@ -176,6 +176,7 @@ class HTTPTenantManagementAdapter: external_ref=str(payload.get("tenant_id") or identifier), lifecycle=str(payload.get("lifecycle") or "unknown"), version=int(payload.get("version") or 0), + grouping=payload.get("grouping"), display_name=payload.get("display_name"), contact_email=payload.get("contact_email"), retired_at=payload.get("retired_at"), diff --git a/src/user_engine/ports.py b/src/user_engine/ports.py index 38e40a1..faebf37 100644 --- a/src/user_engine/ports.py +++ b/src/user_engine/ports.py @@ -128,6 +128,9 @@ class TenantRecord: external_ref: str lifecycle: str version: int + # Authoritative classification. The identifier's own grouping segment is + # historical after a reclassification, so it must never be parsed for this. + grouping: str | None = None display_name: str | None = None contact_email: str | None = None retired_at: str | None = None diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 5c78931..464eb9d 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -1694,6 +1694,7 @@ class PortalApplication: f"Tenant {record.tenant}", f"""

{escape(record.tenant)}

Lifecycle {escape(record.lifecycle)} at version {record.version}.

+

Grouping {escape(record.grouping or 'not reported')}, as reported by the tenant authority. The identifier's own segment is historical after a reclassification and is not the grouping.

{replayed} {metadata_form}

Lifecycle

diff --git a/tests/test_tenant_management_adapter.py b/tests/test_tenant_management_adapter.py index 3a77b2e..a23cb71 100644 --- a/tests/test_tenant_management_adapter.py +++ b/tests/test_tenant_management_adapter.py @@ -96,6 +96,16 @@ class TenantLifecycleAdapterTests(unittest.TestCase): self.assertEqual(record.version, 1) self.assertFalse(record.replayed) + def test_read_reports_the_authoritative_grouping(self): + """Grouping must come from the record; the identifier segment is historical.""" + request, record = self._call( + "tenant", + response={**RECORD, "identifier": "tenant:small:acme", "grouping": "large"}, + tenant="tenant:small:acme", correlation_id="corr-1", + ) + self.assertEqual(record.tenant, "tenant:small:acme") + self.assertEqual(record.grouping, "large") + def test_update_sends_a_version_etag_and_the_allow_listed_change(self): request, record = self._call( "update_tenant", response={**RECORD, "display_name": "Binky Ltd", "version": 2}, diff --git a/tests/test_web.py b/tests/test_web.py index df980d5..6482dc8 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -847,6 +847,7 @@ class PortalApplicationTests(unittest.TestCase): ) self.assertEqual("200 OK", page["status"]) self.assertIn(b"Retire tenant", html) + self.assertIn(b"not reported", html) self.assertIn(b'name="version" value="1"', html) forged, _ = invoke(