Kulcare Docs

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

  1. 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_id arguments.

  2. Cross-tenant aggregation lives behind dedicated endpoints that explicitly opt in. Specifically:

    • Backend Connect::BaseController overrides set_tenant_context to a no-op and replaces it with require_profile!. Personal endpoints (/connect/me/*) and the dashboard derive scope from current_user.user_memberships.active.pluck(:tenant_id), never from Current.tenant.
    • Frontend hooks live under @kulcare/shared-services/connect and are named after their semantics (useMyAppointments, useMyReferrals, useConnectDashboard), not after a tenant they accept. Their query keys live under connectKeys.*.personal.list(params), distinct from clinicalKeys.*.list(params).
  3. 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.tenant as 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.ts exports Appointment (tenant-scoped); connect/types.ts exports PersonalAppointment (cross-tenant). The fields differ (the personal shape carries tenant: PersonalTenant and facility: PersonalFacility envelopes). 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, in clinical/) vs useMyAppointments (cross-tenant, in connect/). 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::BaseController AND adding hooks in connect/ AND adding query keys in connectKeys.*.personal. The cost is procedural, not architectural.

Decision tree for future cross-tenant work

When you reach for cross-tenant data:

  1. Is it a read? If yes, mirror the pattern: dedicated Connect::*Controller inheriting Connect::BaseController, dedicated /connect/me/* hook in shared-services/connect/, dedicated query key under connectKeys.*.personal.

  2. 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 through Connect::BaseController without set_tenant_context. Audit case (b) carefully — it's rarer than it sounds.

  3. Are you tempted to pass tenant_id as a parameter to a hook or controller? Stop. Read this ADR. The next person will too.

References

  • backend/domains/connect/controllers/base_controller.rb — the set_tenant_context no-op
  • frontend/packages/shared-services/src/domains/connect/hooks.ts — useMyAppointments, useMyReferrals, useConnectDashboard
  • frontend/apps/connect/src/components/referrals/CrossTenantConfirmDialog.tsx — the confirm-and-switch UI
  • docs/plans/2026-04-27-feat-connect-app-release-readiness-plan.md — the plan that drove this; "Tenancy model — Shape A" section
  • docs/brainstorms/2026-04-26-connect-app-release-readiness-brainstorm.md — earlier exploration

On this page