Skip to content

Authority Doc / Public V1 Tenancy

Multi-tenancy defines the workspace tenant boundary and how the domain layer enforces it.

This page is the Public V1 authority for what a CoachMe tenant is, which tables carry the tenant column, how the application and repository layers enforce the tenant fence, how each operating context binds to a tenant, and how the tenant boundary propagates into indexes, uniqueness, foreign keys, object keys, cache keys, events, and mobile sync.

AuthorityWorkspace = TenantShared schemaRepository-enforcedPublic V1

Decision status: Approved 2026-07-14

All decisions on this page are approved: the repository-enforced isolation model, workspaces.kind = 'personal' sharing one fence, a global un-fenced exercise catalog alongside tenant-scoped custom exercises, no foreign keys across aggregates in any context, and an integrity scan running in both CI and on schedule. Row-Level Security remains deferred and available.

This page no longer gates the first domain migration. Domain schema may proceed against these rules.

Note

This page does not define who may do what, and does not restate the architecture. roles-and-permissions owns roles, contexts, and permission gates; technical-architecture-plan owns the DDD modular-monolith design and bounded contexts; domain-model-map owns aggregate ownership and cross-context references. This page owns only the tenant fence those rules operate inside.

Scope

This page owns the tenant boundary and its enforcement. Adjacent authorities own the permission model, the domain design, retention, and environment.

This Page Owns

  • The definition of a tenant and the workspace kinds.
  • Table classification: which tables carry the tenant column and which cannot.
  • The repository enforcement contract that keeps the fence in the domain layer.
  • Tenant propagation into indexes, uniqueness constraints, and foreign keys.
  • The foreign-key rule at aggregate and context boundaries.
  • Derived key spaces: object-storage prefixes, cache prefixes, event payloads, and sync slices.
  • How each operating context binds a request to exactly one tenant.
  • Required negative-test and detective-scan evidence for cross-tenant isolation.

This Page Does Not Own

Tenant Model

A tenant is the unit of data isolation. CoachMe has exactly one: the workspace.

ConcernDecision
The tenantThe workspace. Every business record belongs to exactly one workspace.
Why not the coachroles-and-permissions reserves a Post-V1 Assistant/Staff role. A workspace can gain members; a tenant keyed to a person could not.
Why not the clientA client's records exist inside the coach's workspace through a relationship, not as an independent tenant.
Workspace kindscoach and personal. Both are tenants and use the identical fence.
Personal workspaceModeled as a workspace row with kind = 'personal'. The personal_workspace_id named by the roles authority is the id of a personal-kind workspace.
Dual-context accountsOne user account may hold membership in several workspaces. The request selects exactly one active workspace; the fence makes non-selected workspaces unreachable for that request.

Note

Modeling the personal workspace as a kind rather than a separate table is what allows one marker, one column name, one repository base, and one set of indexes to serve both. It also implements the roles authority's "permissions do not bleed between contexts" as a structural property rather than a convention.

Isolation Decision

The fence lives in the domain and repository layers, consistent with the architecture plan's rule to keep business rules in domain/application layers, not ORM models. Tenant isolation is a business rule, so it is modeled and enforced where the domain can own it.

OptionModelAssessment
A — ApprovedShared database, shared schema, workspace_id on every tenant-scoped table, enforced by a TenantScoped domain marker and a tenant-scoped repository base that no sanctioned query can bypass.Keeps the invariant in the domain layer where DDD places it. Enforcement is structural rather than remembered: an unscoped query is not expressible through the sanctioned path. Cost is the repository base, the private-handle boundary, and the lint rule that makes the boundary real.
B — RejectedShared schema with workspace_id filtered ad hoc in each query.Correctness rests on every author remembering a predicate on every query. This is not Option A: the distinguishing property of A is that the tenant term is contributed by the base, not by the caller.
C — RejectedSchema-per-tenant or database-per-tenant.Physical isolation at the cost of migrations, backups, and connections multiplied by tenant count. Unjustified at Public V1 scale and incompatible with the single-operator posture assumed by the environment-runbook.

Row-Level Security — Deferred, Remains Available

PostgreSQL Row-Level Security was considered as a database-level backstop and is deferred, not rejected. It would place a business rule in infrastructure, which the architecture plan's layering direction disfavors, and it carries real cost: forced-RLS and non-owning-role discipline, transaction-scoped session binding, and manual wiring under Drizzle.

