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:
belongs_to :tenant, class_name: 'Identity::Tenant'— a hard, required association.validates :tenant_id, presence: true— explicit validator.- A
default_scopethat filters every query byShared::Current.tenant.idwhen set. before_validation :set_tenant_from_current— auto-fillstenant_idfrom 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:
- The hard
belongs_to :tenantoverrides any localbelongs_to :tenant, optional: truein the model body. Vendor-owned rows fail to save because the inherited association expects a tenant. validates :tenant_id, presence: truerejects vendor rows at validation time. There's no clean way to "remove" a validation defined elsewhere.- The
default_scopefilters out vendor rows entirely whenever the request hasShared::Current.tenantset — which is most of the time. Vendor rows become invisible. set_tenant_from_currentwould auto-filltenant_idon vendor-owned rows from whatever tenant happens to be inCurrent, 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:
- No hard
belongs_to :tenant— declarebelongs_to :tenant, class_name: 'Identity::Tenant', optional: truelocally if the tenant-owned branch needs it. - 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? - Custom default scope that respects the polymorphic shape — only filter tenant-owned rows when
Shared::Current.tenantis 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 - No auto-fill from Current — owners are explicit at create time. Use a focused service (e.g.
Identity::Vendors::SavePackageService) that setsowner:directly. Abefore_validation :sync_tenant_from_ownercallback can mirrorowner_id → tenant_idfor tenant-owned rows so callers don't have to pass both. - Reimplement
unscoped_by_tenantas a class method (one-lineunscope(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.tenantstill 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
TenantScopedunchanged.
Negative
- Models that drop
TenantScopedare slightly more verbose. The four bullets above (~25 lines) replace oneincludeline. 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 aShared::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 only | Use Shared::Concerns::TenantScoped as before |
| Polymorphic owner where ALL types are tenant-bound | TenantScoped 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 |
Related
Connect::ProfessionalProfile(PR #1293) — first polymorphic owner; never adoptedTenantScopedClinical::Referral(PR #1295) — polymorphic referee; same patternIdentity::ServicePackage(PR #1306) — first model to removeTenantScopedmid-flight- Developer guide:
docs/guides/developer/identity/service_packages/polymorphic-owner.md - ADR 005 — parallel focused services for vendor surfaces