diff --git a/.changeset/adr-0090-showcase-permission-zoo.md b/.changeset/adr-0090-showcase-permission-zoo.md
new file mode 100644
index 0000000000..b4e81c4c34
--- /dev/null
+++ b/.changeset/adr-0090-showcase-permission-zoo.md
@@ -0,0 +1,43 @@
+---
+"@objectstack/spec": minor
+"@objectstack/example-showcase": minor
+"@objectstack/plugin-sharing": patch
+"@objectstack/plugin-security": patch
+---
+
+ADR-0090 permission-model zoo + docs alignment.
+
+**Showcase (`@objectstack/example-showcase`)** now exercises the full Permission
+Model v2 authoring surface and is guarded by a new runtime dogfood test
+(`showcase-permission-zoo.dogfood.test.ts`): typed `definePosition`/
+`definePermissionSet`/`defineSharingRule` factories; six flat positions (the
+stale pre-D3 `parent` fields are gone); permission sets covering CRUD+FLS+RLS,
+org-depth read/write asymmetry (`readScope: 'org'` / `writeScope: 'own'`),
+View-All (auditor) and Modify-All (ops) bypasses, `systemPermissions`
+(`setup.access`), the `isDefault` everyone-suggestion (incl. personal-data
+grants on the `private`-OWD note object), a guest-safe set for the `guest`
+anchor (D9), and a delegated-administration `adminScope` bounded to a seeded
+`sys_business_unit` subtree (D12). Objects gain `externalSharingModel` dials
+(D11). A committed `access-matrix.json` opts the showcase into the D6 snapshot
+gate. Hierarchy depths (`own_and_reports`/`unit`/`unit_and_below`) are
+deliberately NOT authored — they are enterprise (`hierarchy-security`) and the
+open runtime fails closed; BU-shaped visibility is demonstrated via the
+enforced `unit_and_subordinates` sharing-rule recipient instead.
+
+**`@objectstack/spec`**: `defineStack` strict cross-reference validation no
+longer rejects permission grants or seed datasets that target platform-provided
+objects (`sys_`/`cloud_`/`ai_` prefixes) — a delegated-admin set carrying CRUD
+on the RBAC link tables (ADR-0090 D12) and an app seeding the business-unit
+tree are legitimate shapes; the typo net stays intact for the stack's own
+objects. Stale pre-ADR-0090 vocabulary in zod docstrings (rls/territory/
+sharing/tool/agent) is rewritten; the auto-generated references (including the
+previously missing `security/explain.mdx`) are regenerated.
+
+**Docs**: `protocol/objectql/security.mdx` rewritten to the v2 model (no
+profiles, positions, canonical OWD four + D1 private default +
+`externalSharingModel`, position-scoped RLS, enforced sharing recipients);
+`isProfile` scrubbed from every authoring example; the dead
+`/docs/references/identity/role` link fixed; implementation-status and
+plugin READMEs aligned. Remaining rename misses are tracked in #2722
+(RLSUserContext.role), #2723 (portal `profiles`), #2724 (sys_record_share
+`role` enum).
diff --git a/content/docs/ai/actions-as-tools.mdx b/content/docs/ai/actions-as-tools.mdx
index 78f13745cb..e063218645 100644
--- a/content/docs/ai/actions-as-tools.mdx
+++ b/content/docs/ai/actions-as-tools.mdx
@@ -210,7 +210,7 @@ await aiService.chatWithTools(messages, tools, {
actor: {
id: currentUser.id,
name: currentUser.displayName,
- roles: currentUser.roles,
+ positions: currentUser.positions,
permissions: currentUser.permissions,
},
conversationId,
diff --git a/content/docs/ai/index.mdx b/content/docs/ai/index.mdx
index 08337814f5..da25323785 100644
--- a/content/docs/ai/index.mdx
+++ b/content/docs/ai/index.mdx
@@ -167,7 +167,7 @@ end-user's `ExecutionContext` so tool calls respect row-level security:
```typescript
const reply = await aiService.chatWithTools(messages, tools, {
toolExecutionContext: {
- actor: { id: currentUser.id, name: currentUser.displayName, roles: currentUser.roles, permissions: currentUser.permissions },
+ actor: { id: currentUser.id, name: currentUser.displayName, positions: currentUser.positions, permissions: currentUser.permissions },
conversationId,
environmentId,
},
diff --git a/content/docs/concepts/architecture.mdx b/content/docs/concepts/architecture.mdx
index 96f76184cf..e499789a63 100644
--- a/content/docs/concepts/architecture.mdx
+++ b/content/docs/concepts/architecture.mdx
@@ -152,11 +152,12 @@ That's the job of the other layers.
// src/permissions/sales_rep.permission.ts
import { definePermissionSet } from '@objectstack/spec';
-// A permission set (here used as a profile) keyed by object and field.
+// A permission set keyed by object and field — the only capability
+// container (ADR-0090: capability is the union of held sets; the old
+// profile concept was removed).
export const SalesRepPermission = definePermissionSet({
name: 'sales_rep',
label: 'Sales Rep',
- isProfile: true,
objects: {
customer: {
allowCreate: true,
diff --git a/content/docs/concepts/index.mdx b/content/docs/concepts/index.mdx
index 619569e1e6..a69302c4da 100644
--- a/content/docs/concepts/index.mdx
+++ b/content/docs/concepts/index.mdx
@@ -133,11 +133,11 @@ This ensures ObjectStack apps can run on Node.js + PostgreSQL today, Python + SQ
| Layer | Responsibility | Example |
| :--- | :--- | :--- |
| **Protocol** | Defines capabilities | A row-level-security policy slot (`operation` + `using` clause) |
-| **App** | Defines business logic | `{ operation: 'select', using: "role == 'admin'" }` |
+| **App** | Defines business logic | `{ operation: 'select', using: "owner_id == current_user.id" }` |
| **Engine** | Enforces the logic | Compiles the `using` condition into a query filter |
-CRUD permissions (`allowRead`, `allowEdit`, …) are simple booleans — they grant or deny an operation. Record-level *conditional* access is a separate mechanism: [Row-Level Security](/docs/concepts/architecture) policies, whose `using` clause is a CEL predicate over context variables such as `current_user.roles` and `current_user.id`.
+CRUD permissions (`allowRead`, `allowEdit`, …) are simple booleans — they grant or deny an operation. Record-level *conditional* access is a separate mechanism: [Row-Level Security](/docs/concepts/architecture) policies, whose `using` clause is a CEL predicate over context variables such as `current_user.positions` and `current_user.id`.
### Single Source of Truth
diff --git a/content/docs/data-modeling/objects.mdx b/content/docs/data-modeling/objects.mdx
index 82f84db261..719247d9c3 100644
--- a/content/docs/data-modeling/objects.mdx
+++ b/content/docs/data-modeling/objects.mdx
@@ -183,7 +183,7 @@ indexes: [
| `active` | `boolean` | Is object active (default: `true`) |
| `isSystem` | `boolean` | System object, protected from deletion (default: `false`) |
| `abstract` | `boolean` | Abstract base, cannot be instantiated (default: `false`) |
-| `sharingModel` | `enum` | Org-Wide Default record visibility (ADR-0055/0056). Canonical: `'private'`, `'public_read'`, `'public_read_write'`, `'controlled_by_parent'` (detail visibility derived from its master). Legacy aliases: `'read'`=public_read, `'read_write'`/`'full'`=public_read_write |
+| `sharingModel` | `enum` | Org-Wide Default record visibility (ADR-0055/0056/0090). Canonical four only: `'private'`, `'public_read'`, `'public_read_write'`, `'controlled_by_parent'` (detail visibility derived from its master). The legacy aliases (`'read'`, `'read_write'`, `'full'`) were removed from the enum (ADR-0090 D4) — authoring rejects them. Unset on a custom object resolves to `'private'` (ADR-0090 D1) |
| `keyPrefix` | `string` | Short prefix for record IDs (e.g. `'001'`) |
| `validations` | `ValidationRule[]` | Object-level validation rules (see [Validation](/docs/data-modeling/validation)) |
diff --git a/content/docs/getting-started/common-patterns.mdx b/content/docs/getting-started/common-patterns.mdx
index 5780732a93..005ca08bac 100644
--- a/content/docs/getting-started/common-patterns.mdx
+++ b/content/docs/getting-started/common-patterns.mdx
@@ -424,7 +424,6 @@ import { definePermissionSet } from '@objectstack/spec';
export const HrRepPermission = definePermissionSet({
name: 'hr_rep',
label: 'HR Rep',
- isProfile: true,
objects: {
employee: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false },
},
diff --git a/content/docs/getting-started/examples.mdx b/content/docs/getting-started/examples.mdx
index dd6aa0e89a..7413536ead 100644
--- a/content/docs/getting-started/examples.mdx
+++ b/content/docs/getting-started/examples.mdx
@@ -230,10 +230,10 @@ hotcrm/
│ ├── dashboards/ # Executive, Sales, Service dashboards
│ ├── data/ # Seed data for all objects
│ ├── flows/ # 5 automation flows: case escalation, approval, ...
-│ ├── profiles/ # 5 security profiles: admin, sales rep, manager, ...
+│ ├── security/ # 5 permission sets: admin, sales rep, manager, ...
│ ├── rag/ # 4 RAG pipelines: product info, competitive intel, ...
│ ├── reports/ # 7 reports with tabular, summary, matrix formats
-│ └── sharing/ # Sharing rules, role hierarchy, org defaults
+│ └── sharing/ # Sharing rules, business-unit hierarchy, org defaults
```
### Features Showcased
@@ -244,7 +244,7 @@ hotcrm/
| **State Machine** | Lead lifecycle: `new → contacted → qualified → converted` with `Lead.state.ts` |
| **AI Agents** | Sales Assistant, Service Agent, Revenue Intelligence, Email Campaign, Lead Enrichment |
| **RAG Pipelines** | Product info, competitive intel, sales knowledge, support knowledge |
-| **Security Model** | 5 profiles (Admin, Manager, Rep, Agent, Marketing), sharing rules, role hierarchy |
+| **Security Model** | 5 permission sets (Admin, Manager, Rep, Agent, Marketing), sharing rules, business-unit hierarchy |
| **Automation** | Lead conversion, case escalation, opportunity approval, campaign enrollment, quote generation |
| **Custom APIs** | `POST /lead-convert`, `GET /pipeline-stats` |
| **Seed Data** | 50+ realistic records across all objects |
diff --git a/content/docs/getting-started/glossary.mdx b/content/docs/getting-started/glossary.mdx
index 29aaf1e7a1..18477b22cf 100644
--- a/content/docs/getting-started/glossary.mdx
+++ b/content/docs/getting-started/glossary.mdx
@@ -53,7 +53,7 @@ External communication layer including REST contracts, API discovery, Realtime s
Business process automation including Workflows (state machines), Flows (visual logic), and Webhooks (HTTP callbacks).
### Identity Protocol
-User, organization, and profile schemas for identity management.
+User, organization, and position schemas for identity management.
### Security Protocol
RBAC, permissions, policy, and access-control schemas.
diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx
index 4f4482a463..c6649c0864 100644
--- a/content/docs/getting-started/quick-reference.mdx
+++ b/content/docs/getting-started/quick-reference.mdx
@@ -170,13 +170,13 @@ Access control, permissions, and row-level security.
## Identity Protocol (4 schemas)
-User identity, organizations, and role management.
+User identity, organizations, and position management.
| Protocol | Source File | Key Schemas | Purpose |
|:---------|:-----------|:------------|:--------|
| **[Identity](/docs/references/identity/identity)** | `identity.zod.ts` | Identity, User | User identity management |
| **[Organization](/docs/references/identity/organization)** | `organization.zod.ts` | Organization | Multi-organization support |
-| **[Role](/docs/references/identity/role)** | `role.zod.ts` | Role | Role-based access control |
+| **[Position](/docs/references/identity/position)** | `position.zod.ts` | Position | Permission-set distribution (岗位, ADR-0090) |
| **[SCIM](/docs/references/identity/scim)** | `scim.zod.ts` | SCIMUser, SCIMGroup | SCIM 2.0 provisioning |
## Cloud Protocol (4 schemas)
diff --git a/content/docs/index.mdx b/content/docs/index.mdx
index 013a8f6b64..0403135bf0 100644
--- a/content/docs/index.mdx
+++ b/content/docs/index.mdx
@@ -40,7 +40,7 @@ Each module documents one capability in depth — overview first, then guides, w
-
+
diff --git a/content/docs/kernel/contracts/auth-service.mdx b/content/docs/kernel/contracts/auth-service.mdx
index 26c2e2c67b..a658c322a8 100644
--- a/content/docs/kernel/contracts/auth-service.mdx
+++ b/content/docs/kernel/contracts/auth-service.mdx
@@ -108,7 +108,7 @@ Resolves the authenticated user from a request. Returns `undefined` when the req
```typescript
const user = await authService.getCurrentUser?.(request);
if (user) {
- console.log(`Request from ${user.name} (${user.roles?.join(', ')})`);
+ console.log(`Request from ${user.name} (${user.positions?.join(', ')})`);
}
```
diff --git a/content/docs/protocol/objectql/security.mdx b/content/docs/protocol/objectql/security.mdx
index f288271183..64e3f077f2 100644
--- a/content/docs/protocol/objectql/security.mdx
+++ b/content/docs/protocol/objectql/security.mdx
@@ -36,10 +36,10 @@ const accounts = await dataEngine.find('account');
// owner_id == current_user.id (CEL) → compiled to a row filter by the RLS compiler
```
-The model is composed of three distinct metadata types (see `packages/spec/src/security` and `packages/spec/src/identity`):
+The model is composed of three distinct metadata types (see `packages/spec/src/security` and `packages/spec/src/identity`), per the [Permission Model v2 vocabulary](/docs/permissions) (ADR-0090):
-- **Permission Set** (`permission` / `profile` metadata) — the CRUD/FLS grant. A *profile* is a permission set with `isProfile: true`.
-- **Role** (`role` metadata) — the reporting hierarchy used for ownership-based visibility roll-up.
+- **Permission Set** (`permission` metadata) — the only capability container: CRUD, FLS, RLS, scope depth, View/Modify-All, system permissions. There is no profile concept — a user's capability is the *union* of every set they hold.
+- **Position** (`position` metadata) — the flat distribution layer: who holds which permission sets. Hierarchy lives in the **business-unit tree** (`sys_business_unit`), never on positions.
- **Sharing Rule** + **Organization-Wide Defaults (OWD)** — broaden the baseline visibility set for an object.
---
@@ -81,7 +81,6 @@ Object permissions live inside a **permission set**. Each entry in the `objects`
# sales_rep.permission.yml
name: sales_rep # lowercase snake_case, required
label: Sales Representative
-isProfile: false # set true to make this a base profile
objects:
account:
allowCreate: true
@@ -106,7 +105,7 @@ Beyond the four CRUD flags, the schema also exposes lifecycle and super-user gra
| `viewAllRecords` | Read every record, bypassing sharing & ownership |
| `modifyAllRecords` | Write every record, bypassing sharing & ownership |
-> Permission sets are layered: a user has exactly one **profile** (`isProfile: true`) plus zero or more add-on permission sets. Grants are additive (a `true` anywhere wins).
+> Permission sets are **additive-only**: a user's effective capability is the union of every set they hold — directly, via positions, or via the built-in `everyone` baseline (ADR-0090 D5). A `true` anywhere wins; there are no subtraction sets — to withhold, don't grant.
### Permission Check Flow
@@ -129,7 +128,7 @@ Return filtered result
## 2. Row-Level Security
-Row-level filtering is expressed as **RLS policies** (`RowLevelSecurityPolicySchema` in `packages/spec/src/security/rls.zod.ts`). Policies carry a **CEL** `using` clause (for SELECT/UPDATE/DELETE) and/or a `check` clause (for INSERT/UPDATE) — canonical CEL since ADR-0058; a legacy SQL-style `=`/`IN (...)` predicate still compiles via a **deprecated bridge** (warns). Multiple policies for one object are combined with OR (most-permissive wins). Available context variables are the **unique identifiers and membership sets** the runtime pre-resolves: equality predicates may use `current_user.id`, `current_user.email` (the unique, seedable owner anchor), or `current_user.organization_id`; set-membership predicates may use `id in current_user.org_user_ids`, `id in current_user.roles`, or any §7.3.1 set staged in `ExecutionContext.rlsMembership`. Display `name` and arbitrary user fields are **intentionally not** resolvable — only unique identifiers, so an ownership predicate can never leak access through a name collision.
+Row-level filtering is expressed as **RLS policies** (`RowLevelSecurityPolicySchema` in `packages/spec/src/security/rls.zod.ts`). Policies carry a **CEL** `using` clause (for SELECT/UPDATE/DELETE) and/or a `check` clause (for INSERT/UPDATE) — canonical CEL since ADR-0058; a legacy SQL-style `=`/`IN (...)` predicate still compiles via a **deprecated bridge** (warns). Multiple policies for one object are combined with OR (most-permissive wins). Available context variables are the **unique identifiers and membership sets** the runtime pre-resolves: equality predicates may use `current_user.id`, `current_user.email` (the unique, seedable owner anchor), or `current_user.organization_id`; set-membership predicates may use `id in current_user.org_user_ids`, `'manager' in current_user.positions`, or any §7.3.1 set staged in `ExecutionContext.rlsMembership`. Display `name` and arbitrary user fields are **intentionally not** resolvable — only unique identifiers, so an ownership predicate can never leak access through a name collision.
Policies can be attached to a permission set via its `rowLevelSecurity` array, or registered as standalone metadata.
@@ -162,11 +161,11 @@ rowLevelSecurity:
check: 'organization_id == current_user.organization_id'
```
-The `RLS` helper factory exports `RLS.tenantPolicy(object)` / `RLS.ownerPolicy(object)` / `RLS.rolePolicy(...)` to generate these.
+The `RLS` helper factory exports `RLS.tenantPolicy(object)` / `RLS.ownerPolicy(object)` / `RLS.positionPolicy(...)` to generate these.
-### Role-Scoped Access
+### Position-Scoped Access
-`roles` restricts a policy to specific roles; omit it to apply to everyone (except `bypassRoles`):
+`positions` restricts a policy to users holding one of the named positions (ADR-0090 D3; formerly `roles`); omit it to apply to everyone:
```yaml
rowLevelSecurity:
@@ -174,7 +173,7 @@ rowLevelSecurity:
object: task
operation: select
using: 'assigned_to_id in current_user.team_member_ids' # pre-resolved §7.3.1 set — NOT a subquery (ADR-0055)
- roles: [manager, director]
+ positions: [manager, director]
```
### Regional / Territory Access
@@ -185,7 +184,7 @@ rowLevelSecurity:
object: account
operation: select
using: 'territory_id in current_user.territory_ids' # pre-resolved set (current_user.region is not exposed)
- roles: [sales_rep]
+ positions: [sales_rep]
```
### Time-Based Access
@@ -264,12 +263,12 @@ fields:
## 5. Sharing Rules & Organization-Wide Defaults
-The baseline visibility of an object is set by its **Organization-Wide Default (OWD)** — the `sharingModel` field on the object (`packages/spec/src/data/object.zod.ts`), one of `private`, `read`, `read_write`, `full`. **Sharing rules** then grant *additional* access on top of that baseline.
+The baseline visibility of an object is set by its **Organization-Wide Default (OWD)** — the `sharingModel` field on the object (`packages/spec/src/data/object.zod.ts`), one of the four canonical values `private`, `public_read`, `public_read_write`, `controlled_by_parent` (ADR-0090 D4 — the legacy aliases `read`/`read_write`/`full` were **removed from the enum**; authoring rejects them). A custom object with no declared `sharingModel` resolves to **`private`** (ADR-0090 D1 — the unset state no longer means public). An optional `externalSharingModel` (same enum, default `private`, never wider than the internal value) is the stricter dial for external portal/partner principals (ADR-0090 D11). **Sharing rules** then grant *additional* access on top of that baseline — sharing only ever widens, RLS only ever narrows.
```yaml
# account.object.yml
name: account
-sharingModel: private # owner-only by default
+sharingModel: private # owner-only baseline (also the D1 default)
```
### Criteria-Based Sharing
@@ -284,13 +283,15 @@ object: account
accessLevel: read # read | edit | full
condition: 'record.account_type == "Enterprise"'
sharedWith:
- type: role # user | group | role | role_and_subordinates | guest
+ type: position # user | group | position | unit_and_subordinates | guest
value: sales_rep
```
+`unit_and_subordinates` expands a **business-unit subtree**: the unit named by `value` plus every descendant unit's members (ADR-0057 D5 / ADR-0090 D3 — the former position-tree walk was re-homed onto the `sys_business_unit` tree).
+
### Owner-Based Sharing
-Share records owned by one group/role with another (`OwnerSharingRuleSchema`):
+Share records owned by one group with another (`OwnerSharingRuleSchema`):
```yaml
name: share_west_region
@@ -298,13 +299,15 @@ type: owner
object: account
accessLevel: edit
ownedBy:
- type: role
+ type: position
value: west_region_reps
sharedWith:
- type: role
+ type: position
value: west_region_managers
```
+> **Enforcement status.** Criteria rules with `user` / `position` / `unit_and_subordinates` recipients compile and enforce (the CEL condition lowers to a runtime filter that materializes `sys_record_share` grants, ADR-0058 D3). Owner-type rules and `group`/`guest` recipients are `[experimental — not enforced]`: the seed bootstrap skips them (logged) rather than seeding a permissive match-all (ADR-0049).
+
> `accessLevel` is one of `read`, `edit`, or `full`. `full` additionally grants transfer/share/delete.
### Public Share Links
@@ -384,9 +387,8 @@ fields:
### Principle of Least Privilege
```yaml
-# Restrictive profile: read only what you own
+# Restrictive permission set: read only what you own
name: account_owner
-isProfile: true
objects:
account:
allowRead: true
diff --git a/content/docs/references/ai/agent.mdx b/content/docs/references/ai/agent.mdx
index efb111c10e..b0409894f8 100644
--- a/content/docs/references/ai/agent.mdx
+++ b/content/docs/references/ai/agent.mdx
@@ -82,7 +82,7 @@ const result = AIKnowledge.parse(data);
| **knowledge** | `Object` | optional | RAG access |
| **active** | `boolean` | ✅ | |
| **access** | `string[]` | optional | Who can chat with this agent |
-| **permissions** | `string[]` | optional | Required permissions or roles |
+| **permissions** | `string[]` | optional | Required permission-set capabilities |
| **tenantId** | `string` | optional | Tenant/Organization ID |
| **visibility** | `Enum<'global' \| 'organization' \| 'private'>` | ✅ | [EXPERIMENTAL — NOT ENFORCED, #1901] Intended listing scope. No runtime consumer yet; use access/permissions for real gating. |
| **planning** | `Object` | optional | Autonomous reasoning and planning configuration |
diff --git a/content/docs/references/ai/tool.mdx b/content/docs/references/ai/tool.mdx
index a3451dd650..be5081bd73 100644
--- a/content/docs/references/ai/tool.mdx
+++ b/content/docs/references/ai/tool.mdx
@@ -39,7 +39,7 @@ const result = Tool.parse(data);
| **outputSchema** | `Record` | optional | JSON Schema for tool output |
| **objectName** | `string` | optional | Target object name (snake_case) |
| **requiresConfirmation** | `boolean` | ✅ | Require user confirmation before execution |
-| **permissions** | `string[]` | optional | Required permissions or roles |
+| **permissions** | `string[]` | optional | Required permission-set capabilities |
| **active** | `boolean` | ✅ | Whether the tool is enabled |
| **builtIn** | `boolean` | ✅ | Platform built-in tool flag |
| **protection** | `Object` | optional | Package author protection block — lock policy for this tool. |
diff --git a/content/docs/references/security/explain.mdx b/content/docs/references/security/explain.mdx
new file mode 100644
index 0000000000..966c550f19
--- /dev/null
+++ b/content/docs/references/security/explain.mdx
@@ -0,0 +1,136 @@
+---
+title: Explain
+description: Explain protocol schemas
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
+
+[ADR-0090 D6] Access-explanation contract — `explain(principal, object,
+
+operation)` as a first-class API.
+
+The explain engine (`@objectstack/plugin-security`) walks the SAME code
+
+paths as the enforcement middleware — the same permission-set resolution,
+
+the same evaluator, the same RLS compiler — and reports what each layer of
+
+the evaluation pipeline contributed to the final decision. "Explained by
+
+construction": the report can never drift from enforcement because it IS
+
+enforcement, minus the throw.
+
+Layer order mirrors the runtime pipeline:
+
+principal → required_permissions → object_crud → fls → owd_baseline →
+
+depth → sharing → vama_bypass → rls.
+
+
+**Source:** `packages/spec/src/security/explain.zod.ts`
+
+
+## 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';
+
+// Validate data
+const result = AccessMatrix.parse(data);
+```
+
+---
+
+## AccessMatrix
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **version** | `number` | ✅ | |
+| **entries** | `Object[]` | ✅ | |
+
+
+---
+
+## AccessMatrixEntry
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **permissionSet** | `string` | ✅ | |
+| **object** | `string` | ✅ | |
+| **create** | `boolean` | ✅ | |
+| **read** | `boolean` | ✅ | |
+| **edit** | `boolean` | ✅ | |
+| **delete** | `boolean` | ✅ | |
+| **viewAllRecords** | `boolean` | ✅ | |
+| **modifyAllRecords** | `boolean` | ✅ | |
+| **readScope** | `string` | optional | |
+| **writeScope** | `string` | optional | |
+| **sharingModel** | `string` | optional | |
+
+
+---
+
+## ExplainDecision
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **allowed** | `boolean` | ✅ | |
+| **object** | `string` | ✅ | |
+| **operation** | `Enum<'read' \| 'create' \| 'update' \| 'delete' \| 'transfer' \| 'restore' \| 'purge'>` | ✅ | |
+| **principal** | `Object` | ✅ | |
+| **layers** | `Object[]` | ✅ | |
+| **readFilter** | `any` | optional | |
+
+
+---
+
+## ExplainLayer
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **layer** | `Enum<'principal' \| 'required_permissions' \| 'object_crud' \| 'fls' \| 'owd_baseline' \| 'depth' \| 'sharing' \| 'vama_bypass' \| 'rls'>` | ✅ | |
+| **verdict** | `Enum<'grants' \| 'denies' \| 'narrows' \| 'widens' \| 'neutral' \| 'not_applicable'>` | ✅ | |
+| **detail** | `string` | ✅ | |
+| **contributors** | `Object[]` | ✅ | |
+
+
+---
+
+## ExplainOperation
+
+### Allowed Values
+
+* `read`
+* `create`
+* `update`
+* `delete`
+* `transfer`
+* `restore`
+* `purge`
+
+
+---
+
+## ExplainRequest
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **object** | `string` | ✅ | |
+| **operation** | `Enum<'read' \| 'create' \| 'update' \| 'delete' \| 'transfer' \| 'restore' \| 'purge'>` | ✅ | |
+| **userId** | `string` | optional | |
+
+
+---
+
diff --git a/content/docs/references/security/index.mdx b/content/docs/references/security/index.mdx
index 4b94ae0a29..2885a067a9 100644
--- a/content/docs/references/security/index.mdx
+++ b/content/docs/references/security/index.mdx
@@ -6,6 +6,7 @@ description: Complete reference for all security protocol schemas
This section contains all protocol schemas for the security layer of ObjectStack.
+
diff --git a/content/docs/references/security/meta.json b/content/docs/references/security/meta.json
index 1e70554bfd..9d9dcff0cd 100644
--- a/content/docs/references/security/meta.json
+++ b/content/docs/references/security/meta.json
@@ -1,6 +1,7 @@
{
"title": "Security Protocol",
"pages": [
+ "explain",
"permission",
"rls",
"sharing",
diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx
index 92e2af7597..4d1063964f 100644
--- a/content/docs/references/security/permission.mdx
+++ b/content/docs/references/security/permission.mdx
@@ -24,13 +24,29 @@ Refined with enterprise data lifecycle controls:
## TypeScript Usage
```typescript
-import { FieldPermission, ObjectAccessScope, ObjectPermission, PermissionSet } from '@objectstack/spec/security';
-import type { FieldPermission, ObjectAccessScope, ObjectPermission, PermissionSet } from '@objectstack/spec/security';
+import { AdminScope, FieldPermission, ObjectAccessScope, ObjectPermission, PermissionSet } from '@objectstack/spec/security';
+import type { AdminScope, FieldPermission, ObjectAccessScope, ObjectPermission, PermissionSet } from '@objectstack/spec/security';
// Validate data
-const result = FieldPermission.parse(data);
+const result = AdminScope.parse(data);
```
+---
+
+## AdminScope
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **businessUnit** | `string` | ✅ | [ADR-0090 D12] Delegation boundary: sys_business_unit.name of the subtree root |
+| **includeSubtree** | `boolean` | ✅ | Cover descendant business units too (default true) |
+| **manageAssignments** | `boolean` | ✅ | Manage user↔position assignments within the subtree |
+| **manageBindings** | `boolean` | ✅ | Manage position↔permission-set bindings within the subtree |
+| **authorEnvironmentSets** | `boolean` | ✅ | Author environment-owned permission sets |
+| **assignablePermissionSets** | `string[]` | ✅ | Allowlist of permission-set names the delegate may hand out |
+
+
---
## FieldPermission
@@ -96,6 +112,7 @@ const result = FieldPermission.parse(data);
| **tabPermissions** | `Record>` | optional | App/tab visibility: visible, hidden, default_on (shown by default), default_off (available but hidden initially) |
| **rowLevelSecurity** | `Object[]` | optional | Row-level security policies (see rls.zod.ts for full spec) |
| **contextVariables** | `Record` | optional | Context variables for RLS evaluation |
+| **adminScope** | `Object` | optional | [ADR-0090 D12] Scoped delegated-administration grant (BU subtree + assignable-set allowlist) |
---
diff --git a/content/docs/references/security/rls.mdx b/content/docs/references/security/rls.mdx
index 5e8507889a..26f5e8a34e 100644
--- a/content/docs/references/security/rls.mdx
+++ b/content/docs/references/security/rls.mdx
@@ -15,9 +15,9 @@ and Salesforce Criteria-Based Sharing Rules.
Row-Level Security (RLS) allows you to control which rows users can access
-in database tables based on their identity and role. Unlike object-level
+in database tables based on their identity and positions. Unlike
-permissions (CRUD), RLS provides record-level filtering.
+object-level permissions (CRUD), RLS provides record-level filtering.
## Use Cases
@@ -97,15 +97,17 @@ using: 'organization_id == current_user.organization_id'
## Salesforce Sharing Rules Comparison
-Salesforce uses "Sharing Rules" and "Role Hierarchy" for record-level access.
+Salesforce uses "Sharing Rules" and a visibility hierarchy for record-level
+
+access (our equivalent hierarchy is the business-unit tree, ADR-0090 D3).
ObjectStack RLS provides similar functionality with more flexibility.
Salesforce:
-- Criteria-Based Sharing: Share records matching criteria with users/roles
+- Criteria-Based Sharing: Share records matching criteria with users/groups
-- Owner-Based Sharing: Share records based on owner's role
+- Owner-Based Sharing: Share records based on who owns them
- Manual Sharing: Individual record sharing
@@ -123,7 +125,7 @@ ObjectStack RLS:
2. **Define INSERT/UPDATE CHECK Policies**: Prevent data leakage
-3. **Use Role-Based Policies**: Apply different rules to different roles
+3. **Use Position-Scoped Policies**: Apply different rules to different positions
4. **Test Thoroughly**: RLS can have complex interactions
diff --git a/content/docs/references/security/sharing.mdx b/content/docs/references/security/sharing.mdx
index 3ad113f440..ffb5e253d8 100644
--- a/content/docs/references/security/sharing.mdx
+++ b/content/docs/references/security/sharing.mdx
@@ -51,7 +51,7 @@ const result = OWDModel.parse(data);
| **accessLevel** | `Enum<'read' \| 'edit' \| 'full'>` | ✅ | |
| **sharedWith** | `Object` | ✅ | The recipient of the shared access |
| **type** | `string` | ✅ | |
-| **ownedBy** | `Object` | ✅ | Source group/role whose records are being shared |
+| **ownedBy** | `Object` | ✅ | Source group/position whose records are being shared |
---
diff --git a/content/docs/references/security/territory.mdx b/content/docs/references/security/territory.mdx
index e32d75ea37..081b4adc16 100644
--- a/content/docs/references/security/territory.mdx
+++ b/content/docs/references/security/territory.mdx
@@ -7,7 +7,9 @@ description: Territory protocol schemas
Territory Management Protocol
-Defines a matrix reporting structure that exists parallel to the Role Hierarchy.
+Defines a matrix reporting structure that exists parallel to the
+
+business-unit hierarchy (ADR-0090 D3 — the org tree is `sys_business_unit`).
USE CASE:
@@ -17,15 +19,15 @@ USE CASE:
- Strategic Accounts (Account-based: "Strategic Accounts")
-DIFFERENCE FROM ROLE:
+DIFFERENCE FROM THE BUSINESS-UNIT TREE:
-- Role: Hierarchy of PEOPLE (Who reports to whom). Stable. HR-driven.
+- Business unit: Hierarchy of PEOPLE (org structure). Stable. HR-driven.
- Territory: Hierarchy of ACCOUNTS/REVENUE (Who owns which market). Flexible. Sales-driven.
- One User can be assigned to MANY Territories (Matrix).
-- One User has only ONE Role (Tree).
+- One User belongs to one primary business unit (Tree).
**Source:** `packages/spec/src/security/territory.zod.ts`
diff --git a/content/docs/releases/implementation-status.mdx b/content/docs/releases/implementation-status.mdx
index 119eefcbd4..522feefb42 100644
--- a/content/docs/releases/implementation-status.mdx
+++ b/content/docs/releases/implementation-status.mdx
@@ -287,8 +287,8 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
- Client SDK supports bearer token header — but token validation requires the auth plugin
- Auth route (`/auth/*`) only appears in Discovery when the auth plugin is registered
- Fine-grained authorization (RLS, sharing, territory) is internal to the auth plugin
-- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, roles, permissions). Default `member_default` permission set ships a wildcard RLS rule `organization_id == current_user.organization_id` plus per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics now uses the same reusable read scope via `security.getReadFilter`, so dataset-bound dashboards/reports do not bypass RLS. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_role_permission_set`. **Anonymous traffic is denied by default** (the ADR-0056 D2 default-deny flip landed: `requireAuth` defaults to `true`); an explicit `requireAuth: false` opt-out logs a boot-time warning, and public forms do not depend on any fall-open — they carry a declaration-derived `publicFormGrant` (ADR-0056 Option A).
-- **OWD / sharing-model enforcement is live and proven end-to-end (ADR-0056)**: `private`, `public_read`, `public_read_write`, and `controlled_by_parent` are enforced through `plugin-sharing` + `plugin-security` and verified by dogfood proofs over the real HTTP stack. `object.sharingModel` now accepts the canonical OWD vocabulary (`private` / `public_read` / `public_read_write` / `controlled_by_parent`) alongside the legacy `read` / `read_write` / `full` spellings (D1). RLS owner policies resolve `current_user.email` in addition to `id` / `organization_id` / `roles` (#2054). Permission sets may declare `isDefault: true` to act as the app-declared fallback profile (D7).
+- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, positions, permissions). Default `member_default` permission set ships a wildcard RLS rule `organization_id == current_user.organization_id` plus per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics now uses the same reusable read scope via `security.getReadFilter`, so dataset-bound dashboards/reports do not bypass RLS. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_position_permission_set`. **Anonymous traffic is denied by default** (the ADR-0056 D2 default-deny flip landed: `requireAuth` defaults to `true`); an explicit `requireAuth: false` opt-out logs a boot-time warning, and public forms do not depend on any fall-open — they carry a declaration-derived `publicFormGrant` (ADR-0056 Option A).
+- **OWD / sharing-model enforcement is live and proven end-to-end (ADR-0056)**: `private`, `public_read`, `public_read_write`, and `controlled_by_parent` are enforced through `plugin-sharing` + `plugin-security` and verified by dogfood proofs over the real HTTP stack. `object.sharingModel` accepts the canonical OWD vocabulary only (`private` / `public_read` / `public_read_write` / `controlled_by_parent`) — the legacy `read` / `read_write` / `full` aliases were removed from the enum (ADR-0090 D4), and an unset `sharingModel` on a custom object resolves to `private` (ADR-0090 D1). RLS owner policies resolve `current_user.email` in addition to `id` / `organization_id` / `positions` (#2054). Permission sets may declare `isDefault: true` as the install-time suggestion to bind the set to the built-in `everyone` position (ADR-0090 D5, superseding the ADR-0056 D7 fallback-profile mechanism).
---
@@ -426,9 +426,9 @@ cloud / EE.
- [x] Analytics RLS bridge — `@objectstack/service-analytics` auto-bridges to `security.getReadFilter(object, context)` and fails closed when read-scope resolution cannot be safely applied
- [x] Multi-tenancy — verified cross-organization isolation on `pnpm dev:crm` (Alice@OrgAlpha vs. Bob@OrgBeta only see their own records across `sys_organization`, `sys_member`, `sys_user`, and `sys_*_permission_set` link tables)
- [x] Legacy `objectql.registerTenantMiddleware` removed — SecurityPlugin is now the sole tenant-isolation authority
-- [x] Organization-Wide Defaults / sharing model — `private`, `public_read`, `public_read_write`, and `controlled_by_parent` enforced via `plugin-sharing` + `plugin-security`, proven by dogfood over the real HTTP stack (ADR-0056). `object.sharingModel` accepts the canonical OWD vocabulary alongside the legacy `read` / `read_write` / `full` spellings (D1)
-- [x] Sharing Rule evaluator — owner + criteria rules re-evaluated on `afterInsert` / `afterUpdate` (`plugin-sharing/rule-hooks.ts`); recipients include user / role / group and configurable `role_and_subordinates` role-hierarchy widening (ADR-0056 D6)
-- [x] App-declarable default profile — a permission set may set `isDefault: true` to be the fallback profile for unassigned users (ADR-0056 D7)
+- [x] Organization-Wide Defaults / sharing model — `private`, `public_read`, `public_read_write`, and `controlled_by_parent` enforced via `plugin-sharing` + `plugin-security`, proven by dogfood over the real HTTP stack (ADR-0056). Canonical vocabulary only — legacy aliases removed from the enum (ADR-0090 D4); unset custom-object OWD resolves to `private` (ADR-0090 D1)
+- [x] Sharing Rule evaluator — criteria rules re-evaluated on `afterInsert` / `afterUpdate` (`plugin-sharing/rule-hooks.ts`); enforced recipients are user / position / `unit_and_subordinates` (business-unit-subtree widening, ADR-0057 D5 / ADR-0090 D3); owner-type rules and group/guest recipients remain `[experimental — not enforced]`
+- [x] Everyone-baseline suggestion — a permission set may set `isDefault: true` as the install-time suggestion to bind it to the built-in `everyone` position; resolved per-request as an additive baseline, no fallback cliff (ADR-0090 D5)
- [x] Default-deny for anonymous traffic — the global default-deny **flip landed** (ADR-0056 D2): `requireAuth` defaults to `true`, explicit `requireAuth: false` opt-outs warn at boot, and public forms self-authorize via `publicFormGrant` (Option A)
- [ ] Studio RLS visual editor
- [ ] Per-user×org permission cache
diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx
index 32b9b099b2..f73991a288 100644
--- a/content/docs/ui/forms.mdx
+++ b/content/docs/ui/forms.mdx
@@ -45,7 +45,7 @@ Only the spec'd whitelist of form fields is accepted; everything else (status, o
> and the SecurityPlugin authorizes **only** create + the immediate read-back on
> exactly that object, never anything else and never the anonymous fall-open. So
> public forms work under secure-by-default (`requireAuth: true`) with **no**
-> `guest_portal` profile. The `guest_portal` permission set + `anonymous` flag
+> `guest_portal` permission set. The `guest_portal` permission set + `anonymous` flag
> are still attached for **back-compat** (object hooks that detect a guest via a
> falsy `ctx.user?.id`), but they are no longer the authorization mechanism.
@@ -97,23 +97,24 @@ export default defineView({
> - Anything not in the `sections[].fields[]` whitelist is silently stripped at submit time. Treat the whitelist as the form's authoritative "what the public is allowed to set" list.
> - Multiple form views per object are fine — only the one(s) with `sharing.allowAnonymous === true` are exposed.
-## 2. (Optional) Create the `guest_portal` profile
+## 2. (Optional) Create the `guest_portal` permission set
-Authorization no longer depends on this profile — the declaration-derived
+Authorization no longer depends on this set — the declaration-derived
`publicFormGrant` (see the note above) is what permits the insert. You only need
-a `guest_portal` profile if you rely on the legacy back-compat path (e.g. an
-older runtime, or object hooks that branch on the `guest_portal` permission).
+a `guest_portal` permission set if you rely on the legacy back-compat path (e.g.
+an older runtime, or object hooks that branch on the `guest_portal` permission).
When present it is still attached to the anonymous context, so keep it
-INSERT-only on the target object.
+INSERT-only on the target object — the guest-safe shape (ADR-0090 D9).
```ts
-// hotcrm/src/profiles/guest-portal.profile.ts
-import { PermissionSetSchema } from '@objectstack/spec/security';
+// hotcrm/src/security/guest-portal.permission.ts
+import { definePermissionSet } from '@objectstack/spec/security';
-export default PermissionSetSchema.parse({
+// Guest-safe capability: INSERT-only on the intake objects. (`isProfile` no
+// longer exists — the Profile concept was removed by ADR-0090 D2.)
+export default definePermissionSet({
name: 'guest_portal',
label: 'Public Form Submitters',
- isProfile: true,
objects: {
lead: { allowCreate: true }, // no read/edit/delete
case: { allowCreate: true },
diff --git a/content/docs/ui/index.mdx b/content/docs/ui/index.mdx
index 6e9035c597..7a44f48b6e 100644
--- a/content/docs/ui/index.mdx
+++ b/content/docs/ui/index.mdx
@@ -52,7 +52,7 @@ export const CrmApp = App.create({
-
+
diff --git a/content/docs/ui/role-based-interfaces.mdx b/content/docs/ui/role-based-interfaces.mdx
index d55f020f8b..66b24a34cd 100644
--- a/content/docs/ui/role-based-interfaces.mdx
+++ b/content/docs/ui/role-based-interfaces.mdx
@@ -1,9 +1,9 @@
---
-title: Role-based interfaces
+title: Audience-based interfaces
description: The same data serves different audiences. Give end users a curated app/page and keep builder surfaces — Studio, raw object tables, automation config — out of their view. Separate consumer and builder paths by default.
---
-# Role-based interfaces
+# Audience-based interfaces
## Scenario
@@ -18,7 +18,7 @@ description: The same data serves different audiences. Give end users a curated
| **End user (consumer)** | a curated **App** → `page` / `view` (interface mode) | `App.requiredPermissions`, permission-set `tabPermissions`, nav-item gating |
| **Builder / admin** | **Setup / Studio**, raw object tables (data mode) | capabilities: `setup.access`, `studio.access`, `manage_metadata` |
-The built-in profiles already encode this split: `member_default` and `viewer_readonly` do **not** carry `studio.access` / `manage_metadata`, so Studio and schema-design surfaces are invisible to them; `admin_full_access` and `organization_admin` do (see [`default-permission-sets.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-security/src/objects/default-permission-sets.ts)).
+The built-in permission sets already encode this split: `member_default` and `viewer_readonly` do **not** carry `studio.access` / `manage_metadata`, so Studio and schema-design surfaces are invisible to them; `admin_full_access` and `organization_admin` do (see [`default-permission-sets.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-security/src/objects/default-permission-sets.ts)).
### 1. Gate the app and its navigation
@@ -40,7 +40,7 @@ defineApp({
Each nav item supports three independent gates ([`app.zod`](/docs/references/ui/app)):
- `requiredPermissions` — RBAC capability (e.g. `manage_metadata`).
-- `visible` — a CEL predicate (e.g. `P\`'org_admin' in current_user.roles\``).
+- `visible` — a CEL predicate (e.g. `P\`'org_admin' in current_user.positions\``).
- `requiresObject` / `requiresService` — hide unless a runtime object/service is installed.
**Hide, don't disable.** A disabled-but-visible builder entry is still noise and still confuses end users. Gated nav items are *not rendered* for users who lack the capability.
@@ -55,11 +55,11 @@ Use a [curated view](/docs/ui/field-grouping-and-order) (selected fields, progre
## Why
-This is the lesson from Airtable's surfaces: the **Automations tab is visible to every base collaborator, even read-only** — so end users see builder chrome they can't use and don't understand. ObjectStack avoids that by making "consumer vs builder" a capability boundary that the built-in profiles already draw, plus per-nav gating that *omits* (not merely disables) what a user can't use. Action gates are **dual-surface** (ADR-0066 D4): the ActionRunner hides/disables an action in the UI **and** the server rejects the call — no "UI-gated but server-open" footgun.
+This is the lesson from Airtable's surfaces: the **Automations tab is visible to every base collaborator, even read-only** — so end users see builder chrome they can't use and don't understand. ObjectStack avoids that by making "consumer vs builder" a capability boundary that the built-in permission sets already draw, plus per-nav gating that *omits* (not merely disables) what a user can't use. Action gates are **dual-surface** (ADR-0066 D4): the ActionRunner hides/disables an action in the UI **and** the server rejects the call — no "UI-gated but server-open" footgun.
## Runnable example
-- Profiles: [`default-permission-sets.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-security/src/objects/default-permission-sets.ts) — `member_default` / `viewer_readonly` vs `organization_admin` / `admin_full_access`.
+- Permission sets: [`default-permission-sets.ts`](https://github.com/objectstack-ai/framework/blob/main/packages/plugins/plugin-security/src/objects/default-permission-sets.ts) — `member_default` / `viewer_readonly` vs `organization_admin` / `admin_full_access`.
- Apps: [`examples/app-showcase/src/apps`](https://github.com/objectstack-ai/framework/tree/main/examples/app-showcase/src/apps).
## Anti-patterns
diff --git a/examples/app-crm/src/security/sales-positions.ts b/examples/app-crm/src/security/sales-positions.ts
index d410219d15..691f4ee454 100644
--- a/examples/app-crm/src/security/sales-positions.ts
+++ b/examples/app-crm/src/security/sales-positions.ts
@@ -4,7 +4,8 @@ import { definePosition } from '@objectstack/spec/identity';
import { definePermissionSet } from '@objectstack/spec/security';
/**
- * Example roles — a small sales hierarchy.
+ * Example positions — flat distribution groups for a small sales team
+ * (positions carry no hierarchy — ADR-0090 D3).
*/
export const SalesRepPosition = definePosition({
name: 'sales_rep',
diff --git a/examples/app-showcase/access-matrix.json b/examples/app-showcase/access-matrix.json
new file mode 100644
index 0000000000..c2411fbd6d
--- /dev/null
+++ b/examples/app-showcase/access-matrix.json
@@ -0,0 +1,347 @@
+{
+ "version": 1,
+ "entries": [
+ {
+ "permissionSet": "showcase_auditor",
+ "object": "showcase_inquiry",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": true,
+ "modifyAllRecords": false,
+ "sharingModel": "private"
+ },
+ {
+ "permissionSet": "showcase_auditor",
+ "object": "showcase_invoice",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": true,
+ "modifyAllRecords": false,
+ "sharingModel": "public_read_write"
+ },
+ {
+ "permissionSet": "showcase_auditor",
+ "object": "showcase_invoice_line",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": true,
+ "modifyAllRecords": false,
+ "sharingModel": "controlled_by_parent"
+ },
+ {
+ "permissionSet": "showcase_auditor",
+ "object": "showcase_private_note",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": true,
+ "modifyAllRecords": false,
+ "sharingModel": "private"
+ },
+ {
+ "permissionSet": "showcase_contributor",
+ "object": "showcase_account",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "sharingModel": "public_read_write"
+ },
+ {
+ "permissionSet": "showcase_contributor",
+ "object": "showcase_invoice",
+ "create": true,
+ "read": true,
+ "edit": true,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "sharingModel": "public_read_write"
+ },
+ {
+ "permissionSet": "showcase_contributor",
+ "object": "showcase_invoice_line",
+ "create": true,
+ "read": true,
+ "edit": true,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "sharingModel": "controlled_by_parent"
+ },
+ {
+ "permissionSet": "showcase_contributor",
+ "object": "showcase_project",
+ "create": false,
+ "read": true,
+ "edit": true,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "sharingModel": "public_read_write"
+ },
+ {
+ "permissionSet": "showcase_contributor",
+ "object": "showcase_task",
+ "create": true,
+ "read": true,
+ "edit": true,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "sharingModel": "public_read_write"
+ },
+ {
+ "permissionSet": "showcase_executive",
+ "object": "showcase_inquiry",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "readScope": "org",
+ "sharingModel": "private"
+ },
+ {
+ "permissionSet": "showcase_executive",
+ "object": "showcase_private_note",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "readScope": "org",
+ "sharingModel": "private"
+ },
+ {
+ "permissionSet": "showcase_field_ops_delegate",
+ "object": "sys_business_unit",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false
+ },
+ {
+ "permissionSet": "showcase_field_ops_delegate",
+ "object": "sys_business_unit_member",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false
+ },
+ {
+ "permissionSet": "showcase_field_ops_delegate",
+ "object": "sys_permission_set",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false
+ },
+ {
+ "permissionSet": "showcase_field_ops_delegate",
+ "object": "sys_position",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false
+ },
+ {
+ "permissionSet": "showcase_field_ops_delegate",
+ "object": "sys_user",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false
+ },
+ {
+ "permissionSet": "showcase_field_ops_delegate",
+ "object": "sys_user_position",
+ "create": true,
+ "read": true,
+ "edit": true,
+ "delete": true,
+ "viewAllRecords": false,
+ "modifyAllRecords": false
+ },
+ {
+ "permissionSet": "showcase_guest_portal",
+ "object": "showcase_announcement",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "sharingModel": "public_read"
+ },
+ {
+ "permissionSet": "showcase_guest_portal",
+ "object": "showcase_inquiry",
+ "create": true,
+ "read": false,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "sharingModel": "private"
+ },
+ {
+ "permissionSet": "showcase_manager",
+ "object": "showcase_contact",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "readScope": "org",
+ "sharingModel": "private"
+ },
+ {
+ "permissionSet": "showcase_manager",
+ "object": "showcase_inquiry",
+ "create": false,
+ "read": true,
+ "edit": true,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "readScope": "org",
+ "writeScope": "own",
+ "sharingModel": "private"
+ },
+ {
+ "permissionSet": "showcase_member_default",
+ "object": "showcase_account",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "sharingModel": "public_read_write"
+ },
+ {
+ "permissionSet": "showcase_member_default",
+ "object": "showcase_announcement",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "sharingModel": "public_read"
+ },
+ {
+ "permissionSet": "showcase_member_default",
+ "object": "showcase_inquiry",
+ "create": true,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "sharingModel": "private"
+ },
+ {
+ "permissionSet": "showcase_member_default",
+ "object": "showcase_private_note",
+ "create": true,
+ "read": true,
+ "edit": true,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "sharingModel": "private"
+ },
+ {
+ "permissionSet": "showcase_member_default",
+ "object": "showcase_product",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "sharingModel": "public_read_write"
+ },
+ {
+ "permissionSet": "showcase_member_default",
+ "object": "showcase_project",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "sharingModel": "public_read_write"
+ },
+ {
+ "permissionSet": "showcase_member_default",
+ "object": "showcase_task",
+ "create": true,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "sharingModel": "public_read_write"
+ },
+ {
+ "permissionSet": "showcase_ops",
+ "object": "showcase_announcement",
+ "create": true,
+ "read": true,
+ "edit": true,
+ "delete": true,
+ "viewAllRecords": false,
+ "modifyAllRecords": true,
+ "sharingModel": "public_read"
+ },
+ {
+ "permissionSet": "showcase_ops",
+ "object": "showcase_inquiry",
+ "create": false,
+ "read": true,
+ "edit": true,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "readScope": "org",
+ "writeScope": "org",
+ "sharingModel": "private"
+ },
+ {
+ "permissionSet": "showcase_ops",
+ "object": "showcase_invoice",
+ "create": false,
+ "read": true,
+ "edit": false,
+ "delete": false,
+ "viewAllRecords": false,
+ "modifyAllRecords": false,
+ "sharingModel": "public_read_write"
+ }
+ ]
+}
diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts
index b450f35881..b5f89ea99c 100644
--- a/examples/app-showcase/src/coverage.ts
+++ b/examples/app-showcase/src/coverage.ts
@@ -121,10 +121,15 @@ export const KIND_COVERAGE: Record = {
// ── security ──
permission: {
status: 'demonstrated',
- files: ['src/security/index.ts'],
- notes: 'Includes MemberDefault — the isDefault permission set (ADR-0090 D5 suggestion flag).',
+ files: ['src/security/permission-sets.ts'],
+ notes:
+ 'Full ADR-0090 authoring surface: CRUD+FLS+RLS, scope depth (own/org read-write asymmetry; hierarchy depths are enterprise hierarchy-security), VAMA, system permissions, the isDefault everyone-suggestion (D5), guest-safe capability (D9), and adminScope delegated administration (D12). Snapshot-gated by access-matrix.json (D6). tabPermissions is not demoable in a single-app package (ADR-0019 D3).',
+ },
+ position: {
+ status: 'demonstrated',
+ files: ['src/security/positions.ts'],
+ notes: 'Flat positions only (no hierarchy — ADR-0090 D3); everyone/guest are built-in anchors, never declared.',
},
- position: { status: 'demonstrated', files: ['src/security/index.ts'] },
// ── ai ──
agent: {
@@ -235,7 +240,8 @@ export const COVERAGE = {
'automation/flows/index.ts — CRUD quartet (create: InboundTaskWebhookFlow, update: ReassignWizardFlow, get+delete: InquiryPurgeFlow), screen/approval/wait/subflow/map/connector_action across the chain; BPMN gateway/boundary forms waived (FLOW_NODE_WAIVERS) in favor of the ADR-0031 structured containers.',
},
capabilityChains: {
- security: 'security/index.ts — roles + permission set (CRUD + FLS + RLS) + sharing + policy',
+ security:
+ 'security/* — positions + permission sets (CRUD + FLS + RLS + depth + VAMA + system/tab permissions + adminScope) + sharing rules (position & BU-subtree recipients) + per-object OWD/externalSharingModel + seeded sys_business_unit tree + access-matrix.json gate (ADR-0090)',
automation: 'automation/flows/index.ts (incl. approval nodes) + automation/webhooks/index.ts + automation/jobs/index.ts + system/emails/index.ts',
},
i18nThemingPortals: {
diff --git a/examples/app-showcase/src/data/objects/account.object.ts b/examples/app-showcase/src/data/objects/account.object.ts
index a930a391c8..8145b9e039 100644
--- a/examples/app-showcase/src/data/objects/account.object.ts
+++ b/examples/app-showcase/src/data/objects/account.object.ts
@@ -18,6 +18,10 @@ export const Account = ObjectSchema.create({
// object is RLS-owned / intentionally public; without this the new secure
// default (unset OWD => private) would owner-filter it.
sharingModel: 'public_read_write',
+ // [ADR-0090 D11] External principals may READ accounts (portal users see
+ // the customer directory) but never write them — a non-default external
+ // dial that is still strictly ≤ the internal `public_read_write`.
+ externalSharingModel: 'public_read',
label: 'Account',
pluralLabel: 'Accounts',
icon: 'building',
diff --git a/examples/app-showcase/src/data/objects/announcement.object.ts b/examples/app-showcase/src/data/objects/announcement.object.ts
index de216597d2..0ed9376792 100644
--- a/examples/app-showcase/src/data/objects/announcement.object.ts
+++ b/examples/app-showcase/src/data/objects/announcement.object.ts
@@ -20,9 +20,16 @@ export const Announcement = ObjectSchema.create({
icon: 'megaphone',
description: 'A team announcement everyone can read but only its owner can edit — `read` OWD (ADR-0056).',
- // Everyone reads; owner writes. Canonical OWD name (ADR-0056 D1); `read` is
- // the legacy alias. No RLS authored.
+ // Everyone reads; owner writes. Canonical OWD vocabulary only — the legacy
+ // aliases were removed from the enum (ADR-0090 D4). No RLS authored.
sharingModel: 'public_read',
+ // [ADR-0090 D11] The EXTERNAL dial: portal/partner principals
+ // (`audience: 'external'`) get their own, stricter baseline. Internal
+ // announcements are public_read for employees but PRIVATE to external
+ // users — an external portal user sees only announcements they own or were
+ // explicitly shared. Must never be wider than the internal model
+ // (validated: external ≤ internal).
+ externalSharingModel: 'private',
fields: {
title: Field.text({ label: 'Title', required: true, searchable: true, maxLength: 160 }),
diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts
index 6309a8a0d5..278d97a1a7 100644
--- a/examples/app-showcase/src/data/seed/index.ts
+++ b/examples/app-showcase/src/data/seed/index.ts
@@ -1,6 +1,6 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-import { defineSeed } from '@objectstack/spec/data';
+import { defineSeed, SeedSchema } from '@objectstack/spec/data';
import { cel } from '@objectstack/spec';
import { Account } from '../objects/account.object.js';
import { Preference } from '../objects/preference.object.js';
@@ -130,6 +130,37 @@ const businessUnits = defineSeed(BusinessUnit, {
],
});
+/**
+ * The REAL org tree — `sys_business_unit` rows (platform identity object),
+ * distinct from the `showcase_business_unit` DEMO object above (which only
+ * feeds the `tree` view). This tree is what the ADR-0090 permission model
+ * actually evaluates against:
+ * • depth grants (`readScope: 'unit' / 'unit_and_below'`) resolve membership
+ * through it (Setup → Access Control → Business Units, org-chart view);
+ * • the `share_new_inquiries_with_field_ops` sharing rule expands the
+ * `bu_field_ops` SUBTREE (Field Operations + West/East Coast);
+ * • the `showcase_field_ops_delegate` adminScope is bounded by it.
+ *
+ * Seeded with EXPLICIT ids so metadata can reference units statically (the
+ * sharing-rule recipient wants the row id; the adminScope wants the `name`).
+ * The tree is normally environment-owned admin data — seeding it here plays
+ * the admin's part so the permission demos work on a fresh boot. Users can't
+ * be seeded (they sign up), so user↔unit membership (`sys_business_unit_member`)
+ * and position assignments stay runtime admin actions.
+ */
+const orgUnits = SeedSchema.parse({
+ object: 'sys_business_unit',
+ mode: 'upsert',
+ externalId: 'id',
+ records: [
+ { id: 'bu_acme', name: 'Acme Corporation', code: 'ACME', kind: 'company', active: true },
+ { id: 'bu_field_ops', name: 'Field Operations', code: 'FOPS', kind: 'division', parent_business_unit_id: 'bu_acme', active: true },
+ { id: 'bu_west_coast', name: 'West Coast', code: 'FOPS-W', kind: 'office', parent_business_unit_id: 'bu_field_ops', active: true },
+ { id: 'bu_east_coast', name: 'East Coast', code: 'FOPS-E', kind: 'office', parent_business_unit_id: 'bu_field_ops', active: true },
+ { id: 'bu_hq_finance', name: 'HQ Finance', code: 'FIN', kind: 'department', parent_business_unit_id: 'bu_acme', active: true },
+ ],
+});
+
const teams = defineSeed(Team, {
mode: 'upsert',
externalId: 'name',
@@ -255,4 +286,4 @@ const preferences = defineSeed(Preference, {
],
});
-export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, teams, memberships, fieldZoo, invoices, invoiceLines, preferences];
+export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, preferences];
diff --git a/examples/app-showcase/src/docs/showcase_tour_security.md b/examples/app-showcase/src/docs/showcase_tour_security.md
index e6a1e4036c..8fd0d2c48a 100644
--- a/examples/app-showcase/src/docs/showcase_tour_security.md
+++ b/examples/app-showcase/src/docs/showcase_tour_security.md
@@ -1,38 +1,108 @@
---
title: "Tour · Security"
-description: Guided tour of the security domain — roles, permission sets, the default profile, sharing rules, and row-level security.
+description: Guided tour of the security domain — positions, permission sets, scope depth, VAMA, audience anchors, sharing rules, delegated administration, and the access-matrix gate.
---
# Guided tour — Security
-Everything in this domain lives under `src/security/index.ts`.
+The showcase ships the complete ADR-0090 permission model. The whole model in
+four sentences: a user's **capability** is the *union* of every **permission
+set** they hold; **positions** decide who holds which sets; the
+**business-unit tree** and manager chain decide *how deep* a grant sees; each
+object's **OWD** (`sharingModel`) sets the record baseline — **sharing** only
+widens it, **RLS** only narrows it.
-## Roles, permission sets, profile
+Everything lives under `src/security/` (positions, permission sets, sharing
+rules), `src/data/objects/` (per-object OWD), and `src/data/seed/` (the
+`sys_business_unit` org tree).
-The showcase ships a role hierarchy, permission sets with object CRUD +
-field-level security + row-level security, and `showcase_member_default` —
-a permission set with `isProfile: true`, the fallback **profile**
-(ADR-0056).
+## Capability — permission sets
-The `showcase_contributor` permission set as a live object-access matrix:
+`showcase_contributor` layers object CRUD + field-level security (budget
+fields read-only) + row-level security (own tasks/invoices only, plus an
+ADR-0058 D4 write-time `check`):
```metadata
type: permission
name: showcase_contributor
```
-## Sharing
+The other sets each demonstrate one axis:
-Sharing rules extend record access beyond ownership — see the
-`sharingRules` wired in `objectstack.config.ts` and the private-note
-object for an owner-only counter-example.
+- `showcase_manager` — **scope depth** (ADR-0057 D1) with read/write
+ asymmetry: org-wide read over inquiries, edit own only (`readScope:
+ 'org'`, `writeScope: 'own'`). The intermediate hierarchy depths
+ (`own_and_reports`/`unit`/`unit_and_below`) are enterprise
+ (`hierarchy-security`); the open edition demonstrates BU-shaped visibility
+ via the sharing rule below instead.
+- `showcase_executive` — org-wide read via `readScope: 'org'` (depth, not a
+ bypass).
+- `showcase_auditor` — **View-All** (`viewAllRecords`): reads every private
+ record, holds no write bit. High-privilege: the publish linter blocks it
+ from `everyone`/`guest` bindings.
+- `showcase_ops` — **system permissions** (`setup.access` opens the Setup
+ app for non-admins — apps declaring `requiredPermissions` appear in
+ `/me/apps` only when the caller's union carries them) and **Modify-All**
+ on announcements (a `public_read` object, so the bypass matters).
+- `showcase_member_default` — `isDefault: true`, the **`everyone`
+ suggestion** (ADR-0090 D5): the read-mostly baseline every authenticated
+ member holds *additively* (no fallback cliff).
+- `showcase_guest_portal` — guest-safe capability (read announcements,
+ create inquiries) for the built-in **`guest`** position (ADR-0090 D9);
+ binding it is an admin action in Setup.
+- `showcase_field_ops_delegate` — **delegated administration**
+ (ADR-0090 D12): an `adminScope` bounded to the Field Operations subtree
+ with an assignable-set allowlist; no self-escalation.
+
+## Record baseline — OWD, internal and external
+
+Every object declares its `sharingModel` explicitly (the unset state no
+longer exists — ADR-0090 D1). The showcase covers all four canonical values:
+`showcase_private_note` (`private`), `showcase_announcement` (`public_read`),
+most demo objects (`public_read_write`), and `showcase_invoice_line`
+(`controlled_by_parent`). Two objects also declare the **external dial**
+(`externalSharingModel`, ADR-0090 D11): announcements are `private` to
+portal users; accounts are `public_read`.
+
+## Widening — sharing rules
+
+Criteria rules compile their CEL condition to an enforced filter and
+materialize `sys_record_share` grants (ADR-0058 D3). Recipients demonstrate
+both enforced kinds: `position` (red projects → execs; compound-condition
+high-value red projects → managers) and `unit_and_subordinates`
+(new inquiries → the Field Operations **business-unit subtree**). The
+owner-based rule is kept as an authoring-shape example only — it is skipped
+at seed time (`[experimental]`, ADR-0049: nothing silently over-shares).
+
+## The org tree
+
+`src/data/seed/` seeds real `sys_business_unit` rows (Acme → Field
+Operations → West/East Coast; HQ Finance) with explicit ids so metadata can
+reference them. Browse it in Setup → Access Control → Business Units
+(org-chart view). User↔unit membership and position assignments are runtime
+admin actions — users sign up, they are not seeded.
+
+## The gate — access matrix
+
+`access-matrix.json` next to `objectstack.config.ts` is the ADR-0090 D6
+snapshot: `objectstack compile` derives the (permission set × object) matrix
+from the declarations above and **fails the build on drift** with a semantic
+diff ("`showcase_auditor` gains delete on `showcase_invoice`") until the
+snapshot is regenerated with `--update-access-matrix`. The snapshot's git
+diff is the review artifact. The same build runs the ADR-0090 D7 security
+posture linter (unset OWD, retired aliases, external-wider-than-internal,
+wildcard VAMA, high-privilege anchor suggestions, forbidden vocabulary).
## See it enforced
-Log in as a non-admin member (create one in Setup → Users, assign the
-member profile): the Field Zoo's permission-gated fields mask, private
-notes vanish, and write attempts outside your row scope are rejected —
-the declared model, enforced end to end.
+Log in as a non-admin member (create one in Setup → Users): the Field Zoo's
+permission-gated fields mask, private notes vanish, and write attempts
+outside your row scope are rejected — while a user assigned the `auditor`
+position reads everything and the Setup app appears for `ops` holders.
+Ask "why" with the explain engine — the `security`
+service's `explain({ object, operation, userId })` reports every layer's
+verdict with contributor attribution, walking the same code the middleware
+enforces with.
This is the last stop — back to the [overview](./showcase_index.md), or
jump to the [Data tour](./showcase_tour_data.md) to start again.
diff --git a/examples/app-showcase/src/security/index.ts b/examples/app-showcase/src/security/index.ts
index 01bf192e01..b03e16b494 100644
--- a/examples/app-showcase/src/security/index.ts
+++ b/examples/app-showcase/src/security/index.ts
@@ -1,176 +1,53 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
- * Security capability chain — a role hierarchy, a permission set that layers
- * object CRUD + field-level security (FLS) + row-level security (RLS), two
- * sharing rules (criteria- and owner-based), and an org security policy.
- * Together these exercise RBAC, FLS, RLS, and sharing in one place. These are
- * plain objects validated by `defineStack` (which coerces string predicates
- * and fills CRUD defaults).
+ * Security domain — the ADR-0090 Permission Model v2, end to end.
+ *
+ * Five concepts, one file each concern:
+ * • positions.ts — flat distribution groups (who gets which sets)
+ * • permission-sets.ts — capability (CRUD, FLS, RLS, depth, VAMA, system
+ * permissions, tabs, everyone-suggestion, guest
+ * capability, delegated-admin scope)
+ * • sharing-rules.ts — record widening (criteria + BU-subtree recipients)
+ *
+ * The other two concepts live elsewhere by design: the OWD baseline
+ * (`sharingModel` / `externalSharingModel`) is declared per-object in
+ * src/data/objects/, and the business-unit tree is DATA (environment-owned,
+ * seeded in src/data/seed/ as `sys_business_unit` rows), not metadata.
+ *
+ * The committed `access-matrix.json` next to objectstack.config.ts is the
+ * ADR-0090 D6 snapshot gate: `objectstack compile` derives the
+ * (permission set × object) matrix from these declarations and fails the
+ * build on drift until the snapshot is regenerated with
+ * `--update-access-matrix` — the snapshot's git diff is the review artifact.
*/
-// ── Roles (hierarchy) ──────────────────────────────────────────────────────
-export const ContributorPosition = {
- name: 'contributor',
- label: 'Contributor',
- description: 'Works tasks on their own projects.',
-};
-
-export const ManagerPosition = {
- name: 'manager',
- label: 'Project Manager',
- description: 'Manages projects and the contributors on them.',
- parent: 'contributor',
-};
-
-export const ExecPosition = {
- name: 'exec',
- label: 'Executive',
- description: 'Read-all visibility for reporting.',
- parent: 'manager',
-};
-
-// ── Permission set: CRUD + FLS + RLS ──────────────────────────────────────
-export const ContributorPermissionSet = {
- name: 'showcase_contributor',
- label: 'Showcase Contributor',
- description: 'Standard access for contributors, with budget fields hidden and row-level scoping to own records.',
- objects: {
- showcase_project: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false },
- showcase_task: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
- showcase_account: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false },
- // Invoice graph: contributors fully manage invoices + their lines. Read/write
- // is scoped by the owner RLS below (invoice) and DERIVED for the lines, which
- // are `controlled_by_parent` — no line RLS is authored (ADR-0055).
- showcase_invoice: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
- showcase_invoice_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
- },
- // Field-level security — contributors can read but not edit budget figures.
- fields: {
- budget: { readable: true, editable: false },
- spent: { readable: true, editable: false },
- budget_remaining: { readable: true, editable: false },
- },
- // Row-level security — contributors only see tasks assigned to them.
- rowLevelSecurity: [
- {
- name: 'task_own_rows',
- label: 'Own Tasks Only',
- description: 'Contributors can only select tasks assigned to them.',
- object: 'showcase_task',
- operation: 'select' as const,
- using: "assignee == current_user.email",
- positions: ['contributor'],
- enabled: true,
- priority: 10,
- },
- // Owner RLS on the MASTER invoice. Because `showcase_invoice_line` is
- // `controlled_by_parent`, a contributor seeing only their own invoices also
- // sees only those invoices' lines — and can by-id read/write a line only when
- // they can read/write its master (ADR-0055). No line rule is authored here.
- {
- name: 'invoice_own_rows',
- label: 'Own Invoices Only',
- description: "Contributors only see invoices they own; their lines follow via controlled_by_parent.",
- object: 'showcase_invoice',
- operation: 'select' as const,
- using: "owner == current_user.email",
- positions: ['contributor'],
- enabled: true,
- priority: 10,
- },
- // [ADR-0058 D4] RLS `check` — write-side post-image validation (NOT a read
- // filter). On UPDATE the new row must still be owned by the caller, so a
- // contributor cannot reassign an invoice they own to someone else. `check`
- // is compiled by the canonical CEL compiler and matched against the post-
- // image (pre-image ∪ change set); a violating write is denied (fail-closed).
- {
- name: 'invoice_owner_immutable',
- label: 'Invoice Owner Cannot Be Reassigned',
- description: 'A contributor cannot change an invoice they own to a different owner (write-time CHECK, ADR-0058 D4).',
- object: 'showcase_invoice',
- operation: 'update' as const,
- check: "owner == current_user.email",
- positions: ['contributor'],
- enabled: true,
- priority: 10,
- },
- ],
-};
-
-// ── App-declared DEFAULT PROFILE (ADR-0056 D7) ──────────────────────────────
-/**
- * The showcase's default access posture for a freshly signed-up user who holds
- * no explicit grants. `isDefault: true` makes the app declare what "a new member
- * can do" instead of inheriting the built-in `member_default` wildcard. The CLI
- * (`pnpm dev`) reads this off the stack and wires it as the SecurityPlugin
- * fallback (ADR-0056 D7) — without that wiring an `isDefault` flag in app
- * metadata is silently ignored. Deliberately read-mostly: a brand-new member can
- * browse the shared catalog + announcements and file tasks/inquiries, but cannot
- * edit or delete anyone's records (owner/OWD enforcement still applies on top).
- */
-export const MemberDefaultProfile = {
- name: 'showcase_member_default',
- label: 'Showcase Member (Default)',
- description: 'App-declared default profile for new sign-ups — read-mostly baseline (ADR-0056 D7).',
- isDefault: true,
- objects: {
- showcase_account: { allowRead: true },
- showcase_product: { allowRead: true },
- showcase_project: { allowRead: true },
- showcase_task: { allowRead: true, allowCreate: true },
- showcase_announcement: { allowRead: true },
- showcase_inquiry: { allowRead: true, allowCreate: true },
- },
-};
-
-// ── Sharing rules ──────────────────────────────────────────────────────────
-/** criteria-based: red-health projects are shared up to executives. */
-export const RedProjectSharingRule = {
- type: 'criteria' as const,
- name: 'share_red_projects_with_execs',
- label: 'Red Projects → Executives',
- description: 'Automatically share at-risk (red health) projects with executives.',
- object: 'showcase_project',
- condition: "record.health == 'red'",
- accessLevel: 'read' as const,
- sharedWith: { type: 'position' as const, value: 'exec' },
- active: true,
-};
-
-/**
- * [ADR-0058 D3 / closes #1887] criteria-based with a COMPOUND CEL condition.
- * Before #1887 a multi-clause `&&` condition was silently skipped (the sharing
- * rule was decorative metadata); now it compiles to a compound `criteria_json`
- * and enforces. Shares only projects that are BOTH at-risk (red) AND high-budget
- * with managers — the AND matters: a red but low-budget project is NOT shared.
- */
-export const HighValueRedProjectRule = {
- type: 'criteria' as const,
- name: 'share_high_value_red_projects_with_managers',
- label: 'High-Value Red Projects → Managers',
- description: 'Share at-risk (red health) projects over the budget threshold with managers (compound condition, ADR-0058 D3).',
- object: 'showcase_project',
- condition: "record.health == 'red' && record.budget > 100000",
- accessLevel: 'read' as const,
- sharedWith: { type: 'position' as const, value: 'manager' },
- active: true,
-};
-
-/** owner-based: a contributor's tasks are shared read-only with managers. */
-export const ContributorTaskSharingRule = {
- type: 'owner' as const,
- name: 'share_contributor_tasks_with_manager',
- label: "Contributor Tasks → Manager",
- description: "Share each contributor's tasks with managers for oversight.",
- object: 'showcase_task',
- ownedBy: { type: 'position' as const, value: 'contributor' },
- accessLevel: 'read' as const,
- sharedWith: { type: 'position' as const, value: 'manager' },
- active: true,
-};
-
-
-export const allPositions = [ContributorPosition, ManagerPosition, ExecPosition];
-export const allPermissionSets = [ContributorPermissionSet, MemberDefaultProfile];
-export const allSharingRules = [RedProjectSharingRule, HighValueRedProjectRule, ContributorTaskSharingRule];
+export {
+ ContributorPosition,
+ ManagerPosition,
+ ExecPosition,
+ AuditorPosition,
+ OpsPosition,
+ FieldOpsDelegatePosition,
+ allPositions,
+} from './positions.js';
+
+export {
+ ContributorPermissionSet,
+ ManagerPermissionSet,
+ ExecutivePermissionSet,
+ AuditorPermissionSet,
+ OpsPermissionSet,
+ MemberDefaultPermissionSet,
+ GuestPortalPermissionSet,
+ FieldOpsDelegatePermissionSet,
+ allPermissionSets,
+} from './permission-sets.js';
+
+export {
+ RedProjectSharingRule,
+ HighValueRedProjectRule,
+ NewInquiryFieldOpsRule,
+ ContributorTaskSharingRule,
+ allSharingRules,
+} from './sharing-rules.js';
diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts
new file mode 100644
index 0000000000..93069c7fce
--- /dev/null
+++ b/examples/app-showcase/src/security/permission-sets.ts
@@ -0,0 +1,288 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Permission sets — the CAPABILITY layer of the ADR-0090 permission model.
+ *
+ * A user's capability is the UNION of every set they hold (additive only —
+ * to withhold, don't grant; there are no subtraction sets). Together these
+ * sets exercise the full authoring surface:
+ *
+ * • object CRUD + field-level security (FLS) + row-level security (RLS)
+ * — `showcase_contributor`
+ * • scope DEPTH (`readScope`/`writeScope`, ADR-0057 D1; the open-edition
+ * dials `own`/`org` — hierarchy depths are enterprise) —
+ * `showcase_manager`, `showcase_executive`
+ * • View/Modify-All bypass (VAMA) — `showcase_auditor`, `showcase_ops`
+ * • system permissions (platform capabilities) — `showcase_ops`
+ * • the `everyone` baseline suggestion (`isDefault`, ADR-0090 D5) —
+ * `showcase_member_default`
+ * • guest-safe capability for the `guest` anchor (ADR-0090 D9) —
+ * `showcase_guest_portal`
+ * • delegated administration (`adminScope`, ADR-0090 D12) —
+ * `showcase_field_ops_delegate`
+ *
+ * DEPTH vs VAMA, in one line: depth widens a grant along the org geometry
+ * (BU tree + manager chain) and still flows through sharing/RLS; VAMA
+ * bypasses record-level checks outright and is therefore high-privilege
+ * (lint-blocked on `everyone`/`guest` bindings).
+ */
+
+import { definePermissionSet } from '@objectstack/spec/security';
+
+// ── CRUD + FLS + RLS ───────────────────────────────────────────────────────
+export const ContributorPermissionSet = definePermissionSet({
+ name: 'showcase_contributor',
+ label: 'Showcase Contributor',
+ objects: {
+ showcase_project: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false },
+ showcase_task: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
+ showcase_account: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false },
+ // Invoice graph: contributors fully manage invoices + their lines. Read/write
+ // is scoped by the owner RLS below (invoice) and DERIVED for the lines, which
+ // are `controlled_by_parent` — no line RLS is authored (ADR-0055).
+ showcase_invoice: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
+ showcase_invoice_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
+ },
+ // Field-level security — contributors can read but not edit budget figures.
+ fields: {
+ budget: { readable: true, editable: false },
+ spent: { readable: true, editable: false },
+ budget_remaining: { readable: true, editable: false },
+ },
+ // Row-level security — contributors only see tasks assigned to them.
+ rowLevelSecurity: [
+ {
+ name: 'task_own_rows',
+ label: 'Own Tasks Only',
+ description: 'Contributors can only select tasks assigned to them.',
+ object: 'showcase_task',
+ operation: 'select' as const,
+ using: 'assignee == current_user.email',
+ positions: ['contributor'],
+ enabled: true,
+ priority: 10,
+ },
+ // Owner RLS on the MASTER invoice. Because `showcase_invoice_line` is
+ // `controlled_by_parent`, a contributor seeing only their own invoices also
+ // sees only those invoices' lines — and can by-id read/write a line only when
+ // they can read/write its master (ADR-0055). No line rule is authored here.
+ {
+ name: 'invoice_own_rows',
+ label: 'Own Invoices Only',
+ description:
+ 'Contributors only see invoices they own; their lines follow via controlled_by_parent.',
+ object: 'showcase_invoice',
+ operation: 'select' as const,
+ using: 'owner == current_user.email',
+ positions: ['contributor'],
+ enabled: true,
+ priority: 10,
+ },
+ // [ADR-0058 D4] RLS `check` — write-side post-image validation (NOT a read
+ // filter). On UPDATE the new row must still be owned by the caller, so a
+ // contributor cannot reassign an invoice they own to someone else. `check`
+ // is compiled by the canonical CEL compiler and matched against the post-
+ // image (pre-image ∪ change set); a violating write is denied (fail-closed).
+ {
+ name: 'invoice_owner_immutable',
+ label: 'Invoice Owner Cannot Be Reassigned',
+ description:
+ 'A contributor cannot change an invoice they own to a different owner (write-time CHECK, ADR-0058 D4).',
+ object: 'showcase_invoice',
+ operation: 'update' as const,
+ check: 'owner == current_user.email',
+ positions: ['contributor'],
+ enabled: true,
+ priority: 10,
+ },
+ ],
+});
+
+// ── Scope depth (ADR-0057 D1) ──────────────────────────────────────────────
+/**
+ * Depth widens a grant on PRIVATE objects along the org geometry — a public
+ * object's baseline is already org-wide, so depth only matters where the
+ * baseline stops. Managers demonstrate the READ/WRITE ASYMMETRY the two
+ * dials allow: org-wide read over inquiries, but edit only their own
+ * (`readScope: 'org'`, `writeScope: 'own'`).
+ *
+ * The intermediate HIERARCHY depths (`own_and_reports` / `unit` /
+ * `unit_and_below`) are an enterprise capability (`requires:
+ * ['hierarchy-security']`, shipped by @objectstack/security-enterprise) —
+ * the open edition fails closed to owner-only, so this open example does not
+ * author them (declared ≠ enforced is the one thing a showcase must never
+ * do). BUSINESS-UNIT-shaped visibility in the open edition is instead
+ * demonstrated by the `share_new_inquiries_with_field_ops` sharing rule
+ * (BU-subtree recipient — see sharing-rules.ts).
+ */
+export const ManagerPermissionSet = definePermissionSet({
+ name: 'showcase_manager',
+ label: 'Showcase Manager',
+ objects: {
+ showcase_inquiry: { allowRead: true, allowEdit: true, readScope: 'org', writeScope: 'own' },
+ showcase_contact: { allowRead: true, readScope: 'org' },
+ },
+});
+
+/**
+ * Executives read org-wide via DEPTH (`readScope: 'org'`) — contrast with the
+ * auditor's VAMA below: `org` depth still respects RLS narrowing; VAMA is a
+ * record-level bypass.
+ */
+export const ExecutivePermissionSet = definePermissionSet({
+ name: 'showcase_executive',
+ label: 'Showcase Executive',
+ objects: {
+ showcase_private_note: { allowRead: true, readScope: 'org' },
+ showcase_inquiry: { allowRead: true, readScope: 'org' },
+ },
+});
+
+// ── VAMA: View-All / Modify-All (record-level bypass) ─────────────────────
+/**
+ * Compliance read-only: `viewAllRecords` bypasses OWD/sharing/depth on the
+ * named objects — the auditor sees every private note, inquiry, and invoice,
+ * but holds no write bit anywhere. High-privilege by definition: the D7 lint
+ * blocks binding this set to the `everyone`/`guest` anchors.
+ */
+export const AuditorPermissionSet = definePermissionSet({
+ name: 'showcase_auditor',
+ label: 'Showcase Auditor',
+ objects: {
+ showcase_private_note: { allowRead: true, viewAllRecords: true },
+ showcase_inquiry: { allowRead: true, viewAllRecords: true },
+ showcase_invoice: { allowRead: true, viewAllRecords: true },
+ showcase_invoice_line: { allowRead: true, viewAllRecords: true },
+ },
+});
+
+// ── System permissions + Modify-All ────────────────────────────────────────
+/**
+ * Back-office operations. Two distinct capabilities on one set:
+ * • `systemPermissions: ['setup.access']` — platform capability
+ * (ADR-0066): ops users can open Setup without being platform admins.
+ * System permissions also drive app-tab reachability: an app declaring
+ * `requiredPermissions` (Setup does) only appears in `/me/apps` when the
+ * union of the caller's sets carries every listed capability.
+ * • `modifyAllRecords` on announcements — announcements are OWD
+ * `public_read` (owner-writes-only), so Modify-All is what lets ops fix
+ * ANYONE's announcement. On an already-`public_read_write` object it
+ * would grant nothing — bypasses only matter where the baseline stops.
+ *
+ * (`tabPermissions` — per-app visible/hidden votes — is deliberately NOT
+ * demoed here: an 'app'-type package carries at most one app (ADR-0019 D3),
+ * and hiding someone else's platform app would demo against surface this
+ * package doesn't own. The enforced consumer is `/me/apps`, which drops apps
+ * whose merged vote is `hidden`.)
+ */
+export const OpsPermissionSet = definePermissionSet({
+ name: 'showcase_ops',
+ label: 'Showcase Operations',
+ objects: {
+ showcase_announcement: { allowRead: true, allowCreate: true, allowEdit: true, modifyAllRecords: true },
+ showcase_inquiry: { allowRead: true, allowEdit: true, readScope: 'org', writeScope: 'org' },
+ showcase_invoice: { allowRead: true },
+ },
+ systemPermissions: ['setup.access'],
+});
+
+// ── The `everyone` baseline suggestion (ADR-0090 D5) ───────────────────────
+/**
+ * `isDefault: true` is a SUGGESTION: "bind this set to the built-in
+ * `everyone` position" — consumed at install time, never auto-bound in a
+ * package install (the admin confirms). The dev CLI wires it as the additive
+ * per-request baseline, so every authenticated member holds it IN ADDITION
+ * to their explicit grants (no fallback cliff — a first explicit grant does
+ * not cost the baseline; ADR-0090 D5 abolished that).
+ *
+ * Deliberately read-mostly and low-privilege: the D7 lint hard-blocks
+ * high-privilege bits (VAMA, delete/purge/transfer, system permissions,
+ * wildcards) on any everyone-suggested set.
+ */
+export const MemberDefaultPermissionSet = definePermissionSet({
+ name: 'showcase_member_default',
+ label: 'Showcase Member (Default)',
+ isDefault: true,
+ objects: {
+ showcase_account: { allowRead: true },
+ showcase_product: { allowRead: true },
+ showcase_project: { allowRead: true },
+ showcase_task: { allowRead: true, allowCreate: true },
+ showcase_announcement: { allowRead: true },
+ showcase_inquiry: { allowRead: true, allowCreate: true },
+ // Personal data on a `private`-OWD object: every member may keep private
+ // notes, and the OWD baseline — not the grant — is what keeps them
+ // owner-scoped (ADR-0090 D1: granting Read never means reading others').
+ // The D7 linter flags this owner-only read as `security-private-no-
+ // readscope` (info) — here it is exactly the intent.
+ showcase_private_note: { allowRead: true, allowCreate: true, allowEdit: true },
+ },
+});
+
+// ── Guest-safe capability (ADR-0090 D9) ────────────────────────────────────
+/**
+ * Capability shaped for the built-in `guest` position: anonymous visitors
+ * may read announcements and file an inquiry (public form intake) — nothing
+ * else. Guest bindings face the STRICTEST lint tier: named objects only (no
+ * wildcard), read-only by default (create case-by-case), never `allowEdit`,
+ * never VAMA/system permissions. The runtime anchor gate
+ * (`assertAudienceAnchorBindingGate`) enforces the same rules on the binding
+ * write itself.
+ *
+ * Binding is an ADMIN action (Setup → Access Control → bind `guest` →
+ * `showcase_guest_portal`); the app only ships the capability.
+ */
+export const GuestPortalPermissionSet = definePermissionSet({
+ name: 'showcase_guest_portal',
+ label: 'Showcase Guest Portal',
+ objects: {
+ showcase_announcement: { allowRead: true },
+ showcase_inquiry: { allowCreate: true },
+ },
+});
+
+// ── Delegated administration (ADR-0090 D12) ────────────────────────────────
+/**
+ * An admin scope makes ADMINISTRATION ITSELF a scoped grant: the holder may
+ * manage user↔position assignments INSIDE the Field Operations business-unit
+ * subtree (seeded in src/data/seed/ as `bu_field_ops` + descendants), and may
+ * only hand out the sets on the allowlist — to others OR themselves (no
+ * self-escalation, enforced by the runtime `DelegatedAdminGate`).
+ *
+ * The `adminScope` authorizes WHAT may be administered; the plain CRUD bits
+ * on the RBAC link tables below let the requests through at all (both are
+ * required — holders of table CRUD with NO scope are refused).
+ * `manageBindings` stays false: this delegate re-staffs positions, they do
+ * not re-compose what a position means.
+ */
+export const FieldOpsDelegatePermissionSet = definePermissionSet({
+ name: 'showcase_field_ops_delegate',
+ label: 'Field Ops Delegate Admin',
+ objects: {
+ sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
+ sys_position: { allowRead: true },
+ sys_permission_set: { allowRead: true },
+ sys_business_unit: { allowRead: true },
+ sys_business_unit_member: { allowRead: true },
+ sys_user: { allowRead: true },
+ },
+ adminScope: {
+ businessUnit: 'Field Operations',
+ includeSubtree: true,
+ manageAssignments: true,
+ manageBindings: false,
+ authorEnvironmentSets: false,
+ assignablePermissionSets: ['showcase_contributor', 'showcase_manager'],
+ },
+});
+
+export const allPermissionSets = [
+ ContributorPermissionSet,
+ ManagerPermissionSet,
+ ExecutivePermissionSet,
+ AuditorPermissionSet,
+ OpsPermissionSet,
+ MemberDefaultPermissionSet,
+ GuestPortalPermissionSet,
+ FieldOpsDelegatePermissionSet,
+];
diff --git a/examples/app-showcase/src/security/positions.ts b/examples/app-showcase/src/security/positions.ts
new file mode 100644
index 0000000000..c095ccea85
--- /dev/null
+++ b/examples/app-showcase/src/security/positions.ts
@@ -0,0 +1,74 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Positions — the DISTRIBUTION layer of the ADR-0090 permission model.
+ *
+ * A position is a flat, job-shaped group (岗位): it answers "who gets which
+ * permission sets" and nothing else. Deliberately NO hierarchy here — the
+ * visibility tree is the business-unit tree (see `business-units` in
+ * src/data/seed/), and the manager chain is `sys_user.manager_id`
+ * (ADR-0090 D3; the old `parent` field on positions never existed at runtime).
+ *
+ * Two positions are BUILT-IN and never declared by an app: `everyone`
+ * (implicitly held by every authenticated member — the tenant baseline,
+ * ADR-0090 D5) and `guest` (implicitly held by anonymous visitors,
+ * ADR-0090 D9). Packages target them by SUGGESTING bindings (`isDefault` on a
+ * permission set), never by declaring or writing to them.
+ */
+
+import { definePosition } from '@objectstack/spec/identity';
+
+/** Works tasks on their own projects — the rank-and-file position. */
+export const ContributorPosition = definePosition({
+ name: 'contributor',
+ label: 'Contributor',
+ description: 'Works tasks on their own projects.',
+});
+
+/** Runs a unit: depth-scoped visibility over the unit's private records. */
+export const ManagerPosition = definePosition({
+ name: 'manager',
+ label: 'Project Manager',
+ description: 'Manages projects and the contributors on them.',
+});
+
+/** Org-wide read for reporting — depth-based (`readScope: org`), not VAMA. */
+export const ExecPosition = definePosition({
+ name: 'exec',
+ label: 'Executive',
+ description: 'Read-all visibility for reporting.',
+});
+
+/** Compliance: View-All bypass (VAMA) — reads everything, changes nothing. */
+export const AuditorPosition = definePosition({
+ name: 'auditor',
+ label: 'Auditor',
+ description: 'Compliance read-only view across private records (viewAllRecords).',
+});
+
+/** Back-office: system permissions, Modify-All repairs, the Operations app. */
+export const OpsPosition = definePosition({
+ name: 'ops',
+ label: 'Operations',
+ description: 'Back-office operations — Setup access, announcement repairs, Operations app.',
+});
+
+/**
+ * Delegated administrator of the Field Operations subtree (ADR-0090 D12).
+ * The capability itself lives on the `showcase_field_ops_delegate` permission
+ * set (`adminScope`); this position is just how an admin hands it out.
+ */
+export const FieldOpsDelegatePosition = definePosition({
+ name: 'field_ops_delegate',
+ label: 'Field Ops Delegate Admin',
+ description: 'Scoped administration of the Field Operations business-unit subtree.',
+});
+
+export const allPositions = [
+ ContributorPosition,
+ ManagerPosition,
+ ExecPosition,
+ AuditorPosition,
+ OpsPosition,
+ FieldOpsDelegatePosition,
+];
diff --git a/examples/app-showcase/src/security/sharing-rules.ts b/examples/app-showcase/src/security/sharing-rules.ts
new file mode 100644
index 0000000000..e6c14cf7b0
--- /dev/null
+++ b/examples/app-showcase/src/security/sharing-rules.ts
@@ -0,0 +1,101 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Sharing rules — the WIDENING layer of the ADR-0090 permission model.
+ *
+ * Each object's OWD (`sharingModel`) is the record-visibility baseline;
+ * sharing only ever WIDENS it (and RLS only ever narrows). A criteria rule's
+ * CEL `condition` is compiled to a runtime filter at seed time and
+ * materializes `sys_record_share` grants for the resolved recipients
+ * (ADR-0058 D3). Recipients here exercise both enforced recipient kinds:
+ * `position` (flat holder expansion) and `unit_and_subordinates`
+ * (business-unit SUBTREE expansion — the unit named by `value` plus every
+ * descendant unit's members, ADR-0057 D5 / ADR-0090 D3).
+ */
+
+import { defineSharingRule } from '@objectstack/spec/security';
+
+/** criteria-based: red-health projects are shared up to executives. */
+export const RedProjectSharingRule = defineSharingRule({
+ type: 'criteria',
+ name: 'share_red_projects_with_execs',
+ label: 'Red Projects → Executives',
+ description: 'Automatically share at-risk (red health) projects with executives.',
+ object: 'showcase_project',
+ condition: "record.health == 'red'",
+ accessLevel: 'read',
+ sharedWith: { type: 'position', value: 'exec' },
+ active: true,
+});
+
+/**
+ * [ADR-0058 D3 / closes #1887] criteria-based with a COMPOUND CEL condition.
+ * Before #1887 a multi-clause `&&` condition was silently skipped (the sharing
+ * rule was decorative metadata); now it compiles to a compound `criteria_json`
+ * and enforces. Shares only projects that are BOTH at-risk (red) AND high-budget
+ * with managers — the AND matters: a red but low-budget project is NOT shared.
+ */
+export const HighValueRedProjectRule = defineSharingRule({
+ type: 'criteria',
+ name: 'share_high_value_red_projects_with_managers',
+ label: 'High-Value Red Projects → Managers',
+ description:
+ 'Share at-risk (red health) projects over the budget threshold with managers (compound condition, ADR-0058 D3).',
+ object: 'showcase_project',
+ condition: "record.health == 'red' && record.budget > 100000",
+ accessLevel: 'read',
+ sharedWith: { type: 'position', value: 'manager' },
+ active: true,
+});
+
+/**
+ * Business-unit SUBTREE recipient (`unit_and_subordinates`): new inquiries are
+ * shared for triage with everyone in the Field Operations unit — AND every
+ * descendant unit (West Coast, East Coast) via the `sys_business_unit` tree.
+ * `value` is the business-unit row id (`bu_field_ops`, seeded with an explicit
+ * id in src/data/seed/ precisely so this rule can reference it statically).
+ * Inquiries are OWD `private`, so WITHOUT this rule a non-owner member sees
+ * none; the rule + the member baseline's `allowRead` is what lets Field Ops
+ * staff read incoming leads.
+ */
+export const NewInquiryFieldOpsRule = defineSharingRule({
+ type: 'criteria',
+ name: 'share_new_inquiries_with_field_ops',
+ label: 'New Inquiries → Field Operations (BU subtree)',
+ description:
+ 'Share incoming (status=new) inquiries with the Field Operations business-unit subtree for triage.',
+ object: 'showcase_inquiry',
+ condition: "record.status == 'new'",
+ accessLevel: 'read',
+ sharedWith: { type: 'unit_and_subordinates', value: 'bu_field_ops' },
+ active: true,
+});
+
+/**
+ * owner-based: a contributor's tasks are shared read-only with managers.
+ *
+ * [experimental — not enforced] Owner-type rules depend on live position
+ * membership and have no static `criteria_json` equivalent, so the seed
+ * bootstrap SKIPS them (logged) rather than seeding a permissive match-all
+ * (ADR-0049: nothing silently over-shares). Kept here to demonstrate the
+ * authoring shape; managers actually reach contributor tasks via the
+ * criteria rules above and their depth grants.
+ */
+export const ContributorTaskSharingRule = defineSharingRule({
+ type: 'owner',
+ name: 'share_contributor_tasks_with_manager',
+ label: 'Contributor Tasks → Manager',
+ description: "Share each contributor's tasks with managers for oversight.",
+ object: 'showcase_task',
+ ownedBy: { type: 'position', value: 'contributor' },
+ accessLevel: 'read',
+ sharedWith: { type: 'position', value: 'manager' },
+ active: true,
+});
+
+export const allSharingRules = [
+ RedProjectSharingRule,
+ HighValueRedProjectRule,
+ NewInquiryFieldOpsRule,
+ ContributorTaskSharingRule,
+];
diff --git a/examples/app-showcase/test/seed.test.ts b/examples/app-showcase/test/seed.test.ts
index 4973155d85..4a96dfd064 100644
--- a/examples/app-showcase/test/seed.test.ts
+++ b/examples/app-showcase/test/seed.test.ts
@@ -25,7 +25,10 @@ describe('showcase stack', () => {
// leaving 3 dataset-bound analytics reports.
expect((stack.reports ?? []).length).toBe(3);
expect((stack.flows ?? []).length).toBeGreaterThan(0);
- expect((stack.positions ?? []).length).toBe(3);
+ // Six flat positions (contributor/manager/exec/auditor/ops/
+ // field_ops_delegate) — the ADR-0090 distribution layer; `everyone` and
+ // `guest` are built-in anchors and never declared by the app.
+ expect((stack.positions ?? []).length).toBe(6);
expect((stack.agents ?? []).length).toBe(0); // AI agents are an enterprise (service-ai) feature; the open showcase ships none
});
});
diff --git a/packages/dogfood/test/showcase-d7-default-profile.dogfood.test.ts b/packages/dogfood/test/showcase-d7-default-profile.dogfood.test.ts
index 1a71797aa2..1685446d6e 100644
--- a/packages/dogfood/test/showcase-d7-default-profile.dogfood.test.ts
+++ b/packages/dogfood/test/showcase-d7-default-profile.dogfood.test.ts
@@ -52,10 +52,12 @@ describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () =
expect(r.status, 'declared default grants announcement read').toBe(200);
});
- it('and NOT by the built-in member_default wildcard (private_note is denied)', async () => {
- const r = await stack.apiAs(memberToken, 'GET', '/data/showcase_private_note');
+ it('and NOT by the built-in member_default wildcard (contact is denied)', async () => {
+ const r = await stack.apiAs(memberToken, 'GET', '/data/showcase_contact');
// member_default has a wildcard grant → would be 200. The app default grants
- // no private_note access → denied, proving the declared default is in force.
- expect(r.status, 'declared default does NOT grant private_note').not.toBe(200);
+ // no contact access → denied, proving the declared default is in force.
+ // (private_note is no longer a valid canary: the ADR-0090 zoo deliberately
+ // grants it in the baseline as the personal-data-on-private-OWD demo.)
+ expect(r.status, 'declared default does NOT grant showcase_contact').not.toBe(200);
});
});
diff --git a/packages/dogfood/test/showcase-permission-zoo.dogfood.test.ts b/packages/dogfood/test/showcase-permission-zoo.dogfood.test.ts
new file mode 100644
index 0000000000..451c608639
--- /dev/null
+++ b/packages/dogfood/test/showcase-permission-zoo.dogfood.test.ts
@@ -0,0 +1,214 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// ADR-0090 permission-model zoo — runtime guard for the showcase's security
+// metadata, in the spirit of `showcase_field_zoo` (#2005) and the semantic
+// zoo: the showcase declares the FULL authoring surface (positions,
+// CRUD/FLS/RLS sets, org-depth, VAMA, system permissions, everyone/guest
+// capability, adminScope, a seeded sys_business_unit tree, BU-subtree
+// sharing), and this test proves the SERVED runtime enforces it — not just
+// stores it. Each block names the ADR-0090 decision it guards.
+//
+// @proof: showcase-permission-zoo
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import showcaseStack from '@objectstack/example-showcase';
+import { bootStack, type VerifyStack } from '@objectstack/verify';
+
+const SYS = { isSystem: true } as const;
+
+describe('showcase: ADR-0090 permission-model zoo', () => {
+ let stack: VerifyStack;
+ let ql: any;
+ let adminTok: string;
+ let ownerTok: string, plainTok: string, auditorTok: string, delegateTok: string;
+ let ownerId: string, plainId: string, auditorId: string, delegateId: string, targetId: string;
+ let noteId: string;
+
+ const uid = async (email: string) =>
+ (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id;
+
+ beforeAll(async () => {
+ stack = await bootStack(showcaseStack);
+ adminTok = await stack.signIn();
+ ownerTok = await stack.signUp('zoo-owner@verify.test');
+ plainTok = await stack.signUp('zoo-plain@verify.test');
+ auditorTok = await stack.signUp('zoo-auditor@verify.test');
+ delegateTok = await stack.signUp('zoo-delegate@verify.test');
+ await stack.signUp('zoo-target@verify.test');
+
+ ql = await stack.kernel.getServiceAsync('objectql');
+ ownerId = await uid('zoo-owner@verify.test');
+ plainId = await uid('zoo-plain@verify.test');
+ auditorId = await uid('zoo-auditor@verify.test');
+ delegateId = await uid('zoo-delegate@verify.test');
+ targetId = await uid('zoo-target@verify.test');
+
+ // Grant the zoo sets directly (system plumbing — the delegated-admin and
+ // anchor gates are exercised through REAL authed requests below).
+ const grant = async (userId: string, setName: string) => {
+ const ps = await ql.findOne('sys_permission_set', { where: { name: setName }, context: SYS });
+ expect(ps?.id, `permission set ${setName} seeded`).toBeTruthy();
+ await ql.insert(
+ 'sys_user_permission_set',
+ { user_id: userId, permission_set_id: ps.id },
+ { context: SYS },
+ );
+ };
+ await grant(auditorId, 'showcase_auditor');
+ await grant(delegateId, 'showcase_field_ops_delegate');
+
+ // Membership: the assignment target sits INSIDE the delegate's subtree
+ // (West Coast ⊂ Field Operations); the delegate too.
+ await ql.insert('sys_business_unit_member', { business_unit_id: 'bu_west_coast', user_id: targetId }, { context: SYS });
+ await ql.insert('sys_business_unit_member', { business_unit_id: 'bu_field_ops', user_id: delegateId }, { context: SYS });
+
+ // A private record owned by zoo-owner — the probe for OWD/VAMA.
+ const created = await stack.apiAs(ownerTok, 'POST', '/data/showcase_private_note', {
+ title: 'Zoo probe note',
+ });
+ expect(created.status, 'owner creates a private note').toBeLessThan(300);
+ const body: any = await created.json();
+ noteId = body?.id ?? body?.record?.id;
+ if (!noteId) {
+ noteId = (await ql.findOne('showcase_private_note', { where: { title: 'Zoo probe note' }, context: SYS }))?.id;
+ }
+ expect(noteId, 'probe note id resolved').toBeTruthy();
+ }, 120_000);
+
+ afterAll(async () => {
+ await stack?.stop();
+ });
+
+ // ── The app seed can plant the platform org tree ─────────────────────────
+ it('seeds the sys_business_unit tree with explicit ids and parent links', async () => {
+ const units = await ql.find('sys_business_unit', { where: {}, context: SYS });
+ const byId = new Map((units ?? []).map((u: any) => [u.id, u]));
+ expect(byId.get('bu_acme'), 'root unit seeded').toBeTruthy();
+ expect((byId.get('bu_field_ops') as any)?.parent_business_unit_id).toBe('bu_acme');
+ expect((byId.get('bu_west_coast') as any)?.parent_business_unit_id).toBe('bu_field_ops');
+ expect((byId.get('bu_hq_finance') as any)?.parent_business_unit_id).toBe('bu_acme');
+ });
+
+ // ── ADR-0090 D12: adminScope lands on the stored set ─────────────────────
+ it('persists the delegated-admin scope (adminScope → admin_scope JSON)', async () => {
+ const row = await ql.findOne('sys_permission_set', { where: { name: 'showcase_field_ops_delegate' }, context: SYS });
+ expect(row, 'delegate set seeded').toBeTruthy();
+ const scope = JSON.parse(row.admin_scope || 'null');
+ expect(scope?.businessUnit).toBe('Field Operations');
+ expect(scope?.manageAssignments).toBe(true);
+ expect(scope?.manageBindings).toBe(false);
+ expect(scope?.assignablePermissionSets).toContain('showcase_contributor');
+ });
+
+ // ── OWD private baseline vs VAMA bypass ───────────────────────────────────
+ it('a plain member cannot read someone else’s private note (OWD private, D1)', async () => {
+ const r = await stack.apiAs(plainTok, 'GET', `/data/showcase_private_note/${noteId}`);
+ expect(r.status, 'private baseline hides the row').not.toBe(200);
+ });
+
+ it('an auditor reads it via viewAllRecords (VAMA bypass)', async () => {
+ const r = await stack.apiAs(auditorTok, 'GET', `/data/showcase_private_note/${noteId}`);
+ expect(r.status, 'view-all bypasses the private baseline').toBe(200);
+ });
+
+ it('the auditor still cannot WRITE it (read-only compliance set)', async () => {
+ const r = await stack.apiAs(auditorTok, 'PATCH', `/data/showcase_private_note/${noteId}`, {
+ title: 'defaced',
+ });
+ expect(r.status, 'no write bit anywhere on the auditor set').not.toBe(200);
+ });
+
+ // ── ADR-0090 D6: the explain engine attributes the VAMA grant ────────────
+ it('explain() reports the vama_bypass layer with the auditor set as contributor', async () => {
+ const security: any = stack.kernel.getService('security');
+ expect(security?.explain, 'security service exposes explain()').toBeTruthy();
+ const decision = await security.explain(
+ { object: 'showcase_private_note', operation: 'read', userId: auditorId },
+ { isSystem: true },
+ );
+ expect(decision?.allowed).toBe(true);
+ const vama = (decision?.layers ?? []).find((l: any) => l.layer === 'vama_bypass');
+ expect(vama, 'vama_bypass layer present').toBeTruthy();
+ const names = (vama?.contributors ?? []).map((c: any) => c.name);
+ expect(names, 'the granting set is attributed').toContain('showcase_auditor');
+ });
+
+ // ── ADR-0090 D12: the delegated-admin gate, positive and negative ────────
+ it('a delegate assigns an allowlisted position inside their subtree', async () => {
+ // Bind contributor → showcase_contributor first (admin action; the
+ // delegate has manageBindings: false and must NOT be able to do this).
+ const pos = await ql.findOne('sys_position', { where: { name: 'contributor' }, context: SYS });
+ const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_contributor' }, context: SYS });
+ expect(pos?.id && ps?.id, 'contributor position + set seeded').toBeTruthy();
+ const bind = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', {
+ position_id: pos.id,
+ permission_set_id: ps.id,
+ });
+ expect(bind.status, 'tenant admin binds set to position').toBeLessThan(300);
+
+ const assign = await stack.apiAs(delegateTok, 'POST', '/data/sys_user_position', {
+ user_id: targetId,
+ position: 'contributor',
+ business_unit_id: 'bu_west_coast',
+ });
+ expect(assign.status, 'in-subtree, allowlisted assignment passes').toBeLessThan(300);
+ const row = await ql.findOne('sys_user_position', {
+ where: { user_id: targetId, position: 'contributor' },
+ context: SYS,
+ });
+ expect(row, 'assignment persisted').toBeTruthy();
+ expect(row.granted_by, 'granted_by audit stamp applied').toBeTruthy();
+ });
+
+ it('the same delegate is refused OUTSIDE their subtree (no lateral reach)', async () => {
+ const r = await stack.apiAs(delegateTok, 'POST', '/data/sys_user_position', {
+ user_id: targetId,
+ position: 'contributor',
+ business_unit_id: 'bu_hq_finance',
+ });
+ expect(r.status, 'HQ Finance is outside the Field Operations subtree').not.toBeLessThan(300);
+ });
+
+ it('the delegate cannot hand out a set OFF the allowlist (no self-escalation)', async () => {
+ // showcase_auditor is not in assignablePermissionSets — direct grant refused.
+ const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_auditor' }, context: SYS });
+ const r = await stack.apiAs(delegateTok, 'POST', '/data/sys_user_permission_set', {
+ user_id: delegateId,
+ permission_set_id: ps.id,
+ });
+ expect(r.status, 'granting an un-allowlisted set (to ANYONE, incl. self) is refused').not.toBeLessThan(300);
+ });
+
+ it('the delegate cannot re-compose positions (manageBindings: false)', async () => {
+ const pos = await ql.findOne('sys_position', { where: { name: 'contributor' }, context: SYS });
+ const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_manager' }, context: SYS });
+ const r = await stack.apiAs(delegateTok, 'POST', '/data/sys_position_permission_set', {
+ position_id: pos.id,
+ permission_set_id: ps.id,
+ });
+ expect(r.status, 'binding writes need manageBindings').not.toBeLessThan(300);
+ });
+
+ // ── ADR-0090 D5/D9: the audience-anchor binding gate ─────────────────────
+ it('binding a high-privilege set to an anchor is rejected even for the admin', async () => {
+ const everyone = await ql.findOne('sys_position', { where: { name: 'everyone' }, context: SYS });
+ expect(everyone, 'built-in everyone position seeded').toBeTruthy();
+ const auditor = await ql.findOne('sys_permission_set', { where: { name: 'showcase_auditor' }, context: SYS });
+ const r = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', {
+ position_id: everyone.id,
+ permission_set_id: auditor.id,
+ });
+ expect(r.status, 'viewAllRecords on the everyone anchor is lint-tier blocked at runtime').not.toBeLessThan(300);
+ });
+
+ it('binding the guest-safe set to the guest anchor is accepted', async () => {
+ const guest = await ql.findOne('sys_position', { where: { name: 'guest' }, context: SYS });
+ expect(guest, 'built-in guest position seeded').toBeTruthy();
+ const ps = await ql.findOne('sys_permission_set', { where: { name: 'showcase_guest_portal' }, context: SYS });
+ const r = await stack.apiAs(adminTok, 'POST', '/data/sys_position_permission_set', {
+ position_id: guest.id,
+ permission_set_id: ps.id,
+ });
+ expect(r.status, 'read-only + create-intake set passes the guest tier').toBeLessThan(300);
+ });
+});
diff --git a/packages/plugins/plugin-security/README.md b/packages/plugins/plugin-security/README.md
index fbf23ad5bd..3c55969d20 100644
--- a/packages/plugins/plugin-security/README.md
+++ b/packages/plugins/plugin-security/README.md
@@ -9,7 +9,7 @@
`plugin-security` hooks into the ObjectQL pipeline and applies authorization on every read and write:
-1. **Resolve permission sets** — match user roles against `SysPermissionSet` metadata.
+1. **Resolve permission sets** — expand the user's positions and direct grants against `SysPermissionSet` metadata.
2. **Check object CRUD** — `allowRead`, `allowCreate`, `allowEdit`, `allowDelete`.
3. **Inject RLS** — compile row-level policy expressions into query filters.
4. **Mask fields** — remove non-readable fields from results; flag non-editable fields on writes.
@@ -60,10 +60,10 @@ In CLI / dev-server mode the `OS_MULTI_ORG_ENABLED` environment variable (defaul
| Export | Kind | Description |
|:---|:---|:---|
| `SecurityPlugin` | class | Kernel plugin that installs the four-step security chain. |
-| `PermissionEvaluator` | class | Evaluates object-level CRUD permissions across roles (most-permissive merge). |
+| `PermissionEvaluator` | class | Evaluates object-level CRUD permissions across the held permission sets (most-permissive merge). |
| `RLSCompiler` | class | Compiles RLS expressions into ObjectQL filter AST. |
| `FieldMasker` | class | Strips non-readable fields and identifies non-editable ones. |
-| `SysRole`, `SysPermissionSet` | objects | Metadata objects registered by the plugin. |
+| `SysPosition`, `SysPermissionSet` | objects | Metadata objects registered by the plugin. |
## System objects
@@ -71,10 +71,10 @@ The plugin contributes these system objects to the kernel:
| Object | Purpose |
|:---|:---|
-| `sys_position` | User role definitions. |
-| `sys_permission_set` | Bundles object and field permissions; can include RLS expressions. |
+| `sys_position` | Position (岗位) definitions — the flat permission-set distribution layer (ADR-0090 D3). |
+| `sys_permission_set` | Bundles object and field permissions; can include RLS expressions and a delegated-admin `admin_scope` (ADR-0090 D12). |
-Assignment tables (role ↔ user, role ↔ permission_set) are provided by [`@objectstack/plugin-auth`](../plugin-auth) when used together.
+Assignment tables (position ↔ user, position ↔ permission_set, user ↔ permission_set) are registered alongside and governed by the delegated-admin and audience-anchor gates.
## RLS expression language
diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts
index 26688c7504..0c25f7e570 100644
--- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts
+++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts
@@ -137,7 +137,7 @@ export const SysSharingRule = ObjectSchema.create({
label: 'Recipient Type',
required: true,
defaultValue: 'business_unit',
- description: 'Kind of principal that receives access — expanded to user grants at evaluation time. `department` walks the parent_business_unit_id tree; `team` is flat (better-auth); `role` is the role\'s direct members; `unit_and_subordinates` walks the sys_position.parent hierarchy to also include every subordinate role (ADR-0056 D6).',
+ description: 'Kind of principal that receives access — expanded to user grants at evaluation time. `business_unit` walks the parent_business_unit_id tree; `team` is flat (better-auth); `position` expands the position\'s holders (positions are flat, ADR-0090 D3); `unit_and_subordinates` expands the named business unit PLUS every descendant unit\'s members via the sys_business_unit tree (ADR-0057 D5).',
group: 'Recipient',
},
),
diff --git a/packages/spec/src/ai/agent.zod.ts b/packages/spec/src/ai/agent.zod.ts
index 9d7d7dddb4..3181bba9bb 100644
--- a/packages/spec/src/ai/agent.zod.ts
+++ b/packages/spec/src/ai/agent.zod.ts
@@ -164,8 +164,8 @@ export const AgentSchema = lazySchema(() => z.object({
active: z.boolean().default(true),
access: z.array(z.string()).optional().describe('Who can chat with this agent'),
- /** Permission profiles/roles required to use this agent */
- permissions: z.array(z.string()).optional().describe('Required permissions or roles'),
+ /** Permission-set capabilities required to use this agent */
+ permissions: z.array(z.string()).optional().describe('Required permission-set capabilities'),
/** Multi-tenancy & Visibility */
tenantId: z.string().optional().describe('Tenant/Organization ID'),
diff --git a/packages/spec/src/ai/tool.zod.ts b/packages/spec/src/ai/tool.zod.ts
index 88dd4c277e..adf58e5349 100644
--- a/packages/spec/src/ai/tool.zod.ts
+++ b/packages/spec/src/ai/tool.zod.ts
@@ -95,8 +95,8 @@ export const ToolSchema = lazySchema(() => z.object({
/** Whether the tool requires human confirmation before execution */
requiresConfirmation: z.boolean().default(false).describe('Require user confirmation before execution'),
- /** Permission profiles/roles required to use this tool */
- permissions: z.array(z.string()).optional().describe('Required permissions or roles'),
+ /** Permission-set capabilities required to use this tool */
+ permissions: z.array(z.string()).optional().describe('Required permission-set capabilities'),
/** Whether the tool is enabled */
active: z.boolean().default(true).describe('Whether the tool is enabled'),
diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts
index 7cbc79a62b..3c069d40ab 100644
--- a/packages/spec/src/security/rls.zod.ts
+++ b/packages/spec/src/security/rls.zod.ts
@@ -11,8 +11,8 @@ import { z } from 'zod';
* ## Overview
*
* Row-Level Security (RLS) allows you to control which rows users can access
- * in database tables based on their identity and role. Unlike object-level
- * permissions (CRUD), RLS provides record-level filtering.
+ * in database tables based on their identity and positions. Unlike
+ * object-level permissions (CRUD), RLS provides record-level filtering.
*
* ## Use Cases
*
@@ -64,12 +64,13 @@ import { z } from 'zod';
*
* ## Salesforce Sharing Rules Comparison
*
- * Salesforce uses "Sharing Rules" and "Role Hierarchy" for record-level access.
+ * Salesforce uses "Sharing Rules" and a visibility hierarchy for record-level
+ * access (our equivalent hierarchy is the business-unit tree, ADR-0090 D3).
* ObjectStack RLS provides similar functionality with more flexibility.
- *
+ *
* Salesforce:
- * - Criteria-Based Sharing: Share records matching criteria with users/roles
- * - Owner-Based Sharing: Share records based on owner's role
+ * - Criteria-Based Sharing: Share records matching criteria with users/groups
+ * - Owner-Based Sharing: Share records based on who owns them
* - Manual Sharing: Individual record sharing
*
* ObjectStack RLS:
@@ -81,7 +82,7 @@ import { z } from 'zod';
*
* 1. **Always Define SELECT Policy**: Control what users can view
* 2. **Define INSERT/UPDATE CHECK Policies**: Prevent data leakage
- * 3. **Use Role-Based Policies**: Apply different rules to different roles
+ * 3. **Use Position-Scoped Policies**: Apply different rules to different positions
* 4. **Test Thoroughly**: RLS can have complex interactions
* 5. **Monitor Performance**: Complex RLS policies can impact query performance
*
@@ -291,7 +292,7 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => z.object({
* 2. `field = 'literal'` — equality against a single-quoted string literal
* 3. `field IN (current_user.)` — set membership against a
* pre-resolved id array (see "Dynamic membership" below)
- * 4. `1 = 1` — always true / no restriction (privileged-role allow-all)
+ * 4. `1 = 1` — always true / no restriction (privileged-position allow-all)
*
* There is intentionally **no** support for `AND`/`OR`/`NOT`, comparison
* operators other than `=`, `IS NULL`/`IS NOT NULL`, `NOT IN`, `LIKE`/
@@ -324,7 +325,7 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => z.object({
* @example "owner_id = current_user.id"
* @example "status = 'published'"
* @example "assigned_to_id IN (current_user.team_member_ids)" // §7.3.1 pre-resolved
- * @example "1 = 1" // privileged-role allow-all
+ * @example "1 = 1" // privileged-position allow-all
*/
using: z.string()
.optional()
@@ -571,7 +572,7 @@ export const RLS = {
}),
/**
- * Create a role-based policy
+ * Create a position-scoped policy
*/
positionPolicy: (object: string, positions: string[], condition: string): RowLevelSecurityPolicy => ({
name: `${object}_${positions.join('_')}_access`,
diff --git a/packages/spec/src/security/sharing.zod.ts b/packages/spec/src/security/sharing.zod.ts
index 2c12386429..63d1346d70 100644
--- a/packages/spec/src/security/sharing.zod.ts
+++ b/packages/spec/src/security/sharing.zod.ts
@@ -66,7 +66,7 @@ const BaseSharingRuleSchema = z.object({
// Recipient (Whom to share with)
sharedWith: z.object({
type: ShareRecipientType,
- value: z.string().describe('ID or Code of the User/Group/Role'),
+ value: z.string().describe('ID or Code of the recipient (user / group / position / business unit)'),
}).describe('The recipient of the shared access'),
});
@@ -88,7 +88,7 @@ export const OwnerSharingRuleSchema = lazySchema(() => BaseSharingRuleSchema.ext
ownedBy: z.object({
type: ShareRecipientType,
value: z.string(),
- }).describe('Source group/role whose records are being shared'),
+ }).describe('Source group/position whose records are being shared'),
}));
/**
diff --git a/packages/spec/src/security/territory.zod.ts b/packages/spec/src/security/territory.zod.ts
index 30e9aa1921..ef36cbb3d7 100644
--- a/packages/spec/src/security/territory.zod.ts
+++ b/packages/spec/src/security/territory.zod.ts
@@ -5,18 +5,19 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
/**
* Territory Management Protocol
- * Defines a matrix reporting structure that exists parallel to the Role Hierarchy.
- *
+ * Defines a matrix reporting structure that exists parallel to the
+ * business-unit hierarchy (ADR-0090 D3 — the org tree is `sys_business_unit`).
+ *
* USE CASE:
* - Enterprise Sales Teams (Geo-based: "EMEA", "APAC")
* - Industry Verticals (Industry-based: "Healthcare", "Financial")
* - Strategic Accounts (Account-based: "Strategic Accounts")
- *
- * DIFFERENCE FROM ROLE:
- * - Role: Hierarchy of PEOPLE (Who reports to whom). Stable. HR-driven.
+ *
+ * DIFFERENCE FROM THE BUSINESS-UNIT TREE:
+ * - Business unit: Hierarchy of PEOPLE (org structure). Stable. HR-driven.
* - Territory: Hierarchy of ACCOUNTS/REVENUE (Who owns which market). Flexible. Sales-driven.
* - One User can be assigned to MANY Territories (Matrix).
- * - One User has only ONE Role (Tree).
+ * - One User belongs to one primary business unit (Tree).
*/
import { lazySchema } from '../shared/lazy-schema';
diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts
index e0e8a61408..9a9739ab1a 100644
--- a/packages/spec/src/stack.test.ts
+++ b/packages/spec/src/stack.test.ts
@@ -520,6 +520,61 @@ describe('defineStack', () => {
expect(() => defineStack(config, { strict: true })).not.toThrow();
});
+ it('should allow permission grants on platform (sys_) objects the runtime provides', () => {
+ // ADR-0090 D12: a delegated-admin set carries CRUD on the RBAC link
+ // tables — platform objects contributed by the runtime, not the stack.
+ const config = {
+ manifest: baseManifest,
+ objects: [
+ { name: 'crm_lead', fields: { email: { type: 'email' } } },
+ ],
+ permissions: [
+ {
+ name: 'delegate_admin',
+ objects: {
+ sys_user_position: { allowRead: true, allowCreate: true },
+ sys_business_unit: { allowRead: true },
+ },
+ adminScope: { businessUnit: 'Field Operations', manageAssignments: true },
+ },
+ ],
+ };
+ expect(() => defineStack(config, { strict: true })).not.toThrow();
+ });
+
+ it('should allow seed data targeting platform (sys_) objects', () => {
+ // The seed loader officially supports sys_* targets (isSystem context);
+ // e.g. an app seeding the ADR-0090 business-unit tree.
+ const config = {
+ manifest: baseManifest,
+ objects: [
+ { name: 'crm_lead', fields: { email: { type: 'email' } } },
+ ],
+ data: [
+ {
+ object: 'sys_business_unit',
+ mode: 'upsert',
+ externalId: 'id',
+ records: [{ id: 'bu_root', name: 'HQ', kind: 'company' }],
+ },
+ ],
+ };
+ expect(() => defineStack(config, { strict: true })).not.toThrow();
+ });
+
+ it('should still reject seed data targeting an undefined non-platform object', () => {
+ const config = {
+ manifest: baseManifest,
+ objects: [
+ { name: 'crm_lead', fields: { email: { type: 'email' } } },
+ ],
+ data: [
+ { object: 'lead', records: [{ name: 'typo — crm_lead was meant' }] },
+ ],
+ };
+ expect(() => defineStack(config, { strict: true })).toThrow("object 'lead'");
+ });
+
it('should skip cross-reference validation when no objects are defined', () => {
const config = {
manifest: baseManifest,
diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts
index 9ca5e3ff19..40b49edeaf 100644
--- a/packages/spec/src/stack.zod.ts
+++ b/packages/spec/src/stack.zod.ts
@@ -530,6 +530,19 @@ function validateSingleApp(config: ObjectStackDefinition): string[] {
];
}
+/**
+ * Platform-provided object names (`sys_` / `cloud_` / `ai_` prefixes — the
+ * same classification the seed loader applies). These objects are contributed
+ * by the runtime, never by the stack, so cross-reference checks must not
+ * demand they appear in `config.objects`: an app legitimately seeds the
+ * ADR-0090 business-unit tree (`sys_business_unit`) or grants a delegated
+ * administrator CRUD on the RBAC link tables (`sys_user_position`, ADR-0090
+ * D12). The typo net stays intact for the stack's OWN objects.
+ */
+function isPlatformObjectName(name: string): boolean {
+ return /^(sys_|cloud_|ai_)/.test(name);
+}
+
/**
* Collect all object names defined in a stack definition.
*/
@@ -592,10 +605,15 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] {
}
}
- // Validate seed data → object references
+ // Validate seed data → object references (platform objects are runtime-
+ // provided seed targets — see isPlatformObjectName).
if (config.data) {
for (const dataset of config.data) {
- if (dataset.object && !objectNames.has(dataset.object)) {
+ if (
+ dataset.object &&
+ !objectNames.has(dataset.object) &&
+ !isPlatformObjectName(dataset.object)
+ ) {
errors.push(
`Seed data references object '${dataset.object}' which is not defined in objects.`,
);
@@ -634,12 +652,14 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] {
// explicit-permission-set path does not — so the grant is simply lost (e.g. a
// public Web-to-Lead INSERT is denied for "roles []"). Fail loudly at build
// time. (`validateNamespacePrefix`'s doc already assumes this check lives here.)
+ // Platform objects are legitimate grant targets (e.g. a delegated-admin set
+ // carrying CRUD on the RBAC link tables, ADR-0090 D12) — skip them here.
if (config.permissions) {
for (const perm of config.permissions) {
const grants = (perm as { objects?: Record }).objects;
if (grants && typeof grants === 'object') {
for (const objName of Object.keys(grants)) {
- if (!objectNames.has(objName)) {
+ if (!objectNames.has(objName) && !isPlatformObjectName(objName)) {
errors.push(
`Permission '${(perm as { name?: string }).name ?? '(unnamed)'}' grants on object ` +
`'${objName}' which is not defined in objects.`,