The deferral is deliberately reversible. RLS is additive DDL — a policy per table plus a session binding — with no schema change behind it. Every schema rule on this page is identical with or without it. If an audit, an incident, or a scale change later demands defense in depth, RLS can be switched on precisely because the tenant column, index shape, and key prefixes were kept from migration one.

Table Classes

Not every table is tenant-scoped, and two classes must not be. Every new table declares its class in review.

ClassRuleExamples
Tenant-scopedCarries workspace_id. Implements TenantScoped. Reachable only through a tenant-scoped repository. This is the default; a new table is tenant-scoped unless it argues otherwise.Clients, relationships, programs, assignments, logs, forms, messages, media metadata, notes, tasks.
Global referenceNo workspace_id. Readable by all tenants; writable only by migrations or reviewed import tooling.The approved free-exercise-db import and its taxonomy; unit and locale reference data.
Identity and membershipSits outside the fence, keyed to the authenticated user. Either the table answers "which workspaces may this user select?" and so is read before a workspace is known, or the record belongs to the person across every workspace they can select. Scoped by authenticated user identity instead.User accounts, auth identity mappings, workspace membership, device registrations.
System and operationalCarries workspace_id where a record belongs to a tenant, so workers and consumers can route and scope. Reached only under an explicit worker or support context, never ambient.Outbox events, audit records, job state.

The distinguisher for the system class is the reaching context, not the operational flavour of the record. An operational row reached from a live tenant request — an idempotency key, a sync operation receipt — is tenant-scoped and stays behind the request's fence; only records reached under a worker or support context belong to the system class.

Chicken-and-egg

The identity and membership class is where a naive "add workspace_id everywhere" rule breaks. Membership resolution happens before the workspace is known, so those tables are scoped by user identity, not by the tenant fence. Fencing them by workspace would make context selection impossible.

Repository Enforcement Contract

The fence is a domain marker plus a repository base that contributes the tenant term. All four rules below are required; the pattern degrades to Option B if any one is dropped.

RuleRequirement
The markerTenant-scoped models implement TenantScoped, carrying workspaceId. The table type structurally requires the column, so a tenant table that omits it cannot be constructed into a tenant repository.
Reads are scoped by the baseThe repository base contributes workspace_id = <active workspace> to every query. Callers supply only their additional predicate. The tenant term is never the caller's responsibility.
Writes derive the tenant from contextInsert and update signatures omit workspaceId; the base stamps it from the active WorkspaceContext. A caller cannot set, pass, or override it. This is the write half of the fence and is the rule most often skipped.
The database handle is privateTenant tables are reachable only through their repositories. If application services can inject the Drizzle client directly, the repository is advisory and the fence is not real. A lint boundary restricting the client import to the repository layer is what makes this structural rather than cultural.

Acknowledged Escape Hatches

These bypass the fence and are accepted with named compensations rather than pretended away.

  • Raw SQL. Drizzle's sql template reaches under the repository. Restricted by the same lint boundary and by review.
  • Joins. The base scopes the root table. A join to a second tenant-scoped table does not inherit the predicate; the joining repository is responsible for scoping both sides.
  • Multi-table transactions. A command that writes several tenant-scoped tables atomically cannot go through the single-table base, so a workspace-bound transactional repository contributes the tenant term to every statement itself. Accepted only inside the repository layer, only with the WorkspaceContext passed in rather than a bare workspace id, and every reference it writes is covered by the detective scan.
  • Workers, seeds, and repair scripts. These must construct a WorkspaceContext like any request. They are fast, often unreviewed paths, and are the most likely place for the fence to be skipped.
  • Out-of-band access. Migrations, psql, and manual data repair have no fence. This is accepted; the environment-runbook owns the audited-runbook requirement for production data repair.

What The Fence Does Not Enforce

This is the boundary between this page and the roles authority, and it must not be blurred.

The fence enforces exactly one gate of the roles authority's permission formula: the workspace half of workspace/relationship ownership. Every other gate — selected operating context, role, lifecycle state, app surface, and data/source rule — remains application-enforced, and backend authorization stays final.

The consequence to internalize: a coached client's request is bound to the coach's workspace, so the fence alone would expose the whole workspace to them. It does not, because the relationship, role, and data rules from roles-and-permissions apply on top. The fence stops one workspace from reaching another; it does not decide what a client may see inside the workspace they legitimately belong to.

