From 85423e8e09cf2c5f432dabd8b74bf7f37d9f8a2a Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 26 Sep 2026 20:46:38 +0200 Subject: [PATCH] Show login state, active sign-in, and allowed memberships separately. USER-WP-0036 keeps the token tenant off the membership list and leaves workload decisions unchecked until the catalogue reports them. Assistant: grok Assistant-Session: 01a0d25d-d358-7e13-b84a-d007fbb7e34f --- docs/account-journeys.md | 9 +- src/user_engine/web.py | 184 ++++++++++++++---- tests/journey-coverage.json | 11 +- tests/test_account_awareness.py | 133 +++++++++++++ tests/test_account_recovery.py | 7 +- tests/test_portal_navigation.py | 2 +- ...R-WP-0036-account-situational-awareness.md | 27 ++- 7 files changed, 320 insertions(+), 53 deletions(-) create mode 100644 tests/test_account_awareness.py diff --git a/docs/account-journeys.md b/docs/account-journeys.md index dd1c430..860bd3a 100644 --- a/docs/account-journeys.md +++ b/docs/account-journeys.md @@ -3,7 +3,8 @@ Owner: user-engine, with KeyCape/NetKingdom for sign-in and factors, tenant-engine for tenant lifecycle, and applications for workload admission. Acceptance work: USER-WP-0027; OTP dependency: KEY-WP-0035 and NK-WP-0033. -Reviewed against the portal on 2026-09-13. This is the browser acceptance contract; +Reviewed against the portal on 2026-09-13. U01, U09 and U10 were revised on +2026-09-26. This is the browser acceptance contract; headless capability alone does not mean a journey is usable or verified live. ## Common interaction rules @@ -33,7 +34,7 @@ headless capability alone does not mean a journey is usable or verified live. | ID / intent | Success | Failure and recovery | Current support / acceptance | |---|---|---|---| -| U01 — Know whether I am signed in | Header names my verified account; exactly the appropriate Sign in or Log out control | Expired/unknown cookie shows signed-out state; sign in again | Implemented; automated anonymous/expired/member/operator tests | +| U01 — Know whether I am signed in | Header and the home page name the verified portal identity, or say the portal session is absent. An application may keep its own session | Expired/unknown cookie shows signed-out state; a query does not invent a session; sign in again | Implemented; automated anonymous/expired/member/operator tests, including the home login-state section | | U02 — Sign in to my company application | Personal login lands in the intended tenant and application | Wrong credentials stay on provider; denied membership leads to account help with identity switching | Recovery deployed previously; actual fresh-user acceptance waiting on OTP | | U03 — Accept an invitation | Confirm intended tenant/role, accept once, then see next setup step | Expired/used/wrong-person invitation explains next step; admin reissues without duplicates | Service/browser routes exist; live delivery and full browser acceptance pending | | U04 — Set or recover my password | Use actual login name, complete single-use setup, return to sign-in | Missing mail or expired link offers admin-assisted new setup link | Password setup reported successful; login name and sign-in address now named at handoff and in the user entry (2026-09-23 run, USER-WP-0035-T01); email delivery unresolved (USER-WP-0035-T02) | @@ -41,8 +42,8 @@ headless capability alone does not mean a journey is usable or verified live. | U06 — Turn on authenticator codes voluntarily | My account → Sign-in security → provider; confirm identity, scan QR, verify current code, see activation confirmed, test fresh login | Bad code retries; cancellation does not report enabled; interruption can resume safely; support reachable without portal login | Help and configurable provider handoff implemented; provider activation/cancel semantics and live enrollment unverified | | U07 — Sign in with an enrolled authenticator | Current code completes login; existing AAL1 session cannot skip OTP | Invalid code explains retry; lost device has a recovery route | Issuer policy tested; real-user enrolled/recovery acceptance pending | | U08 — Replace or remove my authenticator | Provider reauthenticates; replacement verified before old factor removed; status and recovery instructions clear | Lost old factor triggers verified recovery, not a bypass link; policy-required MFA cannot be disabled | Required provider journey; not verified/available from portal yet | -| U09 — See my tenants and usable applications | Account page lists allowed tenants and launchable applications with the current context | Empty membership explains request-access route; unavailable/stale access is labelled and refreshable | Personal membership display exists; authoritative application catalogue/request-access journey pending USER-WP-0026-T03 | -| U10 — Change tenant or account | Explicit tenant switch confirms new authority; account switch ends relevant sessions and lets me choose identity | Denied tenant leaves a clear recovery route; stale shared identity can be cleared | Tenant reauthentication and shared sign-out implemented; real multi-identity acceptance pending | +| U09 — See my tenants and usable applications | Account page and home show login state, the tenants and privileges active on this sign-in, and allowed memberships separately. An ordinary sign-in has one active tenant. An administrator, vendor, or multi-hire sign-in may show more than one active tenant when the verified token lists them. A recorded workload membership is an allowed record | An empty allowed list says nothing is recorded. A tenant account or the token tenant is not shown as a membership. A missing catalogue decision is not checked, which is neither access nor a denial | Login state, active sign-in, and allowed memberships are shown (USER-WP-0036). Allow, deny, and unavailable workload decisions remain USER-WP-0026-T03 and USER-WP-0028-T02 | +| U10 — Change tenant or account | Explicit reauthentication confirms the new tenant. Other allowed tenants stay inactive until that sign-in. Switching does not claim to end sessions an application already has | Denied tenant leaves a clear recovery route; stale shared identity can be cleared. A portal control does not mark a second tenant active by itself | Reauthentication handoff is the only tenant switch; real multi-identity acceptance pending | | U11 — Sign out | Confirmation states scope; portal session ends; correct signed-out controls appear | CSRF rejection retains session; shared-provider failure explains remaining scope and retry | Portal automated tests; prior shared logout browser checks; current real-user acceptance pending | | U12 — Recover from denied access or service outage | Plain explanation, reference for support, and account/help navigation | No automatic login loop; no private claims/codes echoed; safe retry only | HTML browser denial/recovery implemented; JSON API semantics retained | | U13 — Update my profile and finish onboarding | Saved values and required steps are confirmed; external steps reflect provider evidence | Validation keeps safe input; provider-owned steps cannot be manually faked complete | Existing routes; form-preservation and external completion UX acceptance pending | diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 87b5f83..5e609a7 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -276,7 +276,12 @@ class PortalApplication: if path == "/" and method == "GET": actor = self._optional_actor(environ) self._set_account_navigation(environ, actor) - return self._html(start_response, self._home(actor), correlation_id) + memberships: tuple[Any, ...] = () + if actor is not None: + identity = self.service.store.find_identity(*actor.identity_key) + if identity is not None: + memberships = self.service.store.memberships_for_user(identity.user_id) + return self._html(start_response, self._home(actor, memberships), correlation_id) if path == "/register" and method == "GET": if self._optional_actor(environ) is not None: @@ -389,8 +394,7 @@ class PortalApplication: return self._html( start_response, self._onboarding( - session, memberships, journeys, str(selected_tenant), - self._csrf_token(environ), + session, memberships, journeys, self._csrf_token(environ), ), correlation_id, ) @@ -1894,25 +1898,26 @@ If the recovery message does not arrive, ask your tenant administrator for a new Use the login name they provide; it may differ from your display name.

