Skip to content

Commit f71d19a

Browse files
authored
feat(spec)!: remove tenancy.strategy + tenancy.crossTenantAccess — strict tenancy block with tombstones (#2962)
Implements the #2763 owner decision (enforce-or-remove): TenancyConfigSchema drops its two zero-consumer fields and rejects unknown keys with tombstone guidance pointing at the two real tenancy modes (database-per-tenant = environment-level deployment; row-level isolation = enabled + tenantField). Ships in the 15.x line per the launch-window policy.
1 parent 28a0857 commit f71d19a

14 files changed

Lines changed: 163 additions & 47 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: remove `tenancy.strategy` + `tenancy.crossTenantAccess`; tenancy block is now strict (#2763)
6+
7+
> ⚠️ RELEASE NOTE — breaking by strict semver, shipped as `minor` per the
8+
> launch-window policy (owner decision on PR #2962): the fields had zero
9+
> consumers, behavior is unchanged, and the parse error carries the
10+
> migration. Fold into the v15 release page's "What's new in 15.x" section
11+
> when versioning.
12+
13+
BREAKING CHANGE: `TenancyConfigSchema` drops its two zero-consumer fields, and
14+
the `tenancy` block is now `.strict()` — an unknown key is a loud parse error
15+
with tombstone guidance instead of a silent zod strip (#1535; precedent
16+
ADR-0056 D8 "compliance-grade config must never merely look live", ADR-0049
17+
enforce-or-remove).
18+
19+
The platform has exactly two tenancy modes, and neither needs object-level
20+
strategy config: database-per-tenant isolation is an environment/deployment
21+
choice (each environment carries its own database URL), and shared-database
22+
row isolation is `tenancy.enabled` + `tenancy.tenantField` (both stay, both
23+
live: sql-driver row scoping, security-plugin org scoping). Cross-tenant
24+
visibility is governed by sharing rules / OWD (ADR-0056),
25+
`externalSharingModel` (ADR-0090 D11), and the object access posture — never
26+
by a blanket boolean.
27+
28+
Migration (delete the keys; nothing read them, so behavior is unchanged):
29+
30+
- FROM `tenancy: { enabled: false, strategy: 'shared' }` → TO `tenancy: { enabled: false }`
31+
- FROM `tenancy: { enabled: true, strategy: '...', tenantField: 'x', crossTenantAccess: false }` → TO `tenancy: { enabled: true, tenantField: 'x' }`
32+
- Wanted per-tenant databases? Deploy per environment (EnvironmentKernelFactory) — not object metadata.
33+
- Wanted cross-tenant visibility? Use sharing rules / OWD or `externalSharingModel`.
34+
35+
The compile-time authorWarn for these fields (#2750) and their liveness-ledger
36+
entries are retired with the removal; the schema itself now carries the
37+
prescription.

content/docs/data-modeling/objects.mdx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,15 @@ enable: {
108108

109109
#### Multi-Tenancy
110110

111+
Row-level tenant isolation in a shared database — the tenant field is injected
112+
on write and enforced on read. Database-per-tenant isolation is an
113+
environment/deployment choice (each environment carries its own database URL),
114+
not object metadata.
115+
111116
```typescript
112117
tenancy: {
113118
enabled: true,
114-
strategy: 'shared', // 'shared' | 'isolated' | 'hybrid'
115119
tenantField: 'tenant_id',
116-
crossTenantAccess: false,
117120
}
118121
```
119122

content/docs/protocol/objectql/schema.mdx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -642,8 +642,7 @@ Isolate data by tenant:
642642
```yaml
643643
name: customer
644644
tenancy:
645-
enabled: true # Automatically filter by the tenant field
646-
strategy: shared # shared (row-level) | isolated (DB per tenant) | hybrid
645+
enabled: true # Automatically filter by the tenant field (row-level isolation)
647646
tenantField: tenant_id
648647
fields:
649648
tenant_id:

content/docs/references/data/object.mdx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,7 @@ Type: `string[]`
228228
| Property | Type | Required | Description |
229229
| :--- | :--- | :--- | :--- |
230230
| **enabled** | `boolean` || Enable multi-tenancy for this object |
231-
| **strategy** | `Enum<'shared' \| 'isolated' \| 'hybrid'>` || Tenant isolation strategy: shared (single DB, row-level), isolated (separate DB per tenant), hybrid (mix) |
232231
| **tenantField** | `string` || Field name for tenant identifier |
233-
| **crossTenantAccess** | `boolean` || Allow cross-tenant data access (with explicit permission) |
234232

235233

236234
---

packages/cli/src/utils/lint-liveness-properties.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,12 @@ describe('lintLivenessProperties', () => {
117117
expect(paths(findings).some((m) => m.includes('`timeout`'))).toBe(true);
118118
});
119119

120-
it('warns on the security-shaped dead props (tenancy.strategy / tool.permissions / permission.contextVariables)', () => {
121-
const tenancy = lintLivenessProperties(objStack({ tenancy: { enabled: true, strategy: 'isolated' } }));
122-
expect(paths(tenancy).some((m) => m.includes('tenancy.strategy'))).toBe(true);
123-
// tenancy.enabled itself is live — must NOT warn.
124-
expect(paths(tenancy).some((m) => m.includes('tenancy.enabled'))).toBe(false);
120+
it('warns on the security-shaped dead props (tool.permissions / permission.contextVariables)', () => {
121+
// tenancy.strategy/crossTenantAccess left this list after spec 15.0 (#2763):
122+
// the schema now REJECTS them (strict tenancy block), so the ledger entries
123+
// are gone and the live tenancy knobs must not warn.
124+
const tenancy = lintLivenessProperties(objStack({ tenancy: { enabled: true, tenantField: 'org_id' } }));
125+
expect(paths(tenancy).some((m) => m.includes('tenancy'))).toBe(false);
125126

126127
const tool = lintLivenessProperties({ tools: [{ name: 't1', permissions: ['crm.admin'] }] });
127128
expect(paths(tool).some((m) => m.includes('`permissions`'))).toBe(true);

packages/platform-objects/src/identity/sys-sso-provider.object.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export const SysSsoProvider = ObjectSchema.create({
4545
// Together: admins see all env providers; non-admins get 403. better-auth's
4646
// own endpoints already read via a system context. (Env-only object — no
4747
// control-plane cross-tenant risk.)
48-
tenancy: { enabled: false, strategy: 'shared' },
48+
tenancy: { enabled: false },
4949
requiredPermissions: ['manage_platform_settings'],
5050
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema.
5151
protection: {

packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ describe('SqlDriver tenant scope (organization_id)', () => {
231231
{
232232
name: 'workspace_item',
233233
// Custom tenant column name — not the conventional organization_id.
234-
tenancy: { enabled: true, strategy: 'shared', tenantField: 'workspace_id', crossTenantAccess: false },
234+
tenancy: { enabled: true, tenantField: 'workspace_id' },
235235
fields: {
236236
workspace_id: { type: 'string' },
237237
name: { type: 'string' },
@@ -257,7 +257,7 @@ describe('SqlDriver tenant scope (organization_id)', () => {
257257
const platformGlobal = [
258258
{
259259
name: 'sys_license',
260-
tenancy: { enabled: false, strategy: 'shared' },
260+
tenancy: { enabled: false },
261261
fields: {
262262
customer: { type: 'string' },
263263
organization_id: { type: 'string' }, // optional owner FK, may be NULL

packages/plugins/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ describe('SqliteWasmDriver tenant scope (organization_id)', () => {
192192
{
193193
name: 'workspace_item',
194194
// Custom tenant column name — not the conventional organization_id.
195-
tenancy: { enabled: true, strategy: 'shared', tenantField: 'workspace_id', crossTenantAccess: false },
195+
tenancy: { enabled: true, tenantField: 'workspace_id' },
196196
fields: {
197197
workspace_id: { type: 'string' },
198198
name: { type: 'string' },

packages/plugins/plugin-security/src/security-plugin.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ describe('SecurityPlugin', () => {
224224
permissionSets: [tenantPolicySet],
225225
// Catalog table without organization_id; opts out of tenancy.
226226
objectFields: ['id', 'manifest_id', 'visibility', 'owner_org_id'],
227-
schemaExtra: { tenancy: { enabled: false, strategy: 'shared' } },
227+
schemaExtra: { tenancy: { enabled: false } },
228228
orgScoping: true,
229229
});
230230
await plugin.init(harness.ctx);
@@ -809,7 +809,7 @@ describe('SecurityPlugin', () => {
809809
const harness = makeMiddlewareCtx({
810810
permissionSets: [tenantPolicySet],
811811
objectFields: ['id', 'name'],
812-
schemaExtra: { tenancy: { enabled: false, strategy: 'shared' } },
812+
schemaExtra: { tenancy: { enabled: false } },
813813
orgScoping: true,
814814
});
815815
await plugin.init(harness.ctx);

packages/spec/liveness/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,13 +189,13 @@ EOF
189189

190190
| Type | live | exp | dead | planned | Notes |
191191
|---|---|---|---|---|---|
192-
| object | 36 || 14 | | versioning/partitioning/cdc tier dead; ObjectCapabilities fully live post-#2707/#2727; `tenancy.strategy`/`crossTenantAccess` inert (only `enabled`+`tenantField` read) |
193-
| field | 54 || 6 || near-healthy; dead = referenceFilters/columnName/index/vectorConfig/fileAttachmentConfig/dependencies, all authorWarn'd |
192+
| object | 37 || 12 | 1 | versioning/partitioning/cdc tier dead; ObjectCapabilities fully live post-#2707/#2727; `tenancy.strategy`/`crossTenantAccess` REMOVED post-15.0 (#2763) — tenancy block is now strict with tombstone guidance |
193+
| field | 55 || 6 || near-healthy; dead = referenceFilters/columnName/index/vectorConfig/fileAttachmentConfig/dependencies, all authorWarn'd |
194194
| flow | 27 || 4 || dead = description/template/nodes.outputSchema/errorHandling.fallbackNodeId (engine uses fault edges) |
195195
| action | 34 | 1 | 1 || `disabled` went LIVE via metadata-admin authoring UI (2026-06 audit missed objectui); only `timeout` dead |
196196
| hook | 11 || 2 || model-healthy; only label/description dead (benign) |
197197
| permission | 32 || 1 || CRUD/FLS/RLS live; `contextVariables` dead (RLS uses current_user.* built-ins only) |
198-
| position | 3 |||| (role's ADR-0090 successor) fully live |
198+
| position | 4 |||| (role's ADR-0090 successor) fully live |
199199
| agent | 14 | 5 | 1 || `tenantId` dead (tenancy comes from request context); autonomy tier experimental |
200200
| tool | 9 | 1 | 1 || `permissions` dead — tool invocation not permission-gated by it |
201201
| skill | 10 |||| fully live |

0 commit comments

Comments
 (0)