Schema Rules

These apply from the first domain migration and are independent of the enforcement mechanism.

ConcernRule
Columnworkspace_id uuid not null on every tenant-scoped table, referencing workspaces(id) on aggregate roots. A contained child anchors its tenant column through the composite foreign key to its root's (workspace_id, id), which binds it to the same workspace without a second reference.
IndexesTenant-scoped indexes lead with workspace_id, for example (workspace_id, created_at) rather than (created_at). A non-leading tenant column makes every query scan across tenants and degrade as tenant count grows.
UniquenessUniqueness is per workspace unless a rule is deliberately global: unique (workspace_id, name), not unique (name). Two workspaces must both be able to define an exercise named "Front Squat". A uniqueness rule inside a contained child may key on the root id instead of workspace_id, because the root id is already tenant-anchored by the composite foreign key; the child's read index still leads with workspace_id.
Soft deleteAggregate roots and independently managed business records use soft delete per the architecture plan, so tenant-scoped rows remain addressable for export, audit, and retained history. Per-workspace unique indexes on a soft-deletable table are partial — where deleted_at is null — so a soft-deleted row does not permanently reserve its key and uniqueness agrees with the soft-delete-aware read paths.

Foreign Keys And Aggregate Boundaries

The foreign-key rule follows aggregate ownership, and domain-model-map already states it in its own vocabulary: a relation that contains or owns is inside the aggregate, while one that "references, but does not own" crosses a boundary.

BoundaryRuleTenant effect
Within an aggregate (contains / owns)Foreign key required, composite: foreign key (workspace_id, root_id) references root (workspace_id, id).The database makes attaching a child to another tenant's root impossible. This is the only remaining database-level tenant guarantee, so it is not optional.
Cross-aggregate, same context (references, does not own)Reference by identity. No foreign key; the aggregate is the consistency boundary.No database guarantee. Replaced by the domain invariant below.
Cross-contextReference by identity. No foreign key.No database guarantee. Required by the architecture plan's goal that a context can "split into services later when a domain earns it" — cross-context foreign keys are what make that split expensive.

The Cross-Boundary Invariant

Where a foreign key is deliberately absent, the guarantee moves to the domain, which is where DDD places invariants:

  • On write, the application resolves every referenced aggregate id through a tenant-scoped read. A reference that does not resolve within the active workspace is rejected as an invariant violation, not a foreign-key error.
  • This composes with the fence rather than duplicating it: because all reads are tenant-scoped, another tenant's id cannot be legitimately obtained through the application at all. A cross-tenant reference requires an id injected from outside, and the invariant check is what catches it.

Accept this deliberately

With Row-Level Security deferred and cross-aggregate foreign keys absent by design, there is no database-level tenant enforcement outside a single aggregate. Both decisions are individually sound; together they place the entire cross-aggregate fence in application code. The detective control below is the compensation, and it is the reason this page requires it rather than suggesting it.

Detective Control

Because workspace_id is denormalized onto every tenant-scoped table, a violation is detectable even where it is not preventable. The integrity scan asserts that no reference resolves to a row in a different workspace, converting a lost preventive control into a detective one for the cost of a query. This is the concrete form of the domain map's own intent that its relation statements "become tests, database constraints, and API authorization rules."

The scan runs at both cadences, because they catch different failures:

CadenceTargetCatches
Every pull requestDeterministic CI fixturesA newly introduced code path that can construct a cross-tenant reference. Fails the build before it ships.
ScheduledStaging and productionA violation created by data repair, a worker, a migration, or any path that bypassed the repository. Fails loudly against real data.

A non-zero result is treated as a privacy incident under privacy-and-retention, not as a routine test failure. Every foreign-key-less reference from a tenant-scoped table to another tenant-scoped table is covered by the scan. A reference to an identity-class or global-reference row has no comparable workspace column and is out of scope by construction; any other uncovered reference is a gap in this contract.

Derived Key Spaces

The tenant boundary must appear anywhere data is addressed outside PostgreSQL, because the repository fence does not reach those systems.

