Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/remove-tenancy-strategy-crosstenant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@objectstack/spec': minor
---

feat(spec)!: remove `tenancy.strategy` + `tenancy.crossTenantAccess`; tenancy block is now strict (#2763)

> ⚠️ RELEASE NOTE — breaking by strict semver, shipped as `minor` per the
> launch-window policy (owner decision on PR #2962): the fields had zero
> consumers, behavior is unchanged, and the parse error carries the
> migration. Fold into the v15 release page's "What's new in 15.x" section
> when versioning.

BREAKING CHANGE: `TenancyConfigSchema` drops its two zero-consumer fields, and
the `tenancy` block is now `.strict()` — an unknown key is a loud parse error
with tombstone guidance instead of a silent zod strip (#1535; precedent
ADR-0056 D8 "compliance-grade config must never merely look live", ADR-0049
enforce-or-remove).

The platform has exactly two tenancy modes, and neither needs object-level
strategy config: database-per-tenant isolation is an environment/deployment
choice (each environment carries its own database URL), and shared-database
row isolation is `tenancy.enabled` + `tenancy.tenantField` (both stay, both
live: sql-driver row scoping, security-plugin org scoping). Cross-tenant
visibility is governed by sharing rules / OWD (ADR-0056),
`externalSharingModel` (ADR-0090 D11), and the object access posture — never
by a blanket boolean.

Migration (delete the keys; nothing read them, so behavior is unchanged):

- FROM `tenancy: { enabled: false, strategy: 'shared' }` → TO `tenancy: { enabled: false }`
- FROM `tenancy: { enabled: true, strategy: '...', tenantField: 'x', crossTenantAccess: false }` → TO `tenancy: { enabled: true, tenantField: 'x' }`
- Wanted per-tenant databases? Deploy per environment (EnvironmentKernelFactory) — not object metadata.
- Wanted cross-tenant visibility? Use sharing rules / OWD or `externalSharingModel`.

The compile-time authorWarn for these fields (#2750) and their liveness-ledger
entries are retired with the removal; the schema itself now carries the
prescription.
7 changes: 5 additions & 2 deletions content/docs/data-modeling/objects.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,15 @@ enable: {

#### Multi-Tenancy

Row-level tenant isolation in a shared database — the tenant field is injected
on write and enforced on read. Database-per-tenant isolation is an
environment/deployment choice (each environment carries its own database URL),
not object metadata.

```typescript
tenancy: {
enabled: true,
strategy: 'shared', // 'shared' | 'isolated' | 'hybrid'
tenantField: 'tenant_id',
crossTenantAccess: false,
}
```

Expand Down
3 changes: 1 addition & 2 deletions content/docs/protocol/objectql/schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -642,8 +642,7 @@ Isolate data by tenant:
```yaml
name: customer
tenancy:
enabled: true # Automatically filter by the tenant field
strategy: shared # shared (row-level) | isolated (DB per tenant) | hybrid
enabled: true # Automatically filter by the tenant field (row-level isolation)
tenantField: tenant_id
fields:
tenant_id:
Expand Down
35 changes: 31 additions & 4 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ API Operations Enum
## TypeScript Usage

```typescript
import { ApiMethod, Index, ObjectAccessConfig, ObjectCapabilities, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, ObjectRequiredPermissions, PerOperationRequiredPermissions, SoftDeleteConfig, TenancyConfig, VersioningConfig } from '@objectstack/spec/data';
import type { ApiMethod, Index, ObjectAccessConfig, ObjectCapabilities, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, ObjectRequiredPermissions, PerOperationRequiredPermissions, SoftDeleteConfig, TenancyConfig, VersioningConfig } from '@objectstack/spec/data';
import { ApiMethod, Index, Lifecycle, LifecycleClass, ObjectAccessConfig, ObjectCapabilities, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, ObjectRequiredPermissions, PerOperationRequiredPermissions, SoftDeleteConfig, TenancyConfig, VersioningConfig } from '@objectstack/spec/data';
import type { ApiMethod, Index, Lifecycle, LifecycleClass, ObjectAccessConfig, ObjectCapabilities, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, ObjectRequiredPermissions, PerOperationRequiredPermissions, SoftDeleteConfig, TenancyConfig, VersioningConfig } from '@objectstack/spec/data';

// Validate data
const result = ApiMethod.parse(data);
Expand Down Expand Up @@ -58,6 +58,35 @@ const result = ApiMethod.parse(data);
| **partial** | `string` | optional | Partial index condition (SQL WHERE clause for conditional indexes) |


---

## Lifecycle

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **class** | `Enum<'record' \| 'audit' \| 'telemetry' \| 'transient' \| 'event'>` | ✅ | Persistence contract: record (business truth, permanent) | audit (compliance ledger) | telemetry (high-freq log) | transient (ephemeral state) | event (bus messages). |
| **retention** | `Object` | optional | Age-based retention window enforced by the LifecycleService Reaper. |
| **ttl** | `Object` | optional | Per-row TTL auto-expiry (transient/event classes). |
| **storage** | `Object` | optional | Physical storage strategy for high-frequency telemetry (LifecycleService Rotator). |
| **archive** | `Object` | optional | Cold-store archival (LifecycleService Archiver) — audit-class hot→cold hand-off. |
| **reclaim** | `boolean` | optional | Run driver space reclamation (SQLite incremental_vacuum) after sweeping this object. Default true for non-record classes. |


---

## LifecycleClass

### Allowed Values

* `record`
* `audit`
* `telemetry`
* `transient`
* `event`


---

## ObjectAccessConfig
Expand Down Expand Up @@ -199,9 +228,7 @@ Type: `string[]`
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **enabled** | `boolean` | ✅ | Enable multi-tenancy for this object |
| **strategy** | `Enum<'shared' \| 'isolated' \| 'hybrid'>` | ✅ | Tenant isolation strategy: shared (single DB, row-level), isolated (separate DB per tenant), hybrid (mix) |
| **tenantField** | `string` | ✅ | Field name for tenant identifier |
| **crossTenantAccess** | `boolean` | ✅ | Allow cross-tenant data access (with explicit permission) |


---
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/identity/position.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const result = Position.parse(data);
| **name** | `string` | ✅ | Unique position name (lowercase snake_case) |
| **label** | `string` | ✅ | Display label (e.g. VP of Sales) |
| **description** | `string` | optional | |
| **delegatable** | `boolean` | ✅ | ADR-0091 D3: holders may self-service delegate this position, time-boxed (default false). |


---
Expand Down
2 changes: 2 additions & 0 deletions content/docs/references/kernel/execution-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const result = ExecutionContext.parse(data);
| **positions** | `string[]` | ✅ | |
| **principalKind** | `Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>` | optional | |
| **audience** | `Enum<'internal' \| 'external'>` | optional | |
| **posture** | `Enum<'PLATFORM_ADMIN' \| 'TENANT_ADMIN' \| 'MEMBER' \| 'EXTERNAL'>` | optional | ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows. |
| **onBehalfOf** | `Object` | optional | |
| **permissions** | `string[]` | ✅ | |
| **systemPermissions** | `string[]` | optional | |
Expand All @@ -65,6 +66,7 @@ const result = ExecutionContext.parse(data);
| **rlsMembership** | `Record<string, string[]>` | optional | |
| **isSystem** | `boolean` | ✅ | |
| **skipTriggers** | `boolean` | optional | |
| **skipAutomations** | `boolean` | optional | |
| **oauthScopes** | `string[]` | optional | |
| **accessToken** | `string` | optional | |
| **transaction** | `any` | optional | |
Expand Down
81 changes: 76 additions & 5 deletions content/docs/references/security/explain.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,31 @@ enforcement, minus the throw.

Layer order mirrors the runtime pipeline:

principal → required_permissions → object_crud → fls → owd_baseline
tenant_isolation → principal → required_permissions → object_crud → fls →

depth → sharing → vama_bypass → rls.
owd_baseline → depth → sharing → vama_bypass → rls.

[C2 / ADR-0095] Record-grained explanation. The contract carries an optional

`recordId` on the request and, when present, a per-layer `record` attribution

plus a top-level `record` verdict on the response — so the sharing / rls / owd

layers can report the ROW-LEVEL story for one concrete record (which share

admitted it, which filter excluded it, whether the effective row filter

matches). Object-level requests (no `recordId`) stay byte-compatible.

[ADR-0095 D1/D2] The contract also reserves the kernel-chain vocabulary the

β engine + UI will fill: the always-first tenant wall as `tenant_isolation`

(Layer 0), a per-layer `kernelTier` marking Layer 0 vs. business RLS

(Layer 1), and the monotonic posture ladder

(PLATFORM_ADMIN > TENANT_ADMIN > MEMBER > EXTERNAL) on the resolved principal.

<Callout type="info">
**Source:** `packages/spec/src/security/explain.zod.ts`
Expand All @@ -34,8 +56,8 @@ depth → sharing → vama_bypass → rls.
## TypeScript Usage

```typescript
import { AccessMatrix, AccessMatrixEntry, ExplainDecision, ExplainLayer, ExplainOperation, ExplainRequest } from '@objectstack/spec/security';
import type { AccessMatrix, AccessMatrixEntry, ExplainDecision, ExplainLayer, ExplainOperation, ExplainRequest } from '@objectstack/spec/security';
import { AccessMatrix, AccessMatrixEntry, AuthzPosture, ExplainDecision, ExplainLayer, ExplainMatchedRule, ExplainOperation, ExplainRecordAttribution, ExplainRequest } from '@objectstack/spec/security';
import type { AccessMatrix, AccessMatrixEntry, AuthzPosture, ExplainDecision, ExplainLayer, ExplainMatchedRule, ExplainOperation, ExplainRecordAttribution, ExplainRequest } from '@objectstack/spec/security';

// Validate data
const result = AccessMatrix.parse(data);
Expand Down Expand Up @@ -74,6 +96,20 @@ const result = AccessMatrix.parse(data);
| **sharingModel** | `string` | optional | |


---

## AuthzPosture

ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows.

### Allowed Values

* `PLATFORM_ADMIN`
* `TENANT_ADMIN`
* `MEMBER`
* `EXTERNAL`


---

## ExplainDecision
Expand All @@ -88,6 +124,7 @@ const result = AccessMatrix.parse(data);
| **principal** | `Object` | ✅ | |
| **layers** | `Object[]` | ✅ | |
| **readFilter** | `any` | optional | |
| **record** | `Object` | optional | Row-level verdict for the specific record; set only for record-grained requests. |


---
Expand All @@ -98,10 +135,28 @@ const result = AccessMatrix.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **layer** | `Enum<'principal' \| 'required_permissions' \| 'object_crud' \| 'fls' \| 'owd_baseline' \| 'depth' \| 'sharing' \| 'vama_bypass' \| 'rls'>` | ✅ | |
| **layer** | `Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| 'fls' \| 'owd_baseline' \| 'depth' \| 'sharing' \| 'vama_bypass' \| 'rls'>` | ✅ | |
| **kernelTier** | `Enum<'layer_0_tenant' \| 'layer_1_business'>` | optional | ADR-0095 kernel layer: layer_0_tenant = the always-first org wall; layer_1_business = business RLS/sharing/ownership. |
| **verdict** | `Enum<'grants' \| 'denies' \| 'narrows' \| 'widens' \| 'neutral' \| 'not_applicable'>` | ✅ | |
| **detail** | `string` | ✅ | |
| **contributors** | `Object[]` | ✅ | |
| **record** | `Object` | optional | Row-level determination for the specific record under explanation; set only for record-grained requests. |


---

## ExplainMatchedRule

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **kind** | `Enum<'tenant_filter' \| 'owd_baseline' \| 'ownership' \| 'record_share' \| 'sharing_rule' \| 'team' \| 'territory' \| 'rls_policy'>` | ✅ | The row-visibility source kind evaluated for this record at this layer. |
| **name** | `string` | ✅ | Stable identifier of the concrete rule, share, or policy that was evaluated. |
| **grants** | `Enum<'read' \| 'edit' \| 'full'>` | optional | Access level a sharing source grants on the record (mirrors SharingLevel). |
| **via** | `string` | optional | How the rule reached the principal — recipient group/position, ownership, or the matching criteria. |
| **predicate** | `any` | optional | The row predicate this rule contributed, when it is filter-shaped (null = unrestricted). |
| **effect** | `Enum<'admits' \| 'excludes' \| 'neutral'>` | ✅ | The rule's effect on THIS record: admits, excludes, or neutral. |


---
Expand All @@ -119,6 +174,21 @@ const result = AccessMatrix.parse(data);
* `purge`


---

## ExplainRecordAttribution

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **outcome** | `Enum<'admitted' \| 'excluded' \| 'not_evaluated'>` | ✅ | This layer's row-level outcome for the record: admitted, excluded, or not_evaluated (skipped/not row-scoped). |
| **rowFilter** | `any` | optional | The effective row predicate this layer contributed for the record set (null = unrestricted, __deny_all__ = zero rows). |
| **matchesRecord** | `boolean` | optional | Whether the specific record satisfies rowFilter — the judgement behind outcome. |
| **rules** | `Object[]` | ✅ | Concrete rules, shares, or policies this layer evaluated against the record, in evaluation order. |
| **detail** | `string` | optional | Human-readable, record-specific explanation of this layer's outcome. |


---

## ExplainRequest
Expand All @@ -129,6 +199,7 @@ const result = AccessMatrix.parse(data);
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | |
| **operation** | `Enum<'read' \| 'create' \| 'update' \| 'delete' \| 'transfer' \| 'restore' \| 'purge'>` | ✅ | |
| **recordId** | `string` | optional | Optional id of one concrete record to explain at row granularity; omitted = object-level (pre-C2) request. |
| **userId** | `string` | optional | |


Expand Down
1 change: 1 addition & 0 deletions content/docs/references/security/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"title": "Security Protocol",
"pages": [
"explain",
"misc",
"permission",
"rls",
"sharing",
Expand Down
38 changes: 38 additions & 0 deletions content/docs/references/security/misc.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
title: Misc
description: Misc protocol schemas
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}

<Callout type="info">
**Source:** `packages/spec/src/security/misc.zod.ts`
</Callout>

## TypeScript Usage

```typescript
import { CapabilityDeclaration } from '@objectstack/spec/security';
import type { CapabilityDeclaration } from '@objectstack/spec/security';

// Validate data
const result = CapabilityDeclaration.parse(data);
```

---

## CapabilityDeclaration

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Stable capability key referenced by systemPermissions / requiredPermissions |
| **label** | `string` | optional | Human label shown in Setup |
| **description** | `string` | optional | What holding this capability permits |
| **scope** | `Enum<'platform' \| 'org'>` | ✅ | platform = a platform-wide power; org = scoped to an organization |
| **packageId** | `string` | optional | [ADR-0086 D3] Owning package id (author-declared fallback; absent = registry-stamped) |


---

25 changes: 2 additions & 23 deletions content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ to the field name and is used as the request-body key).
## TypeScript Usage

```typescript
import { ActionAi, ActionLocation, ActionParam, ActionType } from '@objectstack/spec/ui';
import type { ActionAi, ActionLocation, ActionParam, ActionType } from '@objectstack/spec/ui';
import { ActionAi, ActionLocation, ActionType } from '@objectstack/spec/ui';
import type { ActionAi, ActionLocation, ActionType } from '@objectstack/spec/ui';

// Validate data
const result = ActionAi.parse(data);
Expand Down Expand Up @@ -86,27 +86,6 @@ const result = ActionAi.parse(data);
* `global_nav`


---

## ActionParam

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | optional | |
| **field** | `string` | optional | Snake case identifier (lowercase with underscores only) |
| **objectOverride** | `string` | optional | Snake case identifier (lowercase with underscores only) |
| **label** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) |
| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>` | optional | |
| **required** | `boolean` | ✅ | |
| **options** | `Object[]` | optional | |
| **placeholder** | `string` | optional | |
| **helpText** | `string` | optional | |
| **defaultValue** | `any` | optional | |
| **defaultFromRow** | `boolean` | optional | |


---

## ActionType
Expand Down
11 changes: 6 additions & 5 deletions packages/cli/src/utils/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,12 @@ describe('lintLivenessProperties', () => {
expect(paths(findings).some((m) => m.includes('`timeout`'))).toBe(true);
});

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

const tool = lintLivenessProperties({ tools: [{ name: 't1', permissions: ['crm.admin'] }] });
expect(paths(tool).some((m) => m.includes('`permissions`'))).toBe(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const SysSsoProvider = ObjectSchema.create({
// Together: admins see all env providers; non-admins get 403. better-auth's
// own endpoints already read via a system context. (Env-only object — no
// control-plane cross-tenant risk.)
tenancy: { enabled: false, strategy: 'shared' },
tenancy: { enabled: false },
requiredPermissions: ['manage_platform_settings'],
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema.
protection: {
Expand Down
Loading