critical priority high complexity integration pending integration specialist Tier 3

Acceptance Criteria

persistSelection() calls a Supabase RPC function `set_active_organization(org_id uuid)` (or equivalent Edge Function) after the SecureStorage write succeeds
The RPC call writes org_id as a custom claim in the JWT so that all subsequent Supabase queries are RLS-scoped to that organization without any additional client-side filtering
If the RPC call fails after a successful SecureStorage write, the SecureStorage entry is deleted (rollback) and a typed TenantClaimWriteException is thrown to the caller
A Supabase Auth token refresh event (onAuthStateChange with tokenRefreshed) triggers re-application of the stored org_id claim so RLS scope is not lost after token rotation
Claim refresh on token refresh is idempotent — multiple rapid refresh events do not cause race conditions or duplicate RPC calls (debounce or mutex guard)
The active JWT received after the claim write contains the `active_organization_id` field verifiable via `supabase.auth.currentSession?.accessToken` JWT decode in tests
If there is no persisted session (user has not selected an org), token refresh does not attempt a claim write
All Supabase RPC calls use the service-scoped client (with anon key) — the service role key is never used from the mobile client

Technical Requirements

frameworks
Flutter
Riverpod
flutter_test
apis
Supabase RPC: `set_active_organization(org_id uuid)` — must be created as a Postgres function or Edge Function
Supabase Auth: onAuthStateChange stream for token refresh detection
Supabase Edge Functions (Deno) as alternative claim-write mechanism if RPC is insufficient
data models
assignment
performance requirements
RPC call for claim write must complete within 2 seconds under normal network; surface a timeout error after 5 seconds
Token refresh re-claim must not block app navigation — execute asynchronously and surface errors via a Riverpod error notifier
security requirements
Service role key must NEVER be used from the Flutter mobile client — only the anon key or user JWT
The `set_active_organization` Postgres function must verify the calling user belongs to the supplied org_id before writing the claim — prevents privilege escalation
Custom JWT claim write must be scoped: the function should only allow setting orgs the user is a verified member of (validate against user_organizations join table server-side)
On sign-out, the custom claim must be cleared server-side; do not rely solely on client-side cache invalidation for security boundaries
RPC failure must not leave a partially-scoped session; rollback ensures the client is in a consistent unauthenticated-to-tenant state

Execution Context

Execution Tier
Tier 3

Tier 3 - 413 tasks

Can start after Tier 2 completes

Implementation Notes

The server-side Postgres function `set_active_organization` should call `auth.jwt_claims_admin.set_claim(auth.uid(), 'active_organization_id', org_id::text)` or use the `supabase_jwt_claims` approach from the Supabase docs. On the Flutter side, after the RPC call, call `supabase.auth.refreshSession()` to obtain a new JWT containing the claim — Supabase does not automatically update the in-memory JWT after a custom claim write. Use a `Mutex` (from the `synchronized` package) around the claim-write path to prevent concurrent writes. For the rollback: wrap the entire persistSelection as `try { await secureStorage.write(...); await supabase.rpc(...); } catch (e) { await secureStorage.delete(...); rethrow; }`.

Keep the token refresh listener in a private `_authSubscription` cancelled in dispose().

Testing Requirements

Write unit tests with mocked Supabase RPC client: (1) happy path — SecureStorage write succeeds then RPC succeeds, no exception thrown, (2) RPC failure after SecureStorage write — assert SecureStorage entry is deleted (rollback) and TenantClaimWriteException is thrown, (3) token refresh event with stored session — assert RPC is called with correct org_id, (4) token refresh with no stored session — assert RPC is NOT called, (5) rapid double token refresh — assert RPC called exactly once (debounce). Write one integration test against a local Supabase instance (via `supabase start`) verifying the JWT contains the custom claim after the RPC call.

Component
Tenant Session Store
data medium
Epic Risks (2)
high impact low prob technical

TenantSessionStore must write to both SecureStorageAdapter and Supabase session synchronously. If the Supabase write succeeds but the local secure storage write fails (or vice versa), the system ends up in an inconsistent state: local app thinks org A is selected but Supabase queries are scoped to org B (or unscoped), causing silent data leakage or empty result sets.

Mitigation & Contingency

Mitigation: Implement the dual-write with compensating rollback: if the second write fails, undo the first write and surface a typed DualWriteFailureError to callers. Add a startup integrity check in restoreSession() that validates local storage and Supabase session agree on the current org_id.

Contingency: If integrity check fails on startup, clear both stores and redirect the user to the org selection screen rather than proceeding with potentially mismatched state.

medium impact low prob integration

An organization could be deactivated in Supabase between the time the org list is cached and the time the user taps to select it. If the repository serves stale cached data the org-selection-service will attempt to seed a context for an inactive org, potentially causing RLS scope issues or confusing error states.

Mitigation & Contingency

Mitigation: The OrganizationRepository.getOrganizationById() path used during selection validation always performs a fresh network fetch (bypassing cache) to confirm the org is still active before the TenantSessionStore writes anything.

Contingency: If a freshness check finds the org is inactive, surface a localized error message on the selection screen ('This organization is no longer available') and refresh the org list to show only currently active partners.