SurfaceRule
Object storageKeys are prefixed workspace/<workspace_id>/…. This makes per-workspace lifecycle, purge, and export tractable for privacy-and-retention, and keeps the boundary legible in signed URLs.
Cache keysRedis keys are prefixed with the workspace, for example ws:<workspace_id>:client:<id>. An unprefixed key is a cross-tenant poisoning path.
Events and outboxTenant-scoped events carry workspace_id in the envelope so consumers and projections can scope without re-deriving it. Envelope shape remains owned by event-and-outbox-standard.
Mobile syncThe sync slice is bounded by the device's active workspace. Protocol mechanics remain owned by offline-sync-protocol.

Context Binding

Each operating context named by the roles authority supplies a WorkspaceContext. Every request that reaches a tenant-scoped table binds exactly one workspace. Identity-class endpoints — device registration, listing, and revocation — deliberately run unbound and are scoped by the authenticated user instead.

ContextBinding
Coach workspaceThe context carries the validated session's selected workspace, after membership is confirmed. The workspace is never taken from a client-supplied header.
Coach-client relationshipBinds the workspace that owns the relationship. The relationship gate itself remains application-enforced.
Personal workspaceBinds the personal-kind workspace id. Identical mechanics; no separate repository.
Support/adminBinds one target workspace explicitly under a grant with reason code and audit record, exactly as the roles authority requires. Support reaches one tenant at a time, and the access is recorded.
WorkerBinds the target workspace carried by the accepted event, job, or sync operation. Workers never run unbound.
Production telemetryNever binds a workspace and never touches product tables. Telemetry access grants no product-data authority.

Required Evidence

Isolation is proven by negative tests and the detective scan, not by inspection.

  • A repository read returns no rows from another workspace when the caller supplies only a bare id predicate.
  • A write cannot place a row into another workspace: the insert signature does not accept workspaceId, and an attempt to override it fails to compile or is rejected.
  • The lint boundary fails the build when the database client is imported outside the repository layer.
  • A repository joining two tenant-scoped tables scopes both sides, proven by a cross-tenant join test.
  • A worker processing an event runs bound to that event's workspace and reads nothing outside it.
  • A coached client in an active relationship cannot read another client's records in the same workspace, proving the fence and the roles gates compose.
  • The integrity scan reports zero references resolving into a different workspace.
  • Support/admin cross-tenant access emits an audit record with reason code and target scope.

Locked And Revisitable Decisions

This section separates rules that require a formal contract update from those that can move with recorded rationale.

Locked Without A Formal Contract Update

  • The workspace is the tenant, and every business record belongs to exactly one.
  • workspace_id on every tenant-scoped table, with tenant-leading composite indexes and per-workspace uniqueness as the default.
  • workspaces.kind = 'personal' shares one fence with coach workspaces; the personal workspace is not a separate table.
  • The four repository rules — the marker, base-contributed reads, context-derived writes, and the private handle with its lint boundary. Dropping any one degrades the model to rejected Option B.
  • Composite foreign keys within an aggregate, which are the only remaining database-level tenant guarantee.
  • No foreign keys across aggregates, in the same context or across contexts. Cross-boundary references are by identity and guarded by the domain invariant.
  • Object-key and cache-key prefixes, which the retention and purge policy depends on.

Revisitable With Recorded Rationale

  • The Row-Level Security deferral. RLS is additive DDL with no schema change behind it and may be enabled later as a backstop without amending any rule on this page.
  • The integrity scan's schedule frequency and alert thresholds, provided both cadences remain.
  • The global reference set. The imported exercise catalog is un-fenced today; a future provider import declares its class in review.

These pages own adjacent rules that this page depends on but does not redefine.

Technical Architecture Plan

Owns the DDD modular monolith, bounded contexts, layering, and the rule that business rules live in domain/application layers.

Domain Model Map

Owns aggregate roots, ownership verbs, and cross-context references that the foreign-key rule follows.

Roles And Permissions

Owns roles, operating contexts, lifecycle gates, and the permission formula this fence enforces one gate of.

Workflow State Map

Lifecycle states that gate access independently of the tenant boundary.

Privacy And Retention

Data classes, retention, export, and purge that rely on per-workspace addressability.

Event And Outbox Standard

Event envelope that carries the workspace for consumers and projections.

Offline Sync Protocol

Device sync slices bounded by the active workspace.

Environment Runbook

Database roles, connection handling, and object-storage configuration the fence depends on.

Toolchain Contract

Drizzle migration workflow and the lint tooling that enforces the repository boundary.

Development Readiness Review

Tracks this page as the decision gating the first domain migration.

CoachMe internal planning documentation.