ADR 003 — JWT-implicit tenancy and the Connect cross-tenant exception
Status: Accepted
Date: 2026-04-29
Context tags: identity, multi-tenancy, connect, frontend-architecture
Context
Kulcare's CMS, Reports, and Patient apps are tenant-scoped: every request is authoritatively bound to a single tenant via the user's currently-selected facility. The tenant_id is encoded into the JWT at facility-switch time and is not an explicit parameter on any frontend hook or backend controller. Backend before_action :set_tenant_context sets Current.tenant from the JWT claim; controllers reading data filter by Current.tenant_id implicitly.
This worked cleanly until Connect needed to show information across all the tenants a user has membership in — a practitioner's full referral inbox, schedule, and connections shouldn't require them to switch facility four times to see the same week. We call this the "Shape A" read pattern.
The naive fix — letting hooks accept a tenant_id parameter or letting controllers pluck it from query params — would have leaked tenancy concerns into every consumer and made it indistinguishable when an endpoint was reading "the current tenant" vs "an arbitrary tenant the caller named". We needed a sharper boundary.
Decision
-
JWT-implicit tenancy is the default. Any endpoint or hook that reads tenant-scoped data MUST derive the tenant from the JWT, not from a parameter. Frontend hooks do not accept
tenant_idarguments. -
Cross-tenant aggregation lives behind dedicated endpoints that explicitly opt in. Specifically:
- Backend
Connect::BaseControlleroverridesset_tenant_contextto a no-op and replaces it withrequire_profile!. Personal endpoints (/connect/me/*) and the dashboard derive scope fromcurrent_user.user_memberships.active.pluck(:tenant_id), never fromCurrent.tenant. - Frontend hooks live under
@kulcare/shared-services/connectand are named after their semantics (useMyAppointments,useMyReferrals,useConnectDashboard), not after a tenant they accept. Their query keys live underconnectKeys.*.personal.list(params), distinct fromclinicalKeys.*.list(params).
- Backend
-
Mutations stay tenant-scoped. Cross-tenant reads are aggregated; cross-tenant writes require the caller to switch facility first. The Connect referrals page surfaces this with a confirm-and-switch dialog when the user clicks Accept/Decline on a row whose tenant differs from the currently-selected facility. Backend mutations operate on
Current.tenantas before — there is no privileged "operate on tenant X" mutation path.
Consequences
Positive:
- The default mental model stays simple: "if you're reading or writing data, you're doing it for the currently-selected tenant". Cross-tenant reads are an obvious exception that requires opting into a different controller hierarchy.
- Type-level enforcement:
clinical/types.tsexportsAppointment(tenant-scoped);connect/types.tsexportsPersonalAppointment(cross-tenant). The fields differ (the personal shape carriestenant: PersonalTenantandfacility: PersonalFacilityenvelopes). Mixing them up is a TypeScript error, not a runtime surprise. - The cross-tenant accept-flow is explicit and auditable. The user always knows which tenant a mutation will hit.
Negative:
- Two parallel hooks for "the same kind of data" —
useAppointments(tenant-scoped, inclinical/) vsuseMyAppointments(cross-tenant, inconnect/). They look similar but invalidate different query keys. Code review and naming discipline are the guardrails; we do not try to unify them. - The 90-day window on personal endpoints is a real limit. Going further requires either widening it (cost in query latency / index reconsideration) or routing to the per-tenant CMS, which is what we currently do.
- Adding a third Connect personal endpoint requires inheriting
Connect::BaseControllerAND adding hooks inconnect/AND adding query keys inconnectKeys.*.personal. The cost is procedural, not architectural.
Decision tree for future cross-tenant work
When you reach for cross-tenant data:
-
Is it a read? If yes, mirror the pattern: dedicated
Connect::*ControllerinheritingConnect::BaseController, dedicated/connect/me/*hook inshared-services/connect/, dedicated query key underconnectKeys.*.personal. -
Is it a write? Don't add a privileged write path. Either (a) the user must switch tenants first (confirm-and-switch dialog precedent in
ReferralsPage), or (b) the data is genuinely user-owned (not tenant-owned) and goes throughConnect::BaseControllerwithoutset_tenant_context. Audit case (b) carefully — it's rarer than it sounds. -
Are you tempted to pass
tenant_idas a parameter to a hook or controller? Stop. Read this ADR. The next person will too.
References
backend/domains/connect/controllers/base_controller.rb— theset_tenant_contextno-opfrontend/packages/shared-services/src/domains/connect/hooks.ts—useMyAppointments,useMyReferrals,useConnectDashboardfrontend/apps/connect/src/components/referrals/CrossTenantConfirmDialog.tsx— the confirm-and-switch UIdocs/plans/2026-04-27-feat-connect-app-release-readiness-plan.md— the plan that drove this; "Tenancy model — Shape A" sectiondocs/brainstorms/2026-04-26-connect-app-release-readiness-brainstorm.md— earlier exploration