diff --git a/.changeset/adr-0090-docs-audience-enforcement.md b/.changeset/adr-0090-docs-audience-enforcement.md
new file mode 100644
index 0000000000..595444ad1e
--- /dev/null
+++ b/.changeset/adr-0090-docs-audience-enforcement.md
@@ -0,0 +1,40 @@
+---
+'@objectstack/spec': major
+'@objectstack/rest': minor
+'@objectstack/plugin-security': minor
+'@objectstack/lint': minor
+'@objectstack/metadata': minor
+'@objectstack/platform-objects': patch
+---
+
+ADR-0090 follow-through wave: enforce book audience at the read layer; finish the D2/D3 cleanup the P1 rename missed.
+
+- **rest**: `/meta/book`, `/meta/doc`, and `/meta/book/:name/tree` now ENFORCE
+ the ADR-0046 §6.7 audience model (ADR-0049 — no unenforced security
+ properties): anonymous callers see only `public` books/docs;
+ `{ permissionSet }`-gated books require the caller to hold the named set;
+ a doc's effective audience is the union over the books that CLAIM it
+ (unclaimed docs default to `org`; orphan rendering never inherits `public`).
+ Gated evaluation fails CLOSED when holdings cannot be resolved. `doc`/`book`
+ single-item reads bypass the shared meta cache (per-caller gate vs shared ETag).
+- **spec**: new pure helpers powering that gate — `audienceAllows`,
+ `resolveDocAudiences`, `docAudienceAllows`, `resolveBookClaimedDocs`
+ (+ `AudienceCaller`/`AudienceBook` types). BREAKING (launch window):
+ `METADATA_FORM_REGISTRY` keys `role`/`profile` are gone — `position` is the
+ registered form (the `position` type had LOST its form layout in the P1
+ rename); `EnvironmentArtifactMetadataSchema` declares `positions` instead of
+ retired `roles`/`profiles`.
+- **plugin-security**: the `security` service exposes
+ `resolvePermissionSetNames(ctx)` — the same resolution as data-plane
+ enforcement, for the docs gate.
+- **metadata**: artifact ingestion maps `positions → 'position'` (the stale
+ `roles → 'role'` mapping matched nothing since the P1 rename, silently
+ dropping compiled positions from metadata registration).
+- **lint**: books join the D3 role-word scan (their `audience` is a
+ permission-model reference now), and a new advisory rule
+ `security-book-audience-unknown-set` flags a `{ permissionSet }` audience
+ naming a set the stack does not declare (runtime fails closed — the typo
+ cost is "nobody can read the book", so say it at author time).
+- **platform-objects**: metadata-form translations regain `position` (all four
+ locales) and drop the retired `role`/`profile` groups, with a vocabulary
+ regression test.
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 008f1d8c7f..8236ef37f1 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -58,6 +58,13 @@ jobs:
- name: Doc/skill authoring guard
run: pnpm check:doc-authoring
+ # ADR-0090 D3 vocabulary ratchet: "role" is reserved-forbidden in docs
+ # and skills. Existing occurrences are frozen in the baseline (better-auth
+ # boundary, ARIA samples, educational mentions); NEW occurrences fail.
+ # Improvements ratchet the baseline down via --update.
+ - name: Reserved-word ("role") docs ratchet
+ run: pnpm check:role-word
+
# Authorization resolution must stay single-sourced (resolveAuthzContext,
# @objectstack/core). Guards against a duplicate resolver copy drifting on a
# security path (the REST-vs-dispatcher sys_user_role drift) and against an
diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx
index 3eac087d89..6866a7dc39 100644
--- a/content/docs/automation/approvals.mdx
+++ b/content/docs/automation/approvals.mdx
@@ -15,7 +15,7 @@ An approval is a **flow** with an **approval node**. Two access decisions matter
### 1. Who can configure it
-Authoring flows/automations is a builder capability — it needs `manage_metadata` (typically Studio users). End users **submit** records and **act on** approval requests, but they do not edit the automation. Keep the automation surfaces out of consumer apps (see [role-based interfaces](/docs/ui/role-based-interfaces)).
+Authoring flows/automations is a builder capability — it needs `manage_metadata` (typically Studio users). End users **submit** records and **act on** approval requests, but they do not edit the automation. Keep the automation surfaces out of consumer apps (see [audience-based interfaces](/docs/ui/audience-based-interfaces)).
### 2. As whom does it run — the safety decision
diff --git a/content/docs/permissions/access-recipes.mdx b/content/docs/permissions/access-recipes.mdx
index 2d90ee3e87..8f6828e125 100644
--- a/content/docs/permissions/access-recipes.mdx
+++ b/content/docs/permissions/access-recipes.mdx
@@ -11,7 +11,7 @@ description: Map a concrete access requirement onto the platform's layers — ob
## Recommended solution
-Authorization splits into three decoupled concerns (ADR-0066): **capability** (what can be done), **assignment** (who holds it — permission sets / roles, maintained at runtime), and **requirement** (what a resource declares it needs). A resource declares *what is required*; it never bakes in *who*.
+Authorization splits into three decoupled concerns (ADR-0066): **capability** (what can be done), **assignment** (who holds it — permission sets / positions, maintained at runtime), and **requirement** (what a resource declares it needs). A resource declares *what is required*; it never bakes in *who*.
Pick the layer that matches the requirement:
@@ -45,7 +45,7 @@ definePermissionSet({
Two separate questions:
-1. **Who can configure it?** Editing flows/automations needs `manage_metadata` (typically Studio users). Don't expose automation config to end users — see [role-based interfaces](/docs/ui/role-based-interfaces).
+1. **Who can configure it?** Editing flows/automations needs `manage_metadata` (typically Studio users). Don't expose automation config to end users — see [audience-based interfaces](/docs/ui/audience-based-interfaces).
2. **As whom does it run?** A flow's `runAs` (ADR-0049):
- `runAs: 'user'` (default) — runs as the triggering user; CRUD nodes respect that user's RLS. **Safer default.**
- `runAs: 'system'` — elevated, bypasses RLS. Make elevation *explicit*, and surface it in the UI as "runs as system".
diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx
index 038d51130d..4f5e63c033 100644
--- a/content/docs/permissions/authorization.mdx
+++ b/content/docs/permissions/authorization.mdx
@@ -105,13 +105,13 @@ package (metadata); subject bindings and env-specific values stay as config.**
seeds `stack.permissions` at boot — idempotent, re-seeded on upgrade, and it
**never clobbers** env-authored (`platform`/`user`/legacy) rows. A package
never writes into a foreign record.
-- The environment admin **assigns** sets to roles/users; the runtime unions
+- The environment admin **assigns** sets to positions/users; the runtime unions
them. One shared set with hand-picked cross-package grants remains an
env-admin-only construct.
- This is what makes package **uninstall** well-defined (drop the package's
own sets) and the objectui Access matrix scopable to `{ packageId }`.
-- Declared roles and sharing rules seed the same way
- (`bootstrapDeclaredRoles`, ADR-0057 D6) — a declarable-but-never-seeded
+- Declared positions and sharing rules seed the same way
+ (`bootstrapDeclaredPositions`, ADR-0057 D6) — a declarable-but-never-seeded
array is exactly the inert-metadata smell ADR-0078 prohibits.
### Two doors, one metadata (ADR-0086 D6/D7)
@@ -147,7 +147,7 @@ one of two doors, each writing only what it owns:
| Stage | What holds today | Owned by |
|---|---|---|
-| 1 · Package development | Zod-validated authoring; roles / sharingRules / permissions seeded at boot with provenance | ADR-0057 D6, ADR-0086 D5, ADR-0049/0078 gates |
+| 1 · Package development | Zod-validated authoring; positions / sharingRules / permissions seeded at boot with provenance | ADR-0057 D6, ADR-0086 D5, ADR-0049/0078 gates |
| 2 · Distribution / install / upgrade / uninstall | Install-consent scopes (ADR-0025 — consent ≠ RBAC grants); namespaced, collision-free composition; provenance axis makes uninstall well-defined | ADR-0025/0028/0048/0086 |
| 3 · Environment composition / assignment | Platform-owned assignment records (`sys_user_position` etc.); anti-escalation; union semantics | ADR-0057 D4 |
| 4 · Runtime enforcement | The six-gate chain above; ~18 primitives enforced and CI-guarded | ADR-0056 D10 matrix |
@@ -261,7 +261,7 @@ The complete, prioritized gap map lives in issue **#2561** (the production
| [0056](/adr/0056-permission-model-landing-verification) | Permission-model landing: OWD, anonymous deny default, D10 matrix |
| [0057](/adr/0057-erp-authorization-core-business-units-and-scope-depth) | Business units, scope depth, declarative RBAC seeding, platform-owned assignment |
| [0066](/adr/0066-unified-authorization-model) | Unified model: capability registry, posture, precedence, future refinements |
-| [0068](/adr/0068-identity-roles) | Built-in identity roles, `EvalUser` |
+| [0068](/adr/0068-identity-roles) | Built-in identity positions (formerly "identity roles"), `EvalUser` |
| [0069](/adr/0069-enterprise-authentication-hardening) | Enterprise authentication hardening (phased) |
| [0078](/adr/0078-no-inert-declarable-metadata) | No inert declarable metadata |
| [0086](/adr/0086-authz-metadata-config-boundary-and-cross-package-composition) | Metadata↔config boundary, package provenance, cross-package composition |
diff --git a/content/docs/references/system/environment-artifact.mdx b/content/docs/references/system/environment-artifact.mdx
index 5297955ba0..43c130b9b5 100644
--- a/content/docs/references/system/environment-artifact.mdx
+++ b/content/docs/references/system/environment-artifact.mdx
@@ -173,8 +173,7 @@ Compiled environment metadata grouped by category
| **skills** | `any[]` | optional | |
| **permissions** | `any[]` | optional | |
| **permissionSets** | `any[]` | optional | |
-| **roles** | `any[]` | optional | |
-| **profiles** | `any[]` | optional | |
+| **positions** | `any[]` | optional | |
| **translations** | `any[]` | optional | |
| **datasources** | `any[]` | optional | |
| **datasets** | `any[]` | optional | |
diff --git a/content/docs/ui/role-based-interfaces.mdx b/content/docs/ui/audience-based-interfaces.mdx
similarity index 100%
rename from content/docs/ui/role-based-interfaces.mdx
rename to content/docs/ui/audience-based-interfaces.mdx
diff --git a/content/docs/ui/field-grouping-and-order.mdx b/content/docs/ui/field-grouping-and-order.mdx
index 20569a6df7..e496ea7e4e 100644
--- a/content/docs/ui/field-grouping-and-order.mdx
+++ b/content/docs/ui/field-grouping-and-order.mdx
@@ -64,7 +64,7 @@ Conflating these two is the single most common source of exactly this confusion.
## Why
-Keeping grouping off the field (beyond an optional semantic hint) is what lets **one model project to many surfaces**: a create form, a full edit form, a mobile form, a public intake form, role-specific layouts, a flat table, and an API — each arranging the *same* fields differently. Weld a single layout onto the field and you get exactly one arrangement everywhere, which every surface must then share. The semantic `field.group` is the deliberate middle ground: a default that travels, without dictating layout.
+Keeping grouping off the field (beyond an optional semantic hint) is what lets **one model project to many surfaces**: a create form, a full edit form, a mobile form, a public intake form, audience-specific layouts, a flat table, and an API — each arranging the *same* fields differently. Weld a single layout onto the field and you get exactly one arrangement everywhere, which every surface must then share. The semantic `field.group` is the deliberate middle ground: a default that travels, without dictating layout.
## Runnable example
diff --git a/content/docs/ui/index.mdx b/content/docs/ui/index.mdx
index 7a44f48b6e..12adc5fbc0 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/meta.json b/content/docs/ui/meta.json
index 5a47c4b7ed..0a56e5bca3 100644
--- a/content/docs/ui/meta.json
+++ b/content/docs/ui/meta.json
@@ -13,7 +13,7 @@
"---Recipes---",
"create-vs-edit-form",
"field-grouping-and-order",
- "role-based-interfaces",
+ "audience-based-interfaces",
"public-data-collection"
]
}
diff --git a/content/docs/ui/setup-app.mdx b/content/docs/ui/setup-app.mdx
index f99c9dc311..27b7b1bd43 100644
--- a/content/docs/ui/setup-app.mdx
+++ b/content/docs/ui/setup-app.mdx
@@ -26,7 +26,7 @@ ADR-0048 (one app per package), `/apps/setup` — resolvable as
## Where it lives
-| File | Role |
+| File | Purpose |
|:---|:---|
| `packages/platform-objects/src/apps/setup.app.ts` | The `App` definition — the navigation **shell** (group anchors), branding, required permissions |
| `packages/platform-objects/src/apps/setup-nav.contributions.ts` | `SETUP_NAV_CONTRIBUTIONS` — the nav entries owned by `@objectstack/platform-objects`, merged into the shell at runtime |
@@ -51,7 +51,7 @@ anchors are:
| **Overview** (`group_overview`) | System Overview dashboard — `platform-objects` |
| **Apps** (`group_apps`) | Capability plugins only (e.g. marketplace via cloud-connection); empty otherwise |
| **People & Organization** (`group_people_org`) | Users · Departments · Teams · Organizations · Invitations — `platform-objects` |
-| **Access Control** (`group_access_control`) | Roles / Permission Sets — `plugin-security`; Sharing Rules / Record Shares — `plugin-sharing`; API Keys — `platform-objects` |
+| **Access Control** (`group_access_control`) | Positions / Permission Sets — `plugin-security`; Sharing Rules / Record Shares — `plugin-sharing`; API Keys — `platform-objects` |
| **Approvals** (`group_approvals`) | `plugin-approvals` |
| **Configuration** (`group_configuration`) | All Settings · Branding · Authentication · Email · File Storage · AI & Embedder · Knowledge · Feature Flags — `platform-objects` |
| **Diagnostics** (`group_diagnostics`) | Sessions · Notification Events — `platform-objects`; Audit Logs — `plugin-audit` |
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 5413303546..4f3f2694e8 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -37,7 +37,7 @@ const SUBPATH_RULE_MESSAGE =
// `.parse()` time and is a *value* import that fails loudly on a broken import
// instead of silently degrading to `any` (the #2023 failure mode).
const DOMAIN_TYPES = [
- 'Datasource', 'Connector', 'Policy', 'SharingRule', 'Role', 'PermissionSet',
+ 'Datasource', 'Connector', 'Policy', 'SharingRule', 'Position', 'PermissionSet',
'EmailTemplateDefinition', 'Report', 'Webhook', 'ObjectExtension', 'Cube',
'Mapping', 'Theme', 'TranslationBundle', 'Page', 'Action',
].flatMap((t) => [t, t + 'Input']).join('|');
diff --git a/package.json b/package.json
index 98e47361cf..dac1db4f06 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"objectui:clean": "rm -rf packages/console/dist .cache/objectui-*",
"lint": "eslint . --no-inline-config",
"check:doc-authoring": "node scripts/check-doc-authoring.mjs",
+ "check:role-word": "node scripts/check-role-word.mjs",
"check:authz-resolver": "node scripts/check-single-authz-resolver.mjs",
"check:console-sha": "node scripts/check-console-sha.mjs"
},
diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts
index f94d16a391..b0d1cbd0f9 100644
--- a/packages/lint/src/index.ts
+++ b/packages/lint/src/index.ts
@@ -91,6 +91,7 @@ export {
SECURITY_WILDCARD_VAMA,
SECURITY_ANCHOR_HIGH_PRIVILEGE,
SECURITY_ROLE_WORD,
+ SECURITY_BOOK_AUDIENCE_UNKNOWN_SET,
SECURITY_PRIVATE_NO_READSCOPE,
} from './validate-security-posture.js';
export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js';
diff --git a/packages/lint/src/lazy-deps.test.ts b/packages/lint/src/lazy-deps.test.ts
index 4f51bf47de..ee553fbc15 100644
--- a/packages/lint/src/lazy-deps.test.ts
+++ b/packages/lint/src/lazy-deps.test.ts
@@ -129,5 +129,8 @@ describe('lazy dependency loading (kernel boot-path contract)', () => {
});
expect(depLoaded(req.cache, 'typescript')).toBe(true);
expect(props.some((f) => f.rule === 'react-prop-missing-required' && /objectName/.test(f.message))).toBe(true);
- });
+ // Cold-loading sucrase + typescript in-process takes >5s on a loaded CI
+ // runner (dozens of parallel turbo tasks) — the default 5s timeout flakes
+ // there while the assertion set is pure contract, not latency.
+ }, 30_000);
});
diff --git a/packages/lint/src/validate-security-posture.test.ts b/packages/lint/src/validate-security-posture.test.ts
index 042c77d65d..e4148ca855 100644
--- a/packages/lint/src/validate-security-posture.test.ts
+++ b/packages/lint/src/validate-security-posture.test.ts
@@ -15,6 +15,7 @@ import {
SECURITY_WILDCARD_VAMA,
SECURITY_ANCHOR_HIGH_PRIVILEGE,
SECURITY_ROLE_WORD,
+ SECURITY_BOOK_AUDIENCE_UNKNOWN_SET,
SECURITY_PRIVATE_NO_READSCOPE,
SECURITY_MASTER_DETAIL_UNGRANTED,
} from './validate-security-posture.js';
@@ -315,3 +316,45 @@ describe('validateSecurityPosture · master-detail detail ungranted (framework#2
expect(md[0].where).toBe('object "work_order_item"');
});
});
+
+describe('validateSecurityPosture · book audience (ADR-0046 §6.7 / ADR-0090)', () => {
+ it('flags the reserved word in book names and labels', () => {
+ const findings = validateSecurityPosture({
+ books: [
+ { name: 'crm_role_guide', label: 'CRM Guide', groups: [] },
+ { name: 'crm_admin_guide', label: 'Admin Roles Handbook', groups: [] },
+ ],
+ });
+ const bookRole = findings.filter((f) => f.rule === SECURITY_ROLE_WORD);
+ expect(bookRole).toHaveLength(2);
+ expect(bookRole.map((f) => f.where)).toEqual(['book "crm_role_guide"', 'book "crm_admin_guide"']);
+ });
+
+ it('warns when a { permissionSet } audience references a set the stack does not declare', () => {
+ const findings = validateSecurityPosture({
+ permissions: [{ name: 'crm_admin' }],
+ books: [
+ { name: 'crm_admin_guide', audience: { permissionSet: 'crm_admn' }, groups: [] }, // typo
+ ],
+ });
+ const dangling = findings.filter((f) => f.rule === SECURITY_BOOK_AUDIENCE_UNKNOWN_SET);
+ expect(dangling).toHaveLength(1);
+ expect(dangling[0].severity).toBe('warning');
+ expect(dangling[0].path).toBe('books[0].audience.permissionSet');
+ expect(dangling[0].message).toContain('crm_admn');
+ });
+
+ it('accepts a gated book whose set the stack declares — and scalar audiences', () => {
+ expect(
+ rulesOf({
+ permissions: [{ name: 'crm_admin' }],
+ books: [
+ { name: 'crm_admin_guide', audience: { permissionSet: 'crm_admin' }, groups: [] },
+ { name: 'crm_guide', audience: 'public', groups: [] },
+ { name: 'crm_internal', audience: 'org', groups: [] },
+ { name: 'crm_default', groups: [] },
+ ],
+ }),
+ ).toEqual([]);
+ });
+});
diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts
index 9c997dd299..8922c2926b 100644
--- a/packages/lint/src/validate-security-posture.ts
+++ b/packages/lint/src/validate-security-posture.ts
@@ -14,6 +14,7 @@
* | security-wildcard-vama (error) | ADR-0066 superuser wildcard |
* | security-anchor-high-privilege(error) | ADR-0090 D5/D9 anchors |
* | security-role-word (error) | ADR-0090 D3 vocabulary freeze |
+ * | security-book-audience-unknown-set(warn)| ADR-0046 §6.7 { permissionSet } |
* | security-private-no-readscope (info) | admin-intent mismatch class |
* | security-master-detail-ungranted(warn) | framework#2700 os-tianshun-mtc#43|
*
@@ -38,6 +39,7 @@ export const SECURITY_EXTERNAL_WIDER = 'security-external-wider-than-internal';
export const SECURITY_WILDCARD_VAMA = 'security-wildcard-vama';
export const SECURITY_ANCHOR_HIGH_PRIVILEGE = 'security-anchor-high-privilege';
export const SECURITY_ROLE_WORD = 'security-role-word';
+export const SECURITY_BOOK_AUDIENCE_UNKNOWN_SET = 'security-book-audience-unknown-set';
export const SECURITY_PRIVATE_NO_READSCOPE = 'security-private-no-readscope';
export const SECURITY_MASTER_DETAIL_UNGRANTED = 'security-master-detail-ungranted';
@@ -335,6 +337,46 @@ export function validateSecurityPosture(stack: AnyRec): SecurityFinding[] {
for (const [i, app] of asArray(stack.apps).entries()) {
flagRole('app', app.name, app.label, `app "${String(app.name ?? i)}"`, `apps[${i}].name`);
}
+ for (const [i, book] of asArray(stack.books).entries()) {
+ // Books entered the security-relevant set when `book.audience` became a
+ // permission-model reference (ADR-0046 §6.7 / ADR-0090): their names and
+ // labels are access-adjacent UI copy.
+ flagRole('book', book.name, book.label, `book "${String(book.name ?? i)}"`, `books[${i}].name`);
+ }
+
+ // ── Book audience → permission-set reference must resolve ────────────
+ // A `{ permissionSet }` book audience names a set the reader must hold
+ // (ADR-0046 §6.7). The runtime fails CLOSED on an unknown name (nobody
+ // holds it → nobody reads the book), so a typo is not a leak — but it IS
+ // the "why can nobody see the Admin Guide" support class, and packages
+ // should gate their books on their own sets (ADR-0090 D9 / ADR-0086
+ // provenance). Advisory: an environment-authored book may legitimately
+ // reference an installed package's set that is not in THIS stack.
+ const stackSetNames = new Set(
+ permissionSets
+ .map((ps) => (typeof ps.name === 'string' ? ps.name : undefined))
+ .filter((n): n is string => !!n),
+ );
+ for (const [i, book] of asArray(stack.books).entries()) {
+ const audience = (book as AnyRec).audience;
+ if (!audience || typeof audience !== 'object') continue;
+ const setName = (audience as AnyRec).permissionSet;
+ if (typeof setName !== 'string' || setName.length === 0) continue;
+ if (!stackSetNames.has(setName)) {
+ findings.push({
+ severity: 'warning',
+ rule: SECURITY_BOOK_AUDIENCE_UNKNOWN_SET,
+ where: `book "${String(book.name ?? i)}"`,
+ path: `books[${i}].audience.permissionSet`,
+ message:
+ `book audience references permission set "${setName}", which this stack does not declare. ` +
+ `The runtime fails closed — no holder means NO reader can open the book.`,
+ hint:
+ `Gate the book on one of this package's own permission sets (ADR-0090 D9, e.g. its admin set), ` +
+ `or fix the typo. Ignore if the set is intentionally provided by another installed package.`,
+ });
+ }
+ }
// ── Admin-intent mismatch: private object, plain read, no depth ──────
// An object whose baseline is private (explicit or D1-defaulted) where a set
diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts
index 11a89a953c..91b1ed7dc5 100644
--- a/packages/metadata/src/plugin.ts
+++ b/packages/metadata/src/plugin.ts
@@ -69,7 +69,10 @@ const ARTIFACT_FIELD_TO_TYPE: Record = {
themes: 'theme',
workflows: 'workflow',
flows: 'flow',
- roles: 'role',
+ // ADR-0090 D3: stacks declare `positions` (stack.zod.ts); the retired
+ // `roles: 'role'` mapping matched nothing and silently dropped compiled
+ // positions from artifact ingestion.
+ positions: 'position',
permissions: 'permission',
sharingRules: 'sharing_rule',
policies: 'policy',
diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts
index b5d251d7b9..6971c2ecae 100644
--- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts
+++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts
@@ -1338,67 +1338,12 @@ export const enMetadataForms: NonNullable = {
}
}
},
- profile: {
- label: "Profile",
+ position: {
+ label: "Position",
sections: {
- identity: {
- label: "Identity",
- description: "Permission Sets stack on top of a Profile to grant additional access. Profiles are the base set assigned 1:1 to each user."
- },
- system_permissions: {
- label: "System Permissions",
- description: "High-level capabilities not tied to a specific object — e.g. manage_users, view_audit_logs."
- },
- object_and_field_permissions: {
- label: "Object & Field Permissions",
- description: "Per-object CRUD + per-field FLS. Edit via the matrix editor or paste JSON here."
- },
- tab_and_row_level_security: {
- label: "Tab & Row-Level Security",
- description: "Tab visibility, RLS policies, and custom context variables for predicate evaluation."
- }
- },
- fields: {
- name: {
- label: "Name",
- helpText: "Machine name (snake_case)"
- },
- label: {
- label: "Label",
- helpText: "Display label for admins"
- },
- systemPermissions: {
- label: "System Permissions",
- helpText: "List of system capability keys"
- },
- objects: {
- label: "Objects",
- helpText: "{ \"account\": { allowRead: true, allowEdit: true, ... } }"
- },
- fields: {
- label: "Fields",
- helpText: "{ \"account.amount\": { readable: true, editable: false } }"
- },
- tabPermissions: {
- label: "Tab Permissions",
- helpText: "{ \"app_crm\": \"visible\", \"app_admin\": \"hidden\" }"
- },
- rowLevelSecurity: {
- label: "Row Level Security",
- helpText: "Array of RLS policies (see rls.zod.ts)"
- },
- contextVariables: {
- label: "Context Variables",
- helpText: "Custom variables referenced in RLS predicates"
- }
- }
- },
- role: {
- label: "Role",
- sections: {
- role: {
- label: "Role",
- description: "Roles compose a hierarchy used for record sharing (sales VP → sales mgr → sales rep). Permissions themselves live on Permission Sets and Profiles."
+ position: {
+ label: "Position",
+ description: "A position is a flat, assignable bundle of permission sets (e.g. sales_rep, sales_manager). Capability lives on permission sets; visibility depth lives on the business-unit tree."
}
},
fields: {
@@ -1409,10 +1354,6 @@ export const enMetadataForms: NonNullable = {
label: {
label: "Label"
},
- parent: {
- label: "Parent",
- helpText: "Parent role machine name (Reports To)"
- },
description: {
label: "Description"
}
diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts
index 3b2de070a7..05f3d1bf2a 100644
--- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts
+++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts
@@ -1338,67 +1338,12 @@ export const esESMetadataForms: NonNullable =
}
}
},
- profile: {
- label: "Perfil",
+ position: {
+ label: "Posición",
sections: {
- identity: {
- label: "Identidad",
- description: "Los conjuntos de permisos se apilan sobre un perfil para conceder acceso adicional. Los perfiles son el conjunto base asignado 1:1 a cada usuario."
- },
- system_permissions: {
- label: "Permisos del sistema",
- description: "Capacidades de alto nivel no vinculadas a un objeto específico — p. ej. manage_users, view_audit_logs."
- },
- object_and_field_permissions: {
- label: "Permisos de objeto y campo",
- description: "CRUD por objeto + FLS por campo. Edita mediante el editor matricial o pega JSON aquí."
- },
- tab_and_row_level_security: {
- label: "Pestaña y seguridad a nivel de fila",
- description: "Visibilidad de pestañas, políticas RLS y variables de contexto personalizadas para evaluar predicados."
- }
- },
- fields: {
- name: {
- label: "Nombre",
- helpText: "Nombre de máquina (snake_case)"
- },
- label: {
- label: "Etiqueta",
- helpText: "Etiqueta mostrada para administradores"
- },
- systemPermissions: {
- label: "Permisos del sistema",
- helpText: "Lista de claves de capacidades del sistema"
- },
- objects: {
- label: "Permisos de objeto",
- helpText: "Ejemplo: { \"account\": { allowRead: true, allowEdit: true, ... } }"
- },
- fields: {
- label: "Campos",
- helpText: "Ejemplo: { \"account.amount\": { readable: true, editable: false } }"
- },
- tabPermissions: {
- label: "Permisos de pestaña",
- helpText: "Ejemplo: { \"app_crm\": \"visible\", \"app_admin\": \"hidden\" }"
- },
- rowLevelSecurity: {
- label: "Seguridad a nivel de fila",
- helpText: "Array de políticas RLS (ver rls.zod.ts)"
- },
- contextVariables: {
- label: "Variables de contexto",
- helpText: "Variables personalizadas referenciadas en predicados RLS"
- }
- }
- },
- role: {
- label: "Rol",
- sections: {
- role: {
- label: "Rol",
- description: "Los roles componen una jerarquía usada para compartir registros (sales VP → sales mgr → sales rep). Los permisos en sí residen en Permission Sets y Profiles."
+ position: {
+ label: "Posición",
+ description: "Una posición es un conjunto plano y asignable de permission sets (p. ej. sales_rep, sales_manager). La capacidad reside en los permission sets; la profundidad de visibilidad en el árbol de unidades de negocio."
}
},
fields: {
@@ -1409,10 +1354,6 @@ export const esESMetadataForms: NonNullable =
label: {
label: "Etiqueta"
},
- parent: {
- label: "Padre",
- helpText: "Nombre de máquina del rol padre (Reports To)"
- },
description: {
label: "Descripción"
}
diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts
index 5b83f552cd..baa0a40f53 100644
--- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts
+++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts
@@ -1338,67 +1338,12 @@ export const jaJPMetadataForms: NonNullable =
}
}
},
- profile: {
- label: "プロファイル",
+ position: {
+ label: "ポジション",
sections: {
- identity: {
- label: "ID",
- description: "権限セットはプロファイルに追加アクセス権を重ねる。プロファイルは各ユーザーに 1:1 で割り当てる基本セット。"
- },
- system_permissions: {
- label: "システム権限",
- description: "特定オブジェクトに紐づかない高レベル機能 — 例: manage_users, view_audit_logs。"
- },
- object_and_field_permissions: {
- label: "オブジェクトとフィールド権限",
- description: "オブジェクト単位の CRUD + フィールド単位の FLS。マトリックスエディターで編集、またはここに JSON を貼り付け。"
- },
- tab_and_row_level_security: {
- label: "タブと行レベルセキュリティ",
- description: "タブ表示、RLS ポリシー、述語評価用カスタムコンテキスト変数。"
- }
- },
- fields: {
- name: {
- label: "名前",
- helpText: "マシン名(snake_case)"
- },
- label: {
- label: "表示名",
- helpText: "管理者向け表示ラベル"
- },
- systemPermissions: {
- label: "システム権限",
- helpText: "システム機能キーのリスト"
- },
- objects: {
- label: "オブジェクト権限",
- helpText: "例: { \"account\": { allowRead: true, allowEdit: true, ... } }"
- },
- fields: {
- label: "フィールド",
- helpText: "例: { \"account.amount\": { readable: true, editable: false } }"
- },
- tabPermissions: {
- label: "タブ権限",
- helpText: "例: { \"app_crm\": \"visible\", \"app_admin\": \"hidden\" }"
- },
- rowLevelSecurity: {
- label: "行レベルセキュリティ",
- helpText: "RLS ポリシーの配列(rls.zod.ts 参照)"
- },
- contextVariables: {
- label: "コンテキスト変数",
- helpText: "RLS 述語で参照するカスタム変数"
- }
- }
- },
- role: {
- label: "ロール",
- sections: {
- role: {
- label: "ロール",
- description: "ロールはレコード共有に使う階層を構成(sales VP → sales mgr → sales rep)。権限自体は Permission Sets と Profiles に存在。"
+ position: {
+ label: "ポジション",
+ description: "ポジションは権限セットをまとめて割り当てるフラットな単位(例: sales_rep、sales_manager)。能力は権限セットに、可視範囲の深さはビジネスユニットツリーにあります。"
}
},
fields: {
@@ -1409,10 +1354,6 @@ export const jaJPMetadataForms: NonNullable =
label: {
label: "表示名"
},
- parent: {
- label: "親",
- helpText: "親ロールのマシン名(Reports To)"
- },
description: {
label: "説明"
}
diff --git a/packages/platform-objects/src/apps/translations/metadata-forms-vocabulary.test.ts b/packages/platform-objects/src/apps/translations/metadata-forms-vocabulary.test.ts
new file mode 100644
index 0000000000..5ab6b928a3
--- /dev/null
+++ b/packages/platform-objects/src/apps/translations/metadata-forms-vocabulary.test.ts
@@ -0,0 +1,43 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// Regression guard for ADR-0090 D2/D3 in the generated metadata-form
+// translations. The concepts were retired in the P1 wave, but the FORM
+// registry (and therefore these extractor-generated bundles) lagged: the
+// bundles kept `role`/`profile` groups (with pre-D3 copy like "Roles compose
+// a hierarchy…") while the `position` type had no form translations at all.
+// This asserts the retired keys stay gone and the replacement stays present,
+// across every locale.
+
+import { describe, it, expect } from 'vitest';
+import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system';
+import { enMetadataForms } from './en.metadata-forms.generated.js';
+import { zhCNMetadataForms } from './zh-CN.metadata-forms.generated.js';
+import { jaJPMetadataForms } from './ja-JP.metadata-forms.generated.js';
+import { esESMetadataForms } from './es-ES.metadata-forms.generated.js';
+
+const LOCALES = [
+ { name: 'en', forms: enMetadataForms as Record },
+ { name: 'zh-CN', forms: zhCNMetadataForms as Record },
+ { name: 'ja-JP', forms: jaJPMetadataForms as Record },
+ { name: 'es-ES', forms: esESMetadataForms as Record },
+];
+
+describe('ADR-0090 D2/D3 — metadata-form translations use the v2 vocabulary', () => {
+ it('the form registry itself carries no retired kind', () => {
+ expect(Object.keys(METADATA_FORM_REGISTRY)).not.toContain('role');
+ expect(Object.keys(METADATA_FORM_REGISTRY)).not.toContain('profile');
+ expect(Object.keys(METADATA_FORM_REGISTRY)).toContain('position');
+ });
+
+ for (const { name, forms } of LOCALES) {
+ it(`${name}: retired role/profile groups are gone, position group exists`, () => {
+ expect(forms.role, `${name} still carries a 'role' form group`).toBeUndefined();
+ expect(forms.profile, `${name} still carries a 'profile' form group`).toBeUndefined();
+ expect(forms.position?.label, `${name} lacks the 'position' form group`).toBeTruthy();
+ expect(forms.position?.sections?.position?.label).toBeTruthy();
+ for (const field of ['name', 'label', 'description']) {
+ expect(forms.position?.fields?.[field]?.label, `${name} position.fields.${field}`).toBeTruthy();
+ }
+ });
+ }
+});
diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts
index 3c934db4cb..f815af5e65 100644
--- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts
+++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts
@@ -1338,67 +1338,12 @@ export const zhCNMetadataForms: NonNullable =
}
}
},
- profile: {
- label: "配置文件",
+ position: {
+ label: "岗位",
sections: {
- identity: {
- label: "标识",
- description: "权限集叠加在配置文件之上以授予额外的访问权限。配置文件是按 1:1 分配给每个用户的基础集合。"
- },
- system_permissions: {
- label: "系统权限",
- description: "与特定对象无关的高级能力——例如 manage_users、view_audit_logs。"
- },
- object_and_field_permissions: {
- label: "对象与字段权限",
- description: "按对象的增删改查 + 按字段的字段级安全(FLS)。可通过矩阵编辑器编辑,或在此处粘贴 JSON。"
- },
- tab_and_row_level_security: {
- label: "标签页与行级安全",
- description: "标签页可见性、RLS 策略,以及用于谓词求值的自定义上下文变量。"
- }
- },
- fields: {
- name: {
- label: "名称",
- helpText: "机器名(snake_case)"
- },
- label: {
- label: "显示名称",
- helpText: "面向管理员显示的标签"
- },
- systemPermissions: {
- label: "系统权限",
- helpText: "系统能力键列表"
- },
- objects: {
- label: "对象权限",
- helpText: "示例:{ \"account\": { allowRead: true, allowEdit: true, ... } }"
- },
- fields: {
- label: "字段",
- helpText: "示例:{ \"account.amount\": { readable: true, editable: false } }"
- },
- tabPermissions: {
- label: "标签页权限",
- helpText: "示例:{ \"app_crm\": \"visible\", \"app_admin\": \"hidden\" }"
- },
- rowLevelSecurity: {
- label: "行级安全",
- helpText: "RLS 策略数组(参见 rls.zod.ts)"
- },
- contextVariables: {
- label: "上下文变量",
- helpText: "RLS 谓词中引用的自定义变量"
- }
- }
- },
- role: {
- label: "角色",
- sections: {
- role: {
- label: "角色定义",
- description: "角色名称与继承关系"
+ position: {
+ label: "岗位",
+ description: "岗位是扁平的、可分配的权限集组合(如 sales_rep、sales_manager)。能力在权限集上;可见范围深度在业务单元树上。"
}
},
fields: {
@@ -1409,10 +1354,6 @@ export const zhCNMetadataForms: NonNullable =
label: {
label: "显示名称"
},
- parent: {
- label: "父级",
- helpText: "继承父角色的权限"
- },
description: {
label: "描述"
}
diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts
index d04c4e0c61..759eed96f5 100644
--- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts
+++ b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts
@@ -1,18 +1,18 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
- * bootstrapDeclaredPositions — seed stack-declared `roles` into `sys_position`
+ * bootstrapDeclaredPositions — seed stack-declared `positions` into `sys_position`
* (ADR-0057 D6, closes #2077).
*
- * Reads the validated `role` metadata (registered from the stack's `positions: []`
+ * Reads the validated `position` metadata (registered from the stack's `positions: []`
* via `metadataService.list('position')`) and idempotently upserts each into
- * `sys_position` by `name`, so the runtime role→permission-set resolution
+ * `sys_position` by `name`, so the runtime position→permission-set resolution
* (`resolveExecutionContext` → `sys_position` → `sys_position_permission_set`) and
- * sharing-rule role recipients stop being decorative. Runs on `kernel:ready`
+ * sharing-rule position recipients stop being decorative. Runs on `kernel:ready`
* alongside the platform-admin bootstrap.
*
- * Pre-launch posture (ADR-0057): upsert only — no prune. Role visibility
- * HIERARCHY is NOT seeded here: per ADR-0057 D5 the role is a capability
+ * Pre-launch posture (ADR-0057): upsert only — no prune. Position visibility
+ * HIERARCHY is NOT seeded here: per ADR-0057 D5 the position is a capability
* bundle, and "manager sees subordinates" lives on the `sys_business_unit`
* tree, not `sys_position.parent`.
*/
@@ -44,7 +44,7 @@ interface SeedOptions {
/**
* Read declared metadata items of a type. The engine's SchemaRegistry
- * (populated by `manifest.register` from the stack's `roles`/`sharingRules`
+ * (populated by `manifest.register` from the stack's `positions`/`sharingRules`
* arrays) is the reliable source in every boot path; the metadata-service
* facade only surfaces these once the compiled-artifact loader runs (serve.ts).
*/
diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts
index 35e68929f2..ff2459c9ce 100644
--- a/packages/plugins/plugin-security/src/security-plugin.ts
+++ b/packages/plugins/plugin-security/src/security-plugin.ts
@@ -107,7 +107,7 @@ export interface SecurityPluginOptions {
defaultPermissionSets?: PermissionSet[];
/**
* Permission set name applied as an implicit baseline whenever an
- * authenticated request has no resolved permission sets (and no roles
+ * authenticated request has no resolved permission sets (and no positions
* that map to one). This guarantees baseline tenant/owner RLS for
* every logged-in user even before an admin assigns explicit
* profiles. Set to `null` to disable.
@@ -406,6 +406,16 @@ export class SecurityPlugin implements Plugin {
};
ctx.registerService('security', {
getReadFilter: (object: string, context?: any) => this.getReadFilter(object, context),
+ // [ADR-0046 §6.7] Effective permission-set NAMES for a caller — the
+ // primitive the REST read layer needs to evaluate a permission-set-
+ // gated book/doc audience ({ permissionSet: '…' }). Same resolution
+ // as the middleware (positions expanded, additive baseline), so the
+ // docs gate can never drift from data-plane enforcement. Throws on
+ // resolution failure — callers must fail CLOSED (ADR-0049).
+ resolvePermissionSetNames: async (context?: any): Promise => {
+ const sets = await this.resolvePermissionSetsForContext(context);
+ return sets.map((s) => s.name);
+ },
// [ADR-0090 D6] First-class access explanation. Same code paths as
// the middleware (resolution/evaluator/RLS compiler) — explained by
// construction. Explaining ANOTHER user requires `manage_users`.
@@ -490,14 +500,14 @@ export class SecurityPlugin implements Plugin {
await this.delegatedAdminGate.assert(opCtx);
}
- const roles = opCtx.context?.positions ?? [];
+ const positions = opCtx.context?.positions ?? [];
const explicitPermissionSets = opCtx.context?.permissions ?? [];
- // Skip security checks if no roles AND no explicit permission sets
+ // Skip security checks if no positions AND no explicit permission sets
// AND no userId (anonymous/unauthenticated). The auth middleware
// should handle authentication separately.
if (
- roles.length === 0 &&
+ positions.length === 0 &&
explicitPermissionSets.length === 0 &&
!opCtx.context?.userId
) {
@@ -556,7 +566,7 @@ export class SecurityPlugin implements Plugin {
{
operation: opCtx.operation,
object: opCtx.object,
- roles,
+ positions,
permissionSets: explicitPermissionSets,
requiredPermissions: required,
missingPermissions: missing,
@@ -578,8 +588,8 @@ export class SecurityPlugin implements Plugin {
if (!allowed) {
throw new PermissionDeniedError(
`[Security] Access denied: operation '${opCtx.operation}' on object '${opCtx.object}' ` +
- `is not permitted for roles [${roles.join(', ')}]`,
- { operation: opCtx.operation, object: opCtx.object, roles, permissionSets: explicitPermissionSets },
+ `is not permitted for positions [${positions.join(', ')}]`,
+ { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets },
);
}
}
@@ -659,7 +669,7 @@ export class SecurityPlugin implements Plugin {
{
operation: opCtx.operation,
object: opCtx.object,
- roles,
+ positions,
permissionSets: explicitPermissionSets,
recordId: targetId,
},
@@ -729,7 +739,7 @@ export class SecurityPlugin implements Plugin {
{
operation: opCtx.operation,
object: opCtx.object,
- roles,
+ positions,
permissionSets: explicitPermissionSets,
forbiddenFields: forbidden,
},
@@ -821,7 +831,7 @@ export class SecurityPlugin implements Plugin {
);
throw new PermissionDeniedError(
`[Security] Access denied: the ${opCtx.operation} would violate a row-level CHECK on '${opCtx.object}'`,
- { operation: opCtx.operation, object: opCtx.object, roles, permissionSets: explicitPermissionSets },
+ { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets },
);
}
}
@@ -905,12 +915,12 @@ export class SecurityPlugin implements Plugin {
const report = await bootstrapPlatformAdmin(ql, this.bootstrapPermissionSets, {
logger: ctx.logger,
});
- // [ADR-0057 D6 / #2077] Seed stack-declared roles into sys_position so they
- // stop being decorative (role→permission-set resolution + recipients).
+ // [ADR-0057 D6 / #2077] Seed stack-declared positions into sys_position so they
+ // stop being decorative (position→permission-set resolution + recipients).
try {
await bootstrapDeclaredPositions(ql, this.metadata, { logger: ctx.logger });
} catch (e) {
- ctx.logger.warn('[security] declared-role seeding failed', { error: (e as Error).message });
+ ctx.logger.warn('[security] declared-position seeding failed', { error: (e as Error).message });
}
// [ADR-0086 D5] Seed stack-declared permission sets into
// sys_permission_set with package provenance (managed_by:'package' +
@@ -1024,7 +1034,7 @@ export class SecurityPlugin implements Plugin {
} catch (e) {
ctx.logger.warn('[security] permission publish-materializer registration failed', { error: (e as Error).message });
}
- // [ADR-0068 D2] Seed the framework's reserved built-in identity roles
+ // [ADR-0068 D2] Seed the framework's reserved built-in identity positions
// (platform_admin / org_*) so the role catalog is self-describing.
try {
await bootstrapBuiltinRoles(ql, { logger: ctx.logger });
@@ -1166,7 +1176,7 @@ export class SecurityPlugin implements Plugin {
*
* Returns:
* - `undefined` → no scope applies (system context, or an unauthenticated
- * request with no userId/roles/permissions — authn is gated elsewhere).
+ * request with no userId/positions/permissions — authn is gated elsewhere).
* - a `FilterCondition` → AND it into the object's scan (the join's `ON`/
* `WHERE` for analytics; the where clause for a plain find).
* - the `RLS_DENY_FILTER` sentinel → policies applied but none compiled, or
@@ -1246,12 +1256,12 @@ export class SecurityPlugin implements Plugin {
): Promise | null | undefined> {
// System operations bypass scoping (mirrors the middleware's isSystem skip).
if (context?.isSystem) return undefined;
- const roles = context?.positions ?? [];
+ const positions = context?.positions ?? [];
const explicit = context?.permissions ?? [];
- // Unauthenticated + role-less + permission-less → no scope (the auth layer,
- // not RLS, gates anonymous access; the analytics REST endpoint already 401s
- // without a token). Mirrors the middleware's early `return next()`.
- if (roles.length === 0 && explicit.length === 0 && !context?.userId) {
+ // Unauthenticated + position-less + permission-less → no scope (the auth
+ // layer, not RLS, gates anonymous access; the analytics REST endpoint
+ // already 401s without a token). Mirrors the middleware's early `return next()`.
+ if (positions.length === 0 && explicit.length === 0 && !context?.userId) {
return undefined;
}
try {
@@ -1271,7 +1281,7 @@ export class SecurityPlugin implements Plugin {
}
/**
- * Resolve the effective permission sets for an execution context — roles +
+ * Resolve the effective permission sets for an execution context — positions +
* explicit permission sets, with the configured baseline applied both as an
* implicit request (when none were named) and as a post-resolution fallback
* (when named ones resolved to nothing). Shared by the engine middleware and
@@ -1302,7 +1312,7 @@ export class SecurityPlugin implements Plugin {
{ logger: this.logger },
);
// Post-resolution fallback — closes the fail-open hole where a populated
- // `roles` array maps to no permission set yet (no sys_position binding), which
+ // `positions` array maps to no permission set yet (no sys_position binding), which
// would otherwise skip RLS entirely and expose every tenant's data.
if (
permissionSets.length === 0 &&
diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts
index 5034b68a72..ea40ab241b 100644
--- a/packages/rest/src/rest-server.ts
+++ b/packages/rest/src/rest-server.ts
@@ -1294,6 +1294,63 @@ export class RestServer {
return pending;
}
+ /**
+ * [ADR-0046 §6.7] The audience-evaluation view of the caller for book/doc
+ * gating. `permissionSets` resolves through the security service's
+ * `resolvePermissionSetNames` — the SAME resolution as data-plane
+ * enforcement (positions expanded, additive baseline), so the docs gate
+ * can never drift from it. `permissionSets` stays undefined when the
+ * service is absent or resolution fails; `audienceAllows` then DENIES
+ * permission-set-gated audiences (fail closed, ADR-0049). Resolution is
+ * skipped unless `needPermissionSets` — callers pass true only when a
+ * `{ permissionSet }` audience is actually in play.
+ */
+ private async resolveAudienceCaller(
+ environmentId: string | undefined,
+ req: any,
+ opts: { needPermissionSets: boolean },
+ ): Promise<{ authenticated: boolean; permissionSets?: string[] }> {
+ const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined);
+ const authenticated = !!ctx?.userId;
+ if (!authenticated || !opts.needPermissionSets || !this.securityServiceProvider) {
+ return { authenticated };
+ }
+ try {
+ const svc = await this.securityServiceProvider(environmentId);
+ if (!svc || typeof svc.resolvePermissionSetNames !== 'function') return { authenticated };
+ const names = await svc.resolvePermissionSetNames(ctx);
+ return { authenticated, permissionSets: Array.isArray(names) ? names : [] };
+ } catch {
+ return { authenticated }; // unresolved holdings → gated audiences deny
+ }
+ }
+
+ /** Whether any of these books carries a `{ permissionSet }` audience. */
+ private static anyPermissionSetAudience(books: readonly any[]): boolean {
+ return books.some(
+ (b) => b && typeof b === 'object' && b.audience && typeof b.audience === 'object'
+ && typeof b.audience.permissionSet === 'string',
+ );
+ }
+
+ /** Coerce a getMetaItems result (array | {items}) into an array. */
+ private static metaItemsArray(raw: unknown): any[] {
+ if (Array.isArray(raw)) return raw;
+ if (raw && typeof raw === 'object' && Array.isArray((raw as any).items)) return (raw as any).items;
+ return [];
+ }
+
+ /** Fetch every book of the environment, shaped for the audience resolver. */
+ private async fetchAudienceBooks(p: any, environmentId: string | undefined): Promise {
+ const raw = await p.getMetaItems({
+ type: 'book',
+ ...(environmentId ? { environmentId } : {}),
+ } as any).catch(() => []);
+ return RestServer.metaItemsArray(raw).map((b: any) =>
+ b && typeof b === 'object' ? { ...b, packageId: b._packageId } : b,
+ );
+ }
+
/** Heavy path behind `resolveExecCtx` — resolve identity + RBAC/RLS + localization. */
private async computeExecCtx(environmentId: string | undefined, req: any): Promise {
try {
@@ -2425,6 +2482,67 @@ export class RestServer {
}
}
+ // ADR-0046 §6.7 — book list is audience-filtered: anonymous
+ // callers see only `public` books; `{ permissionSet }`-gated
+ // books require the caller to hold the named set (resolved
+ // through the security service; unresolvable → fail closed).
+ if (req.params.type === 'book') {
+ const raw = visible as unknown;
+ const list = RestServer.metaItemsArray(raw);
+ if (list.length > 0) {
+ const { audienceAllows } = await import('@objectstack/spec/system');
+ const caller = await this.resolveAudienceCaller(environmentId, req, {
+ needPermissionSets: RestServer.anyPermissionSetAudience(list),
+ });
+ const filtered = list.filter((b: any) =>
+ b && typeof b === 'object' && audienceAllows((b as any).audience, caller));
+ visible = Array.isArray(raw) ? filtered : { ...(raw as any), items: filtered };
+ }
+ }
+
+ // ADR-0046 §6.7 — doc list is audience-filtered by each
+ // doc's EFFECTIVE audience (union over the books that
+ // claim it; unclaimed docs default to `org`). Runs on the
+ // raw items (before locale collapse) so `_packageId`
+ // provenance is still present for membership scoping.
+ if (req.params.type === 'doc') {
+ const raw = visible as unknown;
+ const list = RestServer.metaItemsArray(raw);
+ if (list.length > 0) {
+ const { audienceAllows, docAudienceAllows, resolveDocAudiences } =
+ await import('@objectstack/spec/system');
+ const books = await this.fetchAudienceBooks(p, environmentId);
+ const caller = await this.resolveAudienceCaller(environmentId, req, {
+ needPermissionSets: RestServer.anyPermissionSetAudience(books),
+ });
+ let filtered: any[];
+ if (caller.authenticated && !RestServer.anyPermissionSetAudience(books)) {
+ // Fast path: with no gated book anywhere, every
+ // effective audience admits an authenticated caller.
+ filtered = list;
+ } else {
+ const corpus = list
+ .filter((d: any) => d && typeof d === 'object')
+ .map((d: any) => ({
+ name: d.name,
+ group: d.group,
+ tags: d.tags,
+ order: d.order,
+ packageId: d._packageId,
+ }));
+ const audiences = resolveDocAudiences(books as any, corpus);
+ filtered = list.filter((d: any) => {
+ if (!d || typeof d !== 'object') return false;
+ const eff = audiences.get(d.name);
+ return eff
+ ? docAudienceAllows(eff, caller)
+ : audienceAllows('org', caller);
+ });
+ }
+ visible = Array.isArray(raw) ? filtered : { ...(raw as any), items: filtered };
+ }
+ }
+
// ADR-0046 i18n: collapse each doc to the request
// locale (localized label/description, `translations`
// map dropped) before the content-strip step below.
@@ -2530,7 +2648,7 @@ export class RestServer {
const prot = await this.resolveProtocol(environmentId, req);
const locale = this.extractLocale(req);
const packageId = req.query?.package || undefined;
- const { resolveBookTree, deriveImplicitPackageBook, isPublicAudience, resolveDocLocale } =
+ const { resolveBookTree, deriveImplicitPackageBook, audienceAllows, resolveDocAudiences, docAudienceAllows, resolveDocLocale } =
await import('@objectstack/spec/system');
const norm = (raw: any): any[] =>
@@ -2547,10 +2665,21 @@ export class RestServer {
book = deriveImplicitPackageBook(req.params.name, req.params.name);
}
- // §6.7 — anonymous callers only get `public` books.
- const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined);
- if (!ctx?.userId && !isPublicAudience((book as any).audience)) {
- sendError(res, { code: 'unauthorized', message: 'This documentation requires sign-in', status: 401 });
+ // §6.7 — the book's audience gates the whole tree:
+ // anonymous → `public` only; `{ permissionSet }` →
+ // the caller must hold the named set (fail closed
+ // when holdings cannot be resolved, ADR-0049).
+ const audienceBooks = books.map((b: any) =>
+ b && typeof b === 'object' ? { ...b, packageId: b._packageId } : b);
+ const caller = await this.resolveAudienceCaller(environmentId, req, {
+ needPermissionSets: RestServer.anyPermissionSetAudience([book, ...audienceBooks]),
+ });
+ if (!audienceAllows((book as any).audience, caller)) {
+ if (!caller.authenticated) {
+ sendError(res, { code: 'unauthorized', message: 'This documentation requires sign-in', status: 401 });
+ } else {
+ sendError(res, { code: 'PERMISSION_DENIED', message: 'This documentation is limited to holders of a permission set you do not have', status: 403 });
+ }
return;
}
@@ -2571,6 +2700,26 @@ export class RestServer {
}));
const tree = resolveBookTree(book as any, docs, (book as any)._packageId);
+
+ // §6.7 — the tree's ENTRIES are additionally filtered by
+ // each doc's effective audience (union over claiming
+ // books, unclaimed → org), so an anonymous reader of a
+ // public book never sees nav entries that would 401 on
+ // fetch, and gated-only docs stay out of non-holders'
+ // trees. The book gate above passed, so this only ever
+ // narrows further for anonymous / non-holder callers.
+ const gatedTreePossible = !caller.authenticated
+ || RestServer.anyPermissionSetAudience(audienceBooks);
+ if (gatedTreePossible) {
+ const audiences = resolveDocAudiences(audienceBooks as any, docs);
+ tree.groups = tree.groups
+ .map((g: any) => ({
+ ...g,
+ entries: g.entries.filter((e: any) =>
+ !e.doc || docAudienceAllows(audiences.get(e.doc), caller)),
+ }))
+ .filter((g: any) => g.entries.some((e: any) => e.doc || e.href));
+ }
res.json(tree);
} catch (error: any) {
logError("[REST] Unhandled error:", error);
@@ -2638,7 +2787,10 @@ export class RestServer {
// `getMetaItem(type, name, packageId)` path runs.
const packageScoped = typeof req.query?.package === 'string'
&& req.query.package.length > 0;
- if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDraftRead && !previewDrafts && !packageScoped && req.params.type !== 'doc') {
+ // `doc` and `book` bypass the shared cache: their §6.7
+ // audience gate is per-caller, and a shared ETag would
+ // leak gated content across viewers.
+ if (metadata.enableCache && p.getMetaItemCached && !isAppType && !isDraftRead && !previewDrafts && !packageScoped && req.params.type !== 'doc' && req.params.type !== 'book') {
const cacheRequest = {
ifNoneMatch: req.headers['if-none-match'] as string,
ifModifiedSince: req.headers['if-modified-since'] as string,
@@ -2739,6 +2891,57 @@ export class RestServer {
if (serviceGate) visible = this.filterDashboardForUser(visible, serviceGate);
}
+ // ADR-0046 §6.7 — audience gate on single-item reads.
+ // A `book` is gated by its own audience; a `doc` by its
+ // EFFECTIVE audience (union over the books that claim
+ // it, unclaimed → org). 401 for anonymous, 403 for an
+ // authenticated non-holder; fail closed when holdings
+ // cannot be resolved (ADR-0049).
+ if ((req.params.type === 'book' || req.params.type === 'doc') && visible) {
+ const { audienceAllows, docAudienceAllows, resolveDocAudiences } =
+ await import('@objectstack/spec/system');
+ const target = isMetaEnvelope(visible) ? (visible as any).item : visible;
+ let caller: { authenticated: boolean; permissionSets?: string[] };
+ let allowed: boolean;
+ if (req.params.type === 'book') {
+ caller = await this.resolveAudienceCaller(environmentId, req, {
+ needPermissionSets: RestServer.anyPermissionSetAudience([target]),
+ });
+ allowed = audienceAllows(target?.audience, caller);
+ } else {
+ const books = await this.fetchAudienceBooks(p, environmentId);
+ caller = await this.resolveAudienceCaller(environmentId, req, {
+ needPermissionSets: RestServer.anyPermissionSetAudience(books),
+ });
+ if (caller.authenticated && !RestServer.anyPermissionSetAudience(books)) {
+ allowed = true; // no gated book anywhere → org suffices
+ } else {
+ const corpus = RestServer.metaItemsArray(await p.getMetaItems({
+ type: 'doc',
+ ...(environmentId ? { environmentId } : {}),
+ } as any).catch(() => []))
+ .filter((d: any) => d && typeof d === 'object')
+ .map((d: any) => ({
+ name: d.name,
+ group: d.group,
+ tags: d.tags,
+ order: d.order,
+ packageId: d._packageId,
+ }));
+ const audiences = resolveDocAudiences(books as any, corpus);
+ allowed = docAudienceAllows(audiences.get(target?.name), caller);
+ }
+ }
+ if (!allowed) {
+ if (!caller.authenticated) {
+ sendError(res, { code: 'unauthorized', message: 'This documentation requires sign-in', status: 401 });
+ } else {
+ sendError(res, { code: 'PERMISSION_DENIED', message: 'This documentation is limited to holders of a permission set you do not have', status: 403 });
+ }
+ return;
+ }
+ }
+
// ADR-0046 i18n: collapse the doc to the request
// locale (label/description/content) and drop the
// `translations` map so consumers get one body.
diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts
index 6716c703bc..cc50af992b 100644
--- a/packages/rest/src/rest.test.ts
+++ b/packages/rest/src/rest.test.ts
@@ -664,6 +664,154 @@ describe('RestServer', () => {
});
});
+ describe('meta book/doc audience gating (ADR-0046 §6.7 / ADR-0090)', () => {
+ function getMetaRoute(rest: any, method: string, path: string) {
+ return rest.getRoutes().find((r: any) => r.method === method && r.path === path);
+ }
+ const mockRes = () => ({
+ json: vi.fn(),
+ status: vi.fn().mockReturnThis(),
+ header: vi.fn(),
+ send: vi.fn(),
+ });
+ const BOOKS = [
+ { name: 'pub_book', audience: 'public', groups: [{ key: 'g', label: 'G', include: 'pub_*' }], _packageId: 'pkg' },
+ { name: 'org_book', groups: [{ key: 'g', label: 'G', include: 'org_*' }], _packageId: 'pkg' },
+ { name: 'gated_book', audience: { permissionSet: 'crm_admin' }, groups: [{ key: 'g', label: 'G', include: 'gated_*' }], _packageId: 'pkg' },
+ ];
+ const DOCS = [
+ { name: 'pub_intro', _packageId: 'pkg' },
+ { name: 'org_intro', _packageId: 'pkg' },
+ { name: 'gated_setup', _packageId: 'pkg' },
+ ];
+ const metaByType = (books: any[] = BOOKS, docs: any[] = DOCS) =>
+ vi.fn().mockImplementation(async ({ type }: any) =>
+ type === 'book' ? books : type === 'doc' ? docs : []);
+
+ it('tree: anonymous caller is 401ed off a non-public book', async () => {
+ protocol.getMetaItems = metaByType();
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
+ rest.registerRoutes();
+ const route = getMetaRoute(rest, 'GET', '/api/v1/meta/book/:name/tree');
+ const res = mockRes();
+ await route!.handler({ params: { name: 'org_book' }, query: {}, headers: {} }, res);
+ expect(res.status).toHaveBeenCalledWith(401);
+ });
+
+ it('tree: anonymous caller gets a public book, with non-public docs filtered OUT of the tree', async () => {
+ protocol.getMetaItems = metaByType();
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
+ rest.registerRoutes();
+ const route = getMetaRoute(rest, 'GET', '/api/v1/meta/book/:name/tree');
+ const res = mockRes();
+ await route!.handler({ params: { name: 'pub_book' }, query: {}, headers: {} }, res);
+ expect(res.status).not.toHaveBeenCalledWith(401);
+ const tree = res.json.mock.calls.at(-1)![0];
+ const docsInTree = tree.groups.flatMap((g: any) => g.entries.map((e: any) => e.doc));
+ expect(docsInTree).toContain('pub_intro');
+ // org_intro / gated_setup are claimed by other books; the orphan pass
+ // would render them, but the audience filter must drop them for anon.
+ expect(docsInTree).not.toContain('org_intro');
+ expect(docsInTree).not.toContain('gated_setup');
+ });
+
+ it('tree: authenticated non-holder is 403ed off a permission-set-gated book (fail closed without security service)', async () => {
+ protocol.getMetaItems = metaByType();
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
+ (rest as any).resolveExecCtx = vi.fn().mockResolvedValue({ userId: 'u1' });
+ rest.registerRoutes();
+ const route = getMetaRoute(rest, 'GET', '/api/v1/meta/book/:name/tree');
+ const res = mockRes();
+ await route!.handler({ params: { name: 'gated_book' }, query: {}, headers: {} }, res);
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ it('tree: a holder resolved through the security service opens the gated book', async () => {
+ protocol.getMetaItems = metaByType();
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
+ (rest as any).resolveExecCtx = vi.fn().mockResolvedValue({ userId: 'u1' });
+ (rest as any).securityServiceProvider = async () => ({
+ resolvePermissionSetNames: async () => ['member_default', 'crm_admin'],
+ });
+ rest.registerRoutes();
+ const route = getMetaRoute(rest, 'GET', '/api/v1/meta/book/:name/tree');
+ const res = mockRes();
+ await route!.handler({ params: { name: 'gated_book' }, query: {}, headers: {} }, res);
+ expect(res.status).not.toHaveBeenCalledWith(401);
+ expect(res.status).not.toHaveBeenCalledWith(403);
+ const tree = res.json.mock.calls.at(-1)![0];
+ const docsInTree = tree.groups.flatMap((g: any) => g.entries.map((e: any) => e.doc));
+ expect(docsInTree).toContain('gated_setup');
+ });
+
+ it('list: anonymous /meta/book returns only public books', async () => {
+ protocol.getMetaItems = metaByType();
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
+ rest.registerRoutes();
+ const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type');
+ const res = mockRes();
+ await route!.handler({ params: { type: 'book' }, query: {}, headers: {} }, res);
+ const body = res.json.mock.calls.at(-1)![0];
+ expect(body.map((b: any) => b.name)).toEqual(['pub_book']);
+ });
+
+ it('list: anonymous /meta/doc returns only docs whose effective audience is public', async () => {
+ protocol.getMetaItems = metaByType();
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
+ rest.registerRoutes();
+ const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type');
+ const res = mockRes();
+ await route!.handler({ params: { type: 'doc' }, query: {}, headers: {} }, res);
+ const body = res.json.mock.calls.at(-1)![0];
+ expect(body.map((d: any) => d.name)).toEqual(['pub_intro']);
+ });
+
+ it('list: authenticated member sees org + public docs but not gated-only docs', async () => {
+ protocol.getMetaItems = metaByType();
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
+ (rest as any).resolveExecCtx = vi.fn().mockResolvedValue({ userId: 'u1' });
+ (rest as any).securityServiceProvider = async () => ({
+ resolvePermissionSetNames: async () => ['member_default'],
+ });
+ rest.registerRoutes();
+ const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type');
+ const res = mockRes();
+ await route!.handler({ params: { type: 'doc' }, query: {}, headers: {} }, res);
+ const body = res.json.mock.calls.at(-1)![0];
+ expect(body.map((d: any) => d.name).sort()).toEqual(['org_intro', 'pub_intro']);
+ });
+
+ it('item: anonymous GET of an org-only doc is 401; of a public-claimed doc succeeds', async () => {
+ protocol.getMetaItems = metaByType();
+ protocol.getMetaItem = vi.fn().mockImplementation(async ({ name }: any) =>
+ DOCS.find((d) => d.name === name));
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
+ rest.registerRoutes();
+ const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type/:name');
+ const resDenied = mockRes();
+ await route!.handler({ params: { type: 'doc', name: 'org_intro' }, query: {}, headers: {} }, resDenied);
+ expect(resDenied.status).toHaveBeenCalledWith(401);
+ const resOk = mockRes();
+ await route!.handler({ params: { type: 'doc', name: 'pub_intro' }, query: {}, headers: {} }, resOk);
+ expect(resOk.status).not.toHaveBeenCalledWith(401);
+ expect(resOk.json.mock.calls.at(-1)![0]).toMatchObject({ name: 'pub_intro' });
+ });
+
+ it('item: gated book GET bypasses the shared cache and 403s a non-holder', async () => {
+ protocol.getMetaItemCached = vi.fn();
+ protocol.getMetaItems = metaByType();
+ protocol.getMetaItem = vi.fn().mockResolvedValue(BOOKS[2]);
+ const rest = new RestServer(server as any, protocol as any, ANON_API as any);
+ (rest as any).resolveExecCtx = vi.fn().mockResolvedValue({ userId: 'u1' });
+ rest.registerRoutes();
+ const route = getMetaRoute(rest, 'GET', '/api/v1/meta/:type/:name');
+ const res = mockRes();
+ await route!.handler({ params: { type: 'book', name: 'gated_book' }, query: {}, headers: {} }, res);
+ expect(protocol.getMetaItemCached).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+ });
+
describe('meta single-item Cache-Control header (cached path)', () => {
function getMetaItemRoute(rest: any) {
return rest
diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json
index 95678f765b..7ae83ed875 100644
--- a/packages/spec/api-surface.json
+++ b/packages/spec/api-surface.json
@@ -517,6 +517,8 @@
"AppManifestSchema (const)",
"AppTranslationBundle (type)",
"AppTranslationBundleSchema (const)",
+ "AudienceBook (interface)",
+ "AudienceCaller (interface)",
"AuditConfig (type)",
"AuditConfigSchema (const)",
"AuditEvent (type)",
@@ -1180,12 +1182,14 @@
"WorkerConfigSchema (const)",
"WorkerStats (type)",
"WorkerStatsSchema (const)",
+ "audienceAllows (function)",
"azureBlobStorageExample (const)",
"defineBook (function)",
"defineEmailTemplateDefinition (function)",
"defineJob (function)",
"defineTranslationBundle (function)",
"deriveImplicitPackageBook (function)",
+ "docAudienceAllows (function)",
"emailTemplateForm (const)",
"gcsStorageExample (const)",
"isPublicAudience (function)",
@@ -1193,7 +1197,9 @@
"resolveActionConfirm (function)",
"resolveActionLabel (function)",
"resolveActionSuccess (function)",
+ "resolveBookClaimedDocs (function)",
"resolveBookTree (function)",
+ "resolveDocAudiences (function)",
"resolveDocLocale (function)",
"resolveMetadataFormLabels (function)",
"resolveMetadataTypeDescription (function)",
diff --git a/packages/spec/src/identity/position.test.ts b/packages/spec/src/identity/position.test.ts
index 2884b6cd3c..a10150f5c7 100644
--- a/packages/spec/src/identity/position.test.ts
+++ b/packages/spec/src/identity/position.test.ts
@@ -3,62 +3,62 @@ import { PositionSchema, type Position } from './position.zod';
describe('PositionSchema', () => {
describe('Basic Properties', () => {
- it('should accept minimal role', () => {
- const role: Position = {
+ it('should accept minimal position', () => {
+ const position: Position = {
name: 'ceo',
label: 'CEO',
};
- expect(() => PositionSchema.parse(role)).not.toThrow();
+ expect(() => PositionSchema.parse(position)).not.toThrow();
});
- it('should enforce snake_case for role name', () => {
+ it('should enforce snake_case for position name', () => {
const validNames = ['ceo', 'vp_sales', 'sales_manager', 'account_exec'];
validNames.forEach(name => {
expect(() => PositionSchema.parse({ name, label: 'Test' })).not.toThrow();
});
- const invalidNames = ['CEO', 'VP-Sales', 'salesManager', '123role', '_internal'];
+ const invalidNames = ['CEO', 'VP-Sales', 'salesManager', '123position', '_internal'];
invalidNames.forEach(name => {
expect(() => PositionSchema.parse({ name, label: 'Test' })).toThrow();
});
});
- it('should accept role with description', () => {
- const role: Position = {
+ it('should accept position with description', () => {
+ const position: Position = {
name: 'sales_director',
label: 'Sales Director',
description: 'Oversees all sales operations',
};
- expect(() => PositionSchema.parse(role)).not.toThrow();
+ expect(() => PositionSchema.parse(position)).not.toThrow();
});
});
describe('Hierarchy', () => {
- it('should accept role without parent (top level)', () => {
- const role: Position = {
+ it('should accept position without parent (top level)', () => {
+ const position: Position = {
name: 'ceo',
label: 'Chief Executive Officer',
};
- expect(() => PositionSchema.parse(role)).not.toThrow();
+ expect(() => PositionSchema.parse(position)).not.toThrow();
});
- it('should accept role with parent', () => {
- const role: Position = {
+ it('should accept position with parent', () => {
+ const position: Position = {
name: 'vp_sales',
label: 'VP of Sales',
parent: 'ceo',
};
- expect(() => PositionSchema.parse(role)).not.toThrow();
+ expect(() => PositionSchema.parse(position)).not.toThrow();
});
});
- describe('Real-World Role Hierarchy Examples', () => {
+ describe('Real-World Position Examples', () => {
it('should accept complete sales organization hierarchy', () => {
- const roles: Position[] = [
+ const positions: Position[] = [
{
name: 'ceo',
label: 'Chief Executive Officer',
@@ -96,13 +96,13 @@ describe('PositionSchema', () => {
},
];
- roles.forEach(role => {
- expect(() => PositionSchema.parse(role)).not.toThrow();
+ positions.forEach(position => {
+ expect(() => PositionSchema.parse(position)).not.toThrow();
});
});
it('should accept service organization hierarchy', () => {
- const roles: Position[] = [
+ const positions: Position[] = [
{
name: 'vp_customer_success',
label: 'VP of Customer Success',
@@ -124,13 +124,13 @@ describe('PositionSchema', () => {
},
];
- roles.forEach(role => {
- expect(() => PositionSchema.parse(role)).not.toThrow();
+ positions.forEach(position => {
+ expect(() => PositionSchema.parse(position)).not.toThrow();
});
});
it('should accept product organization hierarchy', () => {
- const roles: Position[] = [
+ const positions: Position[] = [
{
name: 'cto',
label: 'Chief Technology Officer',
@@ -163,13 +163,13 @@ describe('PositionSchema', () => {
},
];
- roles.forEach(role => {
- expect(() => PositionSchema.parse(role)).not.toThrow();
+ positions.forEach(position => {
+ expect(() => PositionSchema.parse(position)).not.toThrow();
});
});
it('should accept matrix organization with multiple reporting lines', () => {
- const roles: Position[] = [
+ const positions: Position[] = [
{
name: 'ceo',
label: 'CEO',
@@ -201,13 +201,13 @@ describe('PositionSchema', () => {
},
];
- roles.forEach(role => {
- expect(() => PositionSchema.parse(role)).not.toThrow();
+ positions.forEach(position => {
+ expect(() => PositionSchema.parse(position)).not.toThrow();
});
});
it('should accept flat organization', () => {
- const roles: Position[] = [
+ const positions: Position[] = [
{
name: 'founder',
label: 'Founder',
@@ -219,8 +219,8 @@ describe('PositionSchema', () => {
},
];
- roles.forEach(role => {
- expect(() => PositionSchema.parse(role)).not.toThrow();
+ positions.forEach(position => {
+ expect(() => PositionSchema.parse(position)).not.toThrow();
});
});
});
diff --git a/packages/spec/src/system/audit.zod.ts b/packages/spec/src/system/audit.zod.ts
index 6fa9f85452..647a31ff73 100644
--- a/packages/spec/src/system/audit.zod.ts
+++ b/packages/spec/src/system/audit.zod.ts
@@ -139,7 +139,7 @@ export type AuditEventActor = z.infer;
*/
export const AuditEventTargetSchema = lazySchema(() => z.object({
/**
- * Target type (e.g., 'object', 'record', 'user', 'role', 'config')
+ * Target type (e.g., 'object', 'record', 'user', 'position', 'config')
*/
type: z.string().describe('Target type'),
diff --git a/packages/spec/src/system/book.test.ts b/packages/spec/src/system/book.test.ts
index 60d47faced..1eb7a13b55 100644
--- a/packages/spec/src/system/book.test.ts
+++ b/packages/spec/src/system/book.test.ts
@@ -6,6 +6,10 @@ import {
resolveBookTree,
deriveImplicitPackageBook,
isPublicAudience,
+ audienceAllows,
+ resolveBookClaimedDocs,
+ resolveDocAudiences,
+ docAudienceAllows,
type Book,
type ResolverDoc,
} from './book.zod';
@@ -158,3 +162,88 @@ describe('deriveImplicitPackageBook + audience', () => {
expect(isPublicAudience(undefined)).toBe(false);
});
});
+
+describe('audienceAllows (ADR-0046 §6.7, ADR-0090 vocabulary)', () => {
+ const anon = { authenticated: false };
+ const member = { authenticated: true, permissionSets: ['member_default'] };
+ const admin = { authenticated: true, permissionSets: ['member_default', 'crm_admin'] };
+ const unresolved = { authenticated: true }; // security resolution unavailable
+
+ it('public allows everyone, including anonymous', () => {
+ for (const caller of [anon, member, admin, unresolved]) {
+ expect(audienceAllows('public', caller)).toBe(true);
+ }
+ });
+ it('org (and unset) allows any authenticated principal, never anonymous', () => {
+ for (const audience of ['org', undefined] as const) {
+ expect(audienceAllows(audience, anon)).toBe(false);
+ expect(audienceAllows(audience, member)).toBe(true);
+ expect(audienceAllows(audience, unresolved)).toBe(true);
+ }
+ });
+ it('permission-set gate requires holding the named set', () => {
+ const gated = { permissionSet: 'crm_admin' };
+ expect(audienceAllows(gated, admin)).toBe(true);
+ expect(audienceAllows(gated, member)).toBe(false);
+ expect(audienceAllows(gated, anon)).toBe(false);
+ });
+ it('permission-set gate FAILS CLOSED when holdings are unknown (ADR-0049)', () => {
+ expect(audienceAllows({ permissionSet: 'crm_admin' }, unresolved)).toBe(false);
+ });
+});
+
+describe('resolveDocAudiences — union over claiming books (§6.7)', () => {
+ const corpus: ResolverDoc[] = [
+ { name: 'crm_guide_intro', packageId: 'crm' },
+ { name: 'crm_guide_flows', packageId: 'crm' },
+ { name: 'crm_admin_setup', packageId: 'crm' },
+ { name: 'crm_unclaimed', packageId: 'crm' },
+ ];
+ const publicBook: Book & { packageId?: string } = {
+ name: 'crm_guide',
+ audience: 'public',
+ groups: [{ key: 'g', label: 'Guide', include: 'crm_guide_*' }],
+ packageId: 'crm',
+ };
+ const adminBook: Book & { packageId?: string } = {
+ name: 'crm_admin_guide',
+ audience: { permissionSet: 'crm_admin' },
+ groups: [{ key: 'a', label: 'Admin', include: 'crm_admin_*' }],
+ packageId: 'crm',
+ };
+
+ it('claimed docs union the audiences of every claiming book', () => {
+ const audiences = resolveDocAudiences([publicBook, adminBook], corpus);
+ expect(audiences.get('crm_guide_intro')).toEqual(['public']);
+ expect(audiences.get('crm_admin_setup')).toEqual([{ permissionSet: 'crm_admin' }]);
+ });
+ it('a doc claimed by no book defaults to org — orphans do NOT ride along with public books', () => {
+ const audiences = resolveDocAudiences([publicBook, adminBook], corpus);
+ expect(audiences.get('crm_unclaimed')).toEqual(['org']);
+ // The tree view still renders the orphan (nothing is dropped)…
+ const tree = resolveBookTree(publicBook, corpus, 'crm');
+ expect(tree.groups.at(-1)!.entries.some((e) => e.doc === 'crm_unclaimed')).toBe(true);
+ // …but the CLAIM set excludes it, so it never inherits `public`.
+ expect(resolveBookClaimedDocs(publicBook, corpus, 'crm').has('crm_unclaimed')).toBe(false);
+ });
+ it('docAudienceAllows applies the union: any allowing book grants access', () => {
+ const both: Book & { packageId?: string } = {
+ ...adminBook,
+ name: 'crm_all',
+ audience: 'org',
+ groups: [{ key: 'all', label: 'All', include: 'crm_admin_*' }],
+ };
+ const audiences = resolveDocAudiences([adminBook, both], corpus);
+ const member = { authenticated: true, permissionSets: ['member_default'] };
+ // Reachable via the org book even without crm_admin.
+ expect(docAudienceAllows(audiences.get('crm_admin_setup'), member)).toBe(true);
+ // Gated-only doc stays gated.
+ const gatedOnly = resolveDocAudiences([adminBook], corpus);
+ expect(docAudienceAllows(gatedOnly.get('crm_admin_setup'), member)).toBe(false);
+ expect(docAudienceAllows(gatedOnly.get('crm_admin_setup'), { authenticated: false })).toBe(false);
+ });
+ it('an unknown doc (absent from the corpus map) gets the org default', () => {
+ expect(docAudienceAllows(undefined, { authenticated: true })).toBe(true);
+ expect(docAudienceAllows(undefined, { authenticated: false })).toBe(false);
+ });
+});
diff --git a/packages/spec/src/system/book.zod.ts b/packages/spec/src/system/book.zod.ts
index 8125a5fb85..87ad2cdbc4 100644
--- a/packages/spec/src/system/book.zod.ts
+++ b/packages/spec/src/system/book.zod.ts
@@ -321,3 +321,98 @@ export function deriveImplicitPackageBook(packageId: string, label?: string): Bo
export function isPublicAudience(audience?: BookAudience): boolean {
return audience === 'public';
}
+
+// ---------------------------------------------------------------------------
+// Audience evaluation (ADR-0046 §6.7, vocabulary per ADR-0090) — pure helpers
+// shared by the REST read layer so `/meta/book` and `/meta/doc` gate on ONE
+// canonical semantics and can never drift from each other.
+// ---------------------------------------------------------------------------
+
+/** What the read layer knows about the caller when evaluating an audience. */
+export interface AudienceCaller {
+ /** True when the request carries an authenticated principal. */
+ authenticated: boolean;
+ /**
+ * Names of the permission sets the caller effectively holds (positions
+ * expanded, baseline included — the security plugin's resolution).
+ * Absent/undefined means "unknown" and permission-set-gated audiences
+ * DENY (fail closed, ADR-0049) — pass an empty array only when resolution
+ * genuinely returned nothing.
+ */
+ permissionSets?: readonly string[];
+}
+
+/**
+ * Whether a caller may read content published under `audience`:
+ * `'public'` → always; `'org'` (or unset — §3.6 default) → any authenticated
+ * principal; `{ permissionSet }` → an authenticated principal that holds the
+ * named set. Unknown/unresolvable set holdings deny (fail closed).
+ */
+export function audienceAllows(audience: BookAudience | undefined, caller: AudienceCaller): boolean {
+ if (isPublicAudience(audience)) return true;
+ if (!caller.authenticated) return false;
+ if (audience === undefined || audience === 'org') return true;
+ if (typeof audience === 'object' && typeof audience.permissionSet === 'string') {
+ return Array.isArray(caller.permissionSets) && caller.permissionSets.includes(audience.permissionSet);
+ }
+ return false; // unknown future shape → fail closed
+}
+
+/**
+ * Doc names a book CLAIMS — members placed by a group rule (`include`),
+ * an explicit `doc.group`, or a pinned `pages` override. Deliberately
+ * EXCLUDES the synthetic *Uncategorized* orphan group: orphans are a
+ * rendering convenience ("nothing is ever dropped" in the tree view), not
+ * an authored membership claim, and letting them ride along would leak
+ * every unclaimed doc of a package through any `public` book it ships.
+ */
+export function resolveBookClaimedDocs(book: Book, docs: ResolverDoc[], bookPackage?: string): Set {
+ const claimed = new Set();
+ for (const group of resolveBookTree(book, docs, bookPackage).groups) {
+ if (group.key === UNCATEGORIZED_KEY) continue;
+ for (const entry of group.entries) {
+ if (entry.doc) claimed.add(entry.doc);
+ }
+ }
+ return claimed;
+}
+
+/** A book plus the provenance the audience resolver needs. */
+export interface AudienceBook extends Book {
+ /** Owning package id (stamped `_packageId` by the metadata layer). */
+ packageId?: string;
+}
+
+/**
+ * Effective audience of every doc = the union over the books that claim it
+ * (ADR-0046 §6.7). A doc claimed by no authored book falls back to `'org'` —
+ * the §3.6 package-grant default (equivalently: the implicit per-package
+ * book, §6.4, whose audience is `'org'`). Exposure is therefore always an
+ * authored decision: nothing becomes `public` or permission-set-gated except
+ * through a book that explicitly claims it.
+ */
+export function resolveDocAudiences(
+ books: readonly AudienceBook[],
+ docs: ResolverDoc[],
+): Map {
+ const audiences = new Map();
+ for (const doc of docs) audiences.set(doc.name, []);
+ for (const book of books) {
+ const audience: BookAudience = book.audience ?? 'org';
+ for (const name of resolveBookClaimedDocs(book, docs, book.packageId)) {
+ audiences.get(name)?.push(audience);
+ }
+ }
+ for (const [name, list] of audiences) {
+ if (list.length === 0) audiences.set(name, ['org']);
+ }
+ return audiences;
+}
+
+/** Whether a caller may read a doc given its effective audiences (§6.7 union). */
+export function docAudienceAllows(audiences: readonly BookAudience[] | undefined, caller: AudienceCaller): boolean {
+ // A doc with no computed entry (not in the corpus handed to the resolver)
+ // gets the same 'org' default as an unclaimed doc.
+ if (!audiences || audiences.length === 0) return audienceAllows('org', caller);
+ return audiences.some((a) => audienceAllows(a, caller));
+}
diff --git a/packages/spec/src/system/environment-artifact.zod.ts b/packages/spec/src/system/environment-artifact.zod.ts
index 0fd69cd3d9..b237e77fa5 100644
--- a/packages/spec/src/system/environment-artifact.zod.ts
+++ b/packages/spec/src/system/environment-artifact.zod.ts
@@ -200,8 +200,11 @@ export const EnvironmentArtifactMetadataSchema = z
skills: z.array(z.unknown()).optional(),
permissions: z.array(z.unknown()).optional(),
permissionSets: z.array(z.unknown()).optional(),
- roles: z.array(z.unknown()).optional(),
- profiles: z.array(z.unknown()).optional(),
+ // ADR-0090 D3/D2: `roles`/`profiles` categories retired with the concepts
+ // (stacks declare `positions`; the profile concept is gone). Old artifacts
+ // carrying them still parse via `passthrough()` — the keys are simply no
+ // longer part of the declared envelope.
+ positions: z.array(z.unknown()).optional(),
translations: z.array(z.unknown()).optional(),
datasources: z.array(z.unknown()).optional(),
datasets: z.array(z.unknown()).optional(),
diff --git a/packages/spec/src/system/metadata-form-registry.ts b/packages/spec/src/system/metadata-form-registry.ts
index dcdc2784ea..17843b2ddc 100644
--- a/packages/spec/src/system/metadata-form-registry.ts
+++ b/packages/spec/src/system/metadata-form-registry.ts
@@ -72,7 +72,12 @@ export const METADATA_FORM_REGISTRY: Readonly> = Object
view: viewForm,
app: appForm,
dashboard: dashboardForm,
- role: positionForm,
+ // ADR-0090 D3/D2: the `role` and `profile` metadata kinds were retired
+ // in the P1 wave (positions replace roles; the profile concept is gone).
+ // The registry keys are the /meta type machine names, so the entries
+ // moved with them — a `role:`/`profile:` key here would be dead weight
+ // the extractor still emits translations for.
+ position: positionForm,
action: actionForm,
page: pageForm,
agent: agentForm,
@@ -80,7 +85,6 @@ export const METADATA_FORM_REGISTRY: Readonly> = Object
skill: skillForm,
flow: flowForm,
permission: permissionForm,
- profile: permissionForm,
email_template: emailTemplateForm,
});
diff --git a/scripts/check-doc-authoring.mjs b/scripts/check-doc-authoring.mjs
index ba51eea827..8d122cfb2b 100644
--- a/scripts/check-doc-authoring.mjs
+++ b/scripts/check-doc-authoring.mjs
@@ -22,7 +22,7 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'references']);
const SKIP_FILES = new Set(['content/docs/ai/skills-reference.mdx']);
const DOMAINS = [
- 'Datasource', 'Connector', 'Policy', 'SharingRule', 'Role', 'PermissionSet',
+ 'Datasource', 'Connector', 'Policy', 'SharingRule', 'Position', 'PermissionSet',
'EmailTemplateDefinition', 'Report', 'Webhook', 'ObjectExtension', 'Cube',
'Mapping', 'Theme', 'TranslationBundle', 'Page', 'Action',
].join('|');
diff --git a/scripts/check-role-word.mjs b/scripts/check-role-word.mjs
new file mode 100644
index 0000000000..78277d6860
--- /dev/null
+++ b/scripts/check-role-word.mjs
@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+// check-role-word — ADR-0090 D3 vocabulary ratchet for hand-written docs.
+//
+// D3 makes "role" a reserved-forbidden word (capability = permission_set,
+// distribution = position, hierarchy = business_unit). The publish-time lint
+// (`security-role-word`, packages/lint) enforces this for AUTHORED METADATA;
+// nothing enforced it for the repo's own documentation — which is how the
+// pre-D3 copy in book.zod.ts ("role-gated") and content/docs survived the
+// P1 rename wave (#2697 was identifier-driven).
+//
+// This is a RATCHET, not a ban-with-exceptions: existing occurrences are
+// frozen in scripts/role-word-baseline.json (many are legitimate — the
+// better-auth boundary, ARIA `role=` in samples, educational "formerly
+// roles" mentions — and untangling them file-by-file is incremental work).
+// The check fails when:
+// • a file NOT in the baseline contains the word, or
+// • a baselined file's count INCREASES, or
+// • a baselined file's count DECREASED / file vanished (improvement!) —
+// run with --update to ratchet the baseline down and commit it.
+//
+// node scripts/check-role-word.mjs [--update]
+//
+// Scope: content/docs (hand-written; references/ is generated from spec and
+// excluded — the spec source is the fix site there) and skills/. File and
+// directory NAMES count too (they become URLs).
+import { readdirSync, readFileSync, writeFileSync, statSync, existsSync } from 'node:fs';
+import { join, relative } from 'node:path';
+
+const ROOTS = ['content/docs', 'skills'];
+const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'references']);
+const EXTENSIONS = new Set(['.mdx', '.md']);
+const BASELINE_PATH = 'scripts/role-word-baseline.json';
+const WORD = /\brole(?:s)?\b/gi;
+
+const update = process.argv.includes('--update');
+
+function walk(dir, out) {
+ for (const e of readdirSync(dir)) {
+ if (SKIP_DIRS.has(e)) continue;
+ const p = join(dir, e);
+ const s = statSync(p);
+ if (s.isDirectory()) walk(p, out);
+ else if ([...EXTENSIONS].some((x) => e.endsWith(x))) out.push(p);
+ }
+}
+
+function countMatches(text) {
+ const m = text.match(WORD);
+ return m ? m.length : 0;
+}
+
+const files = [];
+for (const root of ROOTS) if (existsSync(root)) walk(root, files);
+
+const current = {};
+for (const f of files.sort()) {
+ const rel = relative('.', f).replace(/\\/g, '/');
+ // File/dir names are URLs — a `role-*` slug is UI copy (counts once).
+ const nameHits = countMatches(rel);
+ const bodyHits = countMatches(readFileSync(f, 'utf8'));
+ const total = nameHits + bodyHits;
+ if (total > 0) current[rel] = total;
+}
+
+if (update) {
+ writeFileSync(BASELINE_PATH, JSON.stringify(current, null, 2) + '\n');
+ console.log(`role-word baseline updated: ${Object.keys(current).length} file(s).`);
+ process.exit(0);
+}
+
+const baseline = existsSync(BASELINE_PATH)
+ ? JSON.parse(readFileSync(BASELINE_PATH, 'utf8'))
+ : {};
+
+const errors = [];
+for (const [file, count] of Object.entries(current)) {
+ const allowed = baseline[file];
+ if (allowed === undefined) {
+ errors.push(`${file}: NEW use of the reserved word "role" (${count} occurrence(s)). ` +
+ `ADR-0090 D3: use permission_set / position / business_unit — or, for a genuine ` +
+ `boundary (better-auth, ARIA, quoted history), add it to ${BASELINE_PATH} with --update.`);
+ } else if (count > allowed) {
+ errors.push(`${file}: role-word count grew ${allowed} → ${count}. New occurrences are banned (ADR-0090 D3).`);
+ }
+}
+for (const [file, allowed] of Object.entries(baseline)) {
+ const now = current[file];
+ if (now === undefined) {
+ errors.push(`${file}: baselined file is clean/gone (was ${allowed}) — ratchet DOWN: run \`node scripts/check-role-word.mjs --update\` and commit the baseline.`);
+ } else if (now < allowed) {
+ errors.push(`${file}: role-word count improved ${allowed} → ${now} — ratchet DOWN: run \`node scripts/check-role-word.mjs --update\` and commit the baseline.`);
+ }
+}
+
+if (errors.length) {
+ console.error(`check-role-word: ${errors.length} problem(s)\n`);
+ for (const e of errors) console.error(' • ' + e);
+ process.exit(1);
+}
+console.log(`check-role-word: OK (${Object.keys(current).length} baselined file(s), no new occurrences).`);
diff --git a/scripts/role-word-baseline.json b/scripts/role-word-baseline.json
new file mode 100644
index 0000000000..f62b0dc576
--- /dev/null
+++ b/scripts/role-word-baseline.json
@@ -0,0 +1,47 @@
+{
+ "content/docs/ai/agents.mdx": 5,
+ "content/docs/ai/index.mdx": 2,
+ "content/docs/api/error-catalog.mdx": 1,
+ "content/docs/api/wire-format.mdx": 1,
+ "content/docs/automation/approvals.mdx": 5,
+ "content/docs/automation/hooks.mdx": 1,
+ "content/docs/concepts/architecture.mdx": 4,
+ "content/docs/concepts/metadata-lifecycle.mdx": 1,
+ "content/docs/data-modeling/seed-data.mdx": 2,
+ "content/docs/deployment/migration-from-objectql.mdx": 2,
+ "content/docs/getting-started/cli.mdx": 1,
+ "content/docs/getting-started/common-patterns.mdx": 1,
+ "content/docs/getting-started/index.mdx": 1,
+ "content/docs/getting-started/quick-start.mdx": 1,
+ "content/docs/kernel/contracts/auth-service.mdx": 4,
+ "content/docs/kernel/contracts/data-engine.mdx": 1,
+ "content/docs/kernel/contracts/metadata-service.mdx": 1,
+ "content/docs/kernel/events.mdx": 1,
+ "content/docs/kernel/runtime-services/examples.mdx": 2,
+ "content/docs/kernel/runtime-services/sharing-service.mdx": 2,
+ "content/docs/kernel/services-checklist.mdx": 5,
+ "content/docs/permissions/authorization.mdx": 3,
+ "content/docs/permissions/index.mdx": 1,
+ "content/docs/permissions/permission-metadata.mdx": 2,
+ "content/docs/permissions/permission-sets.mdx": 1,
+ "content/docs/permissions/positions.mdx": 6,
+ "content/docs/plugins/anatomy.mdx": 7,
+ "content/docs/plugins/packages.mdx": 2,
+ "content/docs/protocol/diagram.mdx": 4,
+ "content/docs/protocol/objectos/error-handling.mdx": 1,
+ "content/docs/protocol/objectos/index.mdx": 1,
+ "content/docs/protocol/objectos/runtime-capabilities.mdx": 4,
+ "content/docs/protocol/objectql/security.mdx": 1,
+ "content/docs/protocol/objectui/record-alert.mdx": 1,
+ "content/docs/protocol/objectui/widget-contract.mdx": 3,
+ "content/docs/releases/implementation-status.mdx": 1,
+ "content/docs/ui/forms.mdx": 3,
+ "skills/objectstack-ai/SKILL.md": 8,
+ "skills/objectstack-api/SKILL.md": 1,
+ "skills/objectstack-automation/SKILL.md": 3,
+ "skills/objectstack-data/SKILL.md": 8,
+ "skills/objectstack-data/rules/relationships.md": 1,
+ "skills/objectstack-platform/SKILL.md": 3,
+ "skills/objectstack-query/rules/filters.md": 9,
+ "skills/objectstack-ui/SKILL.md": 2
+}