Kulcare Docs

ADR 004 — Polymorphic-owner models skip Shared::Concerns::TenantScoped

Status: Accepted

Date: 2026-05-05

Context tags: identity, multi-tenancy, polymorphic-models, vendor

Context

Kulcare's tenant-scoped models include Shared::Concerns::TenantScoped, which provides three things bundled together:

  1. belongs_to :tenant, class_name: 'Identity::Tenant' — a hard, required association.
  2. validates :tenant_id, presence: true — explicit validator.
  3. A default_scope that filters every query by Shared::Current.tenant.id when set.
  4. before_validation :set_tenant_from_current — auto-fills tenant_id from the current request context.

The concern works cleanly for purely tenant-bound models (Facility, Patient, Appointment). But the V1 vendor self-serve plan introduces polymorphic ownership: Identity::ServicePackage.owner can be Identity::Tenant (the historic shape, with tenant_id + facility_id set) or Identity::Vendor (vendor-owned rows that have NO tenant_id and NO facility_id).

Including TenantScoped on a polymorphic-owner model breaks four things:

  1. The hard belongs_to :tenant overrides any local belongs_to :tenant, optional: true in the model body. Vendor-owned rows fail to save because the inherited association expects a tenant.
  2. validates :tenant_id, presence: true rejects vendor rows at validation time. There's no clean way to "remove" a validation defined elsewhere.
  3. The default_scope filters out vendor rows entirely whenever the request has Shared::Current.tenant set — which is most of the time. Vendor rows become invisible.
  4. set_tenant_from_current would auto-fill tenant_id on vendor-owned rows from whatever tenant happens to be in Current, producing data that looks tenant-owned but isn't.

Earlier work (Connect::ProfessionalProfile, Clinical::Referral) sidestepped this by simply not including TenantScoped — those models live in domains where the concern was never adopted in the first place. ServicePackage is the first model where we had to remove the include during a migration.

Decision

Polymorphic-owner models do NOT include Shared::Concerns::TenantScoped. They reimplement only the parts they need:

  1. No hard belongs_to :tenant — declare belongs_to :tenant, class_name: 'Identity::Tenant', optional: true locally if the tenant-owned branch needs it.
  2. No global tenant_id presence validator — use conditional validators tied to ownership type:
    validates :tenant_id, presence: true, if: :tenant_owned?
    validates :tenant_id, absence: true, if: :vendor_owned?
  3. Custom default scope that respects the polymorphic shape — only filter tenant-owned rows when Shared::Current.tenant is set; always pass through vendor-owned rows:
    default_scope do
      if Shared::Current.tenant.present?
        where('tenant_id = ? OR tenant_id IS NULL', Shared::Current.tenant.id)
      else
        all
      end
    end
  4. No auto-fill from Current — owners are explicit at create time. Use a focused service (e.g. Identity::Vendors::SavePackageService) that sets owner: directly. A before_validation :sync_tenant_from_owner callback can mirror owner_id → tenant_id for tenant-owned rows so callers don't have to pass both.
  5. Reimplement unscoped_by_tenant as a class method (one-line unscope(where: :tenant_id)) so callers that need a clean cross-tenant read have the same vocabulary they had with the concern.

The first applied case is Identity::ServicePackage (V1 vendor packages, PR #1306).

Consequences

Positive

  • Polymorphic ownership stays clean. Vendor-owned rows are first-class — no special-casing in queries, no unscoped_by_tenant.where(...) boilerplate at every read.
  • The conditional validators make intent obvious to any reader: tenant-owned rows MUST have a tenant; vendor-owned rows MUST NOT. Wrong-shape rows fail loudly at save! time, not silently.
  • The custom default scope preserves the existing CMS isolation guarantee for tenant-owned rows. Existing code paths that filter by Shared::Current.tenant still get tenant isolation; vendor rows are an additive surface.
  • The decision is contained — only models with polymorphic ownership are affected. Pure tenant-bound models keep using TenantScoped unchanged.

Negative

  • Models that drop TenantScoped are slightly more verbose. The four bullets above (~25 lines) replace one include line.
  • Shared::Current.tenant-based default scoping is duplicated each time it's needed. If we add a third polymorphic-owner model, this should be extracted into a Shared::Concerns::PolymorphicOwnerScoped (or similar) concern. Two cases isn't enough rule of three to invest yet.
  • New developers might not realize the concern is intentionally absent and try to add it back. Mitigated by an explicit comment block at the model where the include used to be (see Identity::ServicePackage).
  • The custom default scope's WHERE clause uses a fully-qualified table name (identity.service_packages.tenant_id) to avoid ambiguity in joined queries. That's slightly fragile if the table is later renamed; document the dependency.

Decision tree for future polymorphic-owner work

Model has...Action
Single tenant owner onlyUse Shared::Concerns::TenantScoped as before
Polymorphic owner where ALL types are tenant-boundTenantScoped still works — add a custom validator for the type discriminator
Polymorphic owner with at least one tenantless type (Vendor, future Demo, etc.)Skip TenantScoped; reimplement per this ADR
  • Connect::ProfessionalProfile (PR #1293) — first polymorphic owner; never adopted TenantScoped
  • Clinical::Referral (PR #1295) — polymorphic referee; same pattern
  • Identity::ServicePackage (PR #1306) — first model to remove TenantScoped mid-flight
  • Developer guide: docs/guides/developer/identity/service_packages/polymorphic-owner.md
  • ADR 005 — parallel focused services for vendor surfaces

On this page