Back to sign-in help

""") - def _home(self, actor: Any | None) -> str: - identity = ( - f"

Signed in as {escape(actor.preferred_username)}.

" - '

View my account

' - if actor is not None - else ( - '

You are not signed in to this portal.

' - + ( - '

New here? Create an account.

' - if self.public_registration and self.registration_verification is not None - else "" - ) - ) + def _home(self, actor: Any | None, memberships: tuple[Any, ...] = ()) -> str: + register = ( + '

New here? Create an account.

' + if actor is None and self.public_registration and self.registration_verification is not None + else "" ) + account = ( + '

View my account

' + if actor is not None else "" + ) + situation = self._login_state(actor) + if actor is not None: + situation += self._active_now(actor, memberships) + situation += self._allowed_tenants(actor, memberships) + situation += self._allowed_workloads(memberships) return self._page_html( "Identity & access", "

Your account, on your terms.

" "

Join a tenant, complete onboarding, and manage access without exposing credentials to applications.

" - + identity, + + situation + register + account, ) def _registration_form(self, csrf_token: str, idempotency_key: str) -> str: @@ -2168,44 +2173,147 @@ Use the login name they provide; it may differ from your display name.

str: - platform_operator = "platform-operator" in session.actor.roles - empty_memberships = ( - "
  • You have no personal tenant memberships. Your platform operator role lets you manage tenants through platform administration.
  • " - if platform_operator else "
  • No tenant memberships yet.
  • " - ) - membership_items = "".join( - f"
  • {escape(item.tenant)} — {escape(item.kind)}
  • " - for item in memberships - ) or empty_memberships journey_items = "".join( self._onboarding_journey_item(item, csrf_token) for item in journeys ) or "
  • No additional onboarding steps are required.
  • " - verification = "Verified by your identity provider" if session.actor.assurance else "Verification pending" consent_checked = " checked" if session.user.consented_at else "" + actor = session.actor return self._page_html( "Onboarding", - f"""

    Welcome, {escape(session.user.display_name or session.actor.preferred_username or session.user.user_id)}

    -

    Current identity

    Signed in as {escape(session.actor.preferred_username or session.actor.subject)}.

    Sign-in tenant: {escape(session.actor.tenant)}.

    Roles: {escape(", ".join(session.actor.roles) or "None")}.

    {escape(verification)}

    Password and two-step verification help

    + f"""

    Welcome, {escape(session.user.display_name or actor.preferred_username or session.user.user_id)}

    +{self._login_state(actor)} +{self._active_now(actor, memberships)}

    Profile and consent

    -

    Tenant access

    Viewing {escape(selected_tenant)}.

    -

    Reauthenticate in this tenant to change the authoritative login context.

    -

    Workload access

    {self._workload_memberships(memberships)}

    Each application checks access when you open it. Tenant membership alone does not grant access to every application.

    +{self._allowed_tenants(actor, memberships)} +{self._allowed_workloads(memberships)}

    Onboarding progress

    """, ) @staticmethod - def _workload_memberships(memberships: tuple[Any, ...]) -> str: - items = "".join( - f"
  • {escape(item.scope_id)} — {escape(item.kind)} ({escape(item.tenant)})
  • " - for item in memberships if item.scope_type in {"application", "service", "workload", "asset"} + def _login_state(actor: Any | None) -> str: + if actor is None: + return ( + '

    Login state

    ' + "

    You are not signed in to this portal.

    " + ) + name = escape(actor.preferred_username or actor.subject) + verification = "Verified by your identity provider" if actor.assurance else "Verification pending" + return ( + '

    Login state

    ' + f"

    Signed in to this portal as {name}.

    " + "

    This is the portal session. An application can keep its own session.

    " + f"

    {escape(verification)}

    " + '

    Password and two-step verification help

    ' + ) + + @staticmethod + def _is_exception_account(actor: Any, memberships: tuple[Any, ...]) -> bool: + if {"tenant-admin", "platform-operator", "platform-root"}.intersection(actor.roles): + return True + return any(item.kind in {"vendor", "multi-hire"} for item in memberships) + + @staticmethod + def _active_tenants(actor: Any, memberships: tuple[Any, ...]) -> tuple[str, ...]: + active = [str(actor.tenant)] + if not PortalApplication._is_exception_account(actor, memberships): + return tuple(active) + claimed = actor.claims.get("active_tenants", ()) + if isinstance(claimed, str): + claimed = (claimed,) + for tenant in claimed: + if ( + isinstance(tenant, str) + and tenant.startswith("tenant:") + and len(tenant) <= 200 + and tenant not in active + ): + active.append(tenant) + if len(active) >= 8: + break + return tuple(active) + + @staticmethod + def _active_now(actor: Any, memberships: tuple[Any, ...]) -> str: + active = PortalApplication._active_tenants(actor, memberships) + items = "".join(f"
  • {escape(tenant)} - Active
  • " for tenant in active) + if len(active) > 1: + note = "

    More than one tenant is active because this sign-in carries an administrator, vendor, or multi-hire role.

    " + elif PortalApplication._is_exception_account(actor, memberships): + note = "

    This account may use more than one tenant when the sign-in says so. This sign-in has one active tenant.

    " + else: + note = "

    An ordinary sign-in uses one tenant. Other allowed tenants stay inactive until you sign in to them.

    " + roles = escape(", ".join(actor.roles) or "None") + groups = escape(", ".join(actor.groups) or "None") + return ( + '

    Active now

    ' + "

    This is the tenant and the privileges on this sign-in.

    " + f'' + f"

    Roles: {roles}.

    " + f"

    Directory groups: {groups}.

    " + f"{note}
    " + ) + + @staticmethod + def _allowed_tenants(actor: Any, memberships: tuple[Any, ...]) -> str: + active = set(PortalApplication._active_tenants(actor, memberships)) + rows = [item for item in memberships if item.scope_type == "tenant"] + if not rows: + if "platform-operator" in actor.roles: + body = ( + "
  • No tenant memberships are recorded. You have no personal tenant memberships. " + "Your platform operator role lets you manage tenants through platform administration.
  • " + ) + else: + body = "
  • No tenant memberships are recorded.
  • " + else: + parts = [] + for item in rows: + if item.tenant in active: + action = "Active" + else: + hint = escape(urlencode({"tenant_hint": item.tenant})) + action = ( + 'Inactive. Sign in to use this tenant' + ) + parts.append( + f"
  • {escape(item.tenant)} - {escape(item.kind)} - {action}
  • " + ) + body = "".join(parts) + return ( + '

    Allowed tenants

    ' + "

    These are memberships recorded for this account. A tenant account created at first sign-in is not a membership.

    " + f'' + "

    Signing in to an inactive tenant replaces the active tenant for an ordinary account. " + "It does not end a session an application already has.

    " + ) + + @staticmethod + def _allowed_workloads(memberships: tuple[Any, ...]) -> str: + rows = [ + item for item in memberships + if item.scope_type in {"application", "service", "workload", "asset"} + ] + if rows: + items = "".join( + f"
  • {escape(item.scope_id)} - {escape(item.kind)} ({escape(item.tenant)}) - Allowed, recorded here
  • " + for item in rows + ) + else: + items = "
  • No workload access is recorded.
  • " + return ( + '

    Allowed workloads

    ' + f'' + "

    Workload decisions are not checked.

    " + "

    Each application checks access when you open it. Tenant membership alone does not grant access to every application.

    " ) - return "" if items else "

    No workload-specific access is recorded for this account.

    " @staticmethod def _onboarding_journey_item(journey: Any, csrf_token: str) -> str: diff --git a/tests/journey-coverage.json b/tests/journey-coverage.json index fc34901..66e499d 100644 --- a/tests/journey-coverage.json +++ b/tests/journey-coverage.json @@ -7,7 +7,8 @@ "implementation": "implemented", "tests": [ "test_account_clarity.AccountClarityTests.test_anonymous_and_expired_sessions_have_login_without_logout", - "test_account_clarity.AccountClarityTests.test_authenticated_roles_have_logout_without_login" + "test_account_clarity.AccountClarityTests.test_authenticated_roles_have_logout_without_login", + "test_account_awareness.AccountAwarenessTests.test_signed_out_home_states_only_the_portal_session" ], "remaining": "" }, @@ -81,9 +82,13 @@ "role": "user", "implementation": "partial", "tests": [ - "test_account_recovery.AccountRecoveryTests.test_account_workload_list_is_scoped_to_current_user" + "test_account_recovery.AccountRecoveryTests.test_account_workload_list_is_scoped_to_current_user", + "test_account_awareness.AccountAwarenessTests.test_token_tenant_without_membership_is_active_and_not_allowed", + "test_account_awareness.AccountAwarenessTests.test_allowed_tenant_that_is_not_active_uses_sign_in", + "test_account_awareness.AccountAwarenessTests.test_recorded_workload_stays_allowed_and_unchecked", + "test_account_awareness.AccountAwarenessTests.test_exception_role_shows_every_tenant_the_sign_in_lists" ], - "remaining": "USER-WP-0028-T02/USER-WP-0026-T03: authoritative application catalogue and access requests not implemented." + "remaining": "USER-WP-0036 shows login state, the active sign-in, and allowed memberships. USER-WP-0028-T02/USER-WP-0026-T03 still own authoritative allow, deny, and unavailable workload decisions." }, { "id": "U10", diff --git a/tests/test_account_awareness.py b/tests/test_account_awareness.py new file mode 100644 index 0000000..1c53cac --- /dev/null +++ b/tests/test_account_awareness.py @@ -0,0 +1,133 @@ +"""Login state, active sign-in, and allowed memberships stay separate.""" +import unittest + +from test_web import invoke +from user_engine.domain import Membership +from user_engine.oidc import BrowserSession +from user_engine.testing.fixtures import human_actor_claims + +import test_portal_navigation + + +def _section(body: bytes, element_id: str) -> bytes: + marker = f'id="{element_id}"'.encode() + start = body.index(marker) + end = body.index(b"", start) + return body[start:end] + + +class AccountAwarenessTests(unittest.TestCase): + setUp = test_portal_navigation.PortalNavigationTests.setUp + get = test_portal_navigation.PortalNavigationTests.get + + def test_signed_out_home_states_only_the_portal_session(self): + _, body = invoke(self.app, "/", query="username=forged&tenant=tenant:evil:one") + self.assertIn(b"Login state", body) + self.assertIn(b"You are not signed in to this portal.", body) + self.assertNotIn(b"Active now", body) + self.assertNotIn(b"Allowed tenants", body) + self.assertNotIn(b"forged", body) + self.assertNotIn(b"tenant:evil:one", body) + + def test_token_tenant_without_membership_is_active_and_not_allowed(self): + session = self.app.service.me(self.oidc.claims("member"), correlation_id="synthetic") + self.assertIsNotNone( + self.app.service.store.tenant_account("tenant:trial:demo-company", session.user.user_id) + ) + _, body = self.get("/onboarding", who="member") + active = _section(body, "active-tenant-list") + allowed = _section(body, "allowed-tenant-list") + self.assertIn(b"tenant:trial:demo-company - Active", active) + self.assertNotIn(b"tenant:two", active) + self.assertIn(b"No tenant memberships are recorded.", allowed) + self.assertNotIn(b"tenant:trial:demo-company", allowed) + self.assertIn(b"No workload access is recorded.", body) + self.assertIn(b"Workload decisions are not checked.", body) + self.assertNotIn(b"Viewing", body) + self.assertIn(b"An ordinary sign-in uses one tenant.", body) + self.assertIn(b"This is the portal session.", body) + + def test_allowed_tenant_that_is_not_active_uses_sign_in(self): + session = self.app.service.me(self.oidc.claims("member"), correlation_id="synthetic") + self.app.service.store.save_membership(Membership( + membership_id="mem-other", user_id=session.user.user_id, + tenant="tenant:other:company", scope_type="tenant", + scope_id="tenant:other:company", kind="user", + )) + _, body = self.get("/onboarding", who="member") + allowed = _section(body, "allowed-tenant-list") + active = _section(body, "active-tenant-list") + self.assertIn(b"tenant:other:company - user - Inactive.", allowed) + self.assertIn(b'href="/login?tenant_hint=tenant%3Aother%3Acompany"', allowed) + self.assertNotIn(b"tenant:one:alpha - Active", active) + self.assertIn(b"tenant:two:beta - Active", active) + self.assertNotIn(b'href="/login?tenant_hint=tenant%3Atwo', body) + + def test_exception_without_a_second_tenant_claim_stays_on_one(self): + claims = human_actor_claims(subject="vendor-one", tenant="tenant:one:alpha") + claims["roles"] = ["user"] + claims["preferred_username"] = "vendor-one" + self.oidc.sessions["vendor-one"] = BrowserSession(claims, 9999999999, "vendor-one-csrf") + session = self.app.service.me(claims, correlation_id="synthetic") + self.app.service.store.save_membership(Membership( + membership_id="mem-vendor-one", user_id=session.user.user_id, + tenant="tenant:other:company", scope_type="tenant", + scope_id="tenant:other:company", kind="vendor", + )) + _, body = self.get("/onboarding", who="vendor-one") + active = _section(body, "active-tenant-list") + allowed = _section(body, "allowed-tenant-list") + self.assertIn(b"tenant:one:alpha", active) + self.assertNotIn(b"tenant:other:company", active) + self.assertIn(b"tenant:other:company - vendor - Inactive.", allowed) + self.assertIn(b"This sign-in has one active tenant.", body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_account_recovery.py b/tests/test_account_recovery.py index 382a084..7961c5a 100644 --- a/tests/test_account_recovery.py +++ b/tests/test_account_recovery.py @@ -19,9 +19,10 @@ class AccountRecoveryTests(unittest.TestCase): self.assertIn(b'/onboarding', body) self.assertIn(b'This portal is signed in as', body) _, body = self.get('/onboarding') - self.assertIn(b'Current identity', body) - self.assertIn(b'Workload access', body) - self.assertIn(b'No workload-specific access is recorded', body) + self.assertIn(b'Login state', body) + self.assertIn(b'Allowed workloads', body) + self.assertIn(b'No workload access is recorded.', body) + self.assertIn(b'Workload decisions are not checked.', body) def test_shared_logout_clears_portal_then_uses_provider_confirmation(self): response, _ = invoke(self.app, '/logout', method='POST', cookie='ue_session=operator', form={'csrf_token':'wrong','scope':'shared'}) diff --git a/tests/test_portal_navigation.py b/tests/test_portal_navigation.py index 569c21a..9a700e1 100644 --- a/tests/test_portal_navigation.py +++ b/tests/test_portal_navigation.py @@ -94,7 +94,7 @@ class PortalNavigationTests(unittest.TestCase): self.assertNotIn(b'href="/platform"',member) self.assertNotIn(b'operator-csrf',member) self.assertIn(b'value="member-csrf"',member) - self.assertIn(b'No tenant memberships yet.',member) + self.assertIn(b'No tenant memberships are recorded.',member) _, anonymous = invoke(self.app,'/') self.assertNotIn(b'action="/logout"',anonymous) self.assertNotIn(b'href="/platform"',anonymous) diff --git a/workplans/USER-WP-0036-account-situational-awareness.md b/workplans/USER-WP-0036-account-situational-awareness.md index 5e09cab..2ecc4f4 100644 --- a/workplans/USER-WP-0036-account-situational-awareness.md +++ b/workplans/USER-WP-0036-account-situational-awareness.md @@ -4,7 +4,7 @@ type: workplan title: "Show allowed and active access on the account page" domain: communication repo: user-engine -status: ready +status: finished flavor: extension owner: grok topic_slug: user-engine @@ -41,7 +41,7 @@ and USER-WP-0028-T02. This plan does not infer those decisions. ```task id: USER-WP-0036-T01 -status: todo +status: done priority: high state_hub_task_id: "ffc8f42f-5ca3-56a2-8850-d2da9beba72c" ``` @@ -76,11 +76,16 @@ The observed case renders as: signed in, one active tenant taken from the token, no allowed tenants, no allowed workloads, workload decisions not checked. Keep the layout readable on a phone. +2026-09-26: the home page and `/onboarding` now render Login state, Active now, +and Allowed tenants / Allowed workloads as separate sections. A tenant account +is not listed as a membership. Recorded workload rows stay labelled as records, +followed by "Workload decisions are not checked." + ## Change the active tenant only through sign-in ```task id: USER-WP-0036-T02 -status: todo +status: done priority: high state_hub_task_id: "136a27ff-fd3e-5f0a-8a3a-bb080f1965d5" ``` @@ -100,11 +105,18 @@ one is already recorded. Switching tenant does not claim to end application sessions. The existing sign-out copy remains the place that explains a remaining shared sign-in. +2026-09-26: an inactive tenant membership links to `/login?tenant_hint=`. The +page has no control that marks a second tenant active inside the portal +session. Extra active tenants are taken from the verified `active_tenants` +claim, and only for `tenant-admin`, `platform-operator`, `platform-root`, or a +recorded `vendor` or `multi-hire` membership. An ordinary sign-in shows the +token tenant alone. + ## Record the journey and prove the three situations ```task id: USER-WP-0036-T03 -status: todo +status: done priority: medium state_hub_task_id: "4c9b0600-5034-58ce-9b02-90a040f6f65f" ``` @@ -121,3 +133,10 @@ catalogue decision renders as not checked. Do not close USER-WP-0026-T03 or USER-WP-0028-T02 from this plan. They remain the owners of allow, deny, and unavailable workload decisions. + +2026-09-26: U01, U09 and U10 in `docs/account-journeys.md` match the page. +`tests/test_account_awareness.py` covers the signed-out home, a token tenant +with a tenant account and no membership, an inactive allowed tenant, a +recorded workload, an ordinary sign-in that ignores a second tenant claim, and +exception roles whose verified token lists two active tenants. 247 unit tests +passed. The live portal still serves the previous image.