Context: #1290 (epic #1097 server foundations) delivered SQL-layer multi-tenancy via a regex-based WorkspaceStatementInspector running over every emitted statement. This is a runtime guardrail, not a structural fix.
The structural fix is Hibernate's native multi-tenancy via @TenantId on every workspace-scoped entity, with workspace_id denormalized onto each table so the predicate doesn't need a join to reach.
ADR 0004 documents why this was cut from the foundations epic: 20+ entities need workspace_id denormalized (some across multi-hop FK chains), each one a non-trivial migration with backfill semantics. 6–12 week effort.
Why eventually
The current inspector approach has documented holes:
- 4 regex carve-outs (INSERT, PK-only DML, PK-anchored SELECT,
workspace_id word-boundary) admit edge cases that depend on the surrogate-key invariant holding.
- ~71 repositories are marked
@WorkspaceAgnostic for FK-chain scoping. Each one is a defect surface — drop the annotation and queries pass the inspector with the wrong scope.
- Inspector runs in
log mode in prod today; the flip to throw is a separate decision (#TBD — file when ready).
Native @TenantId is a single declarative invariant per entity, enforced at the Hibernate level rather than at SQL-shape level.
What's in scope when this lands
- Add
workspace_id NOT NULL column to every workspace-scoped table that doesn't already have one. Backfill from the existing FK chain.
- Annotate every workspace-scoped
@Entity with @TenantId.
- Register a
CurrentTenantIdentifierResolver that reads from the existing WorkspaceContext.
- Remove
WorkspaceStatementInspector and @WorkspaceAgnostic (or repurpose @WorkspaceAgnostic to mean "intentionally cross-tenant query" with a registered tenant override).
- Migrate all repositories that today scope via FK chain or via
@Where/@Filter to plain repositories — @TenantId makes the predicate implicit.
Why not now
Acceptance (epic-level)
Context: #1290 (epic #1097 server foundations) delivered SQL-layer multi-tenancy via a regex-based
WorkspaceStatementInspectorrunning over every emitted statement. This is a runtime guardrail, not a structural fix.The structural fix is Hibernate's native multi-tenancy via
@TenantIdon every workspace-scoped entity, withworkspace_iddenormalized onto each table so the predicate doesn't need a join to reach.ADR 0004 documents why this was cut from the foundations epic: 20+ entities need
workspace_iddenormalized (some across multi-hop FK chains), each one a non-trivial migration with backfill semantics. 6–12 week effort.Why eventually
The current inspector approach has documented holes:
workspace_idword-boundary) admit edge cases that depend on the surrogate-key invariant holding.@WorkspaceAgnosticfor FK-chain scoping. Each one is a defect surface — drop the annotation and queries pass the inspector with the wrong scope.logmode in prod today; the flip tothrowis a separate decision (#TBD — file when ready).Native
@TenantIdis a single declarative invariant per entity, enforced at the Hibernate level rather than at SQL-shape level.What's in scope when this lands
workspace_id NOT NULLcolumn to every workspace-scoped table that doesn't already have one. Backfill from the existing FK chain.@Entitywith@TenantId.CurrentTenantIdentifierResolverthat reads from the existingWorkspaceContext.WorkspaceStatementInspectorand@WorkspaceAgnostic(or repurpose@WorkspaceAgnosticto mean "intentionally cross-tenant query" with a registered tenant override).@Where/@Filterto plain repositories —@TenantIdmakes the predicate implicit.Why not now
Acceptance (epic-level)
@TenantIdwith aworkspace_idcolumnCurrentTenantIdentifierResolverreads fromWorkspaceContextWorkspaceStatementInspectorremoved (or demoted to assertion-only behind a debug flag)@WorkspaceAgnosticannotations either deleted or converted to tenant overridesCrossWorkspaceIsolationTest(feat(server): CrossWorkspaceIsolationTest reflection-walks every @WorkspaceScopedController #1292)