diff --git a/.changeset/18031-permissions-key-two-readings.md b/.changeset/18031-permissions-key-two-readings.md new file mode 100644 index 0000000000..96ad72556a --- /dev/null +++ b/.changeset/18031-permissions-key-two-readings.md @@ -0,0 +1,14 @@ +--- +"@objectstack/plugin-security": minor +"@objectstack/spec": patch +--- + +A package whose `manifest.permissions` carries the ADR-0025 capability grant is now NAMED when the audience-binding reconciler skips it, instead of vanishing; and both halves of the `permissions` key now point at each other in the spec (#18031). + +`permissions` has two incompatible readings and the package registry stores both in the same slot. At the AUTHORING stage `ManifestSchema.permissions` is the capability grant a plugin requests — the legacy flat `string[]`, or the structured `{ services, hooks, network, fs }` block (ADR-0025 §3.2). At the ASSEMBLED stage the collection wins and the same key is the ADR-0090 `PermissionSet[]` collection (`AssembledPackageBodySchema`, ADR-0130 D4). `SchemaRegistry.installPackage` records whichever stage its caller handed it. + +- **`collectDeclaredSuggestions` reports the reading it cannot use.** It wants the assembled one. Handed the authoring one it returned an empty list and logged nothing: the structured arm is an object, so `Array.isArray(manifest.permissions)` was false and the value never entered the loop; every member of the legacy arm is a bare string, so `consider`'s `typeof ps !== 'object'` line dropped all of them. A package declaring the other reading produced no `sys_audience_binding_suggestion` row, no prompt and no log. It now warns once per engine per package and arm, naming which arm it found, what is lost if the author meant permission sets (no admin is ever prompted to bind the set, and the deployment goes on looking healthy), and where the sets belong — the package's own `defineStack({ permissions: [ … ] })`, which is what the assembled body carries. +- **`warn`, not `error`, and deliberately.** Nothing here claims to have persisted anything, so this is a functional degradation — a prompt that is not offered. Same reasoning, one step weaker, as the write-refusal report beside it, and the same sink (`SuggestionDeps['logger']`, which declares no `error`). +- **Reported once per engine per package+arm.** The pass runs at boot, after every package-door `permission` publish and on every list call, while a manifest's shape is fixed for as long as that package is installed; an undeduplicated line would repeat on every console page load and be skimmed past. +- **The spec half is declaration text only — no key, export, arm or accept-set moved.** `ManifestSchema.permissions` now says it describes the AUTHORING stage and names the assembled-stage counterpart; the stack collection `permissions` names the manifest-stage grant; and `InstalledPackageSchema.manifest` says it is the authoring STAGE rather than "whatever was stored", pointing at `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` for the stage a `defineStack()` host installs. +- ⛔ **The union at the key was NOT widened, and must not be.** Widening a manifest key into a union of both stages is road C of #14242, rejected by name by the maintainer on 2026-09-02 in favour of road B — declare the assembled stage rather than widen the authoring one — because a union at the key makes neither stage checkable (Prime Directive #12). That ruling is why the fix here is a report and a cross-reference rather than a schema change. diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index 8297e5b552..8fe0531d73 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -72,7 +72,7 @@ Installed package row whose manifest is the assembled package body | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | -| **permissions** | `{ name: string; label?: string; description?: string; packageId?: string; … }[]` | optional | Permission Sets | +| **permissions** | `{ name: string; label?: string; description?: string; packageId?: string; … }[]` | optional | Permission Sets — the ADR-0090 collection half of `permissions`; at the manifest/authoring stage the same key is the ADR-0025 capability grant instead (`ManifestSchema.permissions`) | | **objects** | `{ name: string; label?: string; pluralLabel?: string; description?: string; … }[]` | optional | Business Objects definition (owned by this package) | | **datasources** | `{ name: string; label?: string; driver: string; config: Record; … }[]` | optional | External Data Connections | | **dependencies** | `Record` | optional | Package dependencies | @@ -177,7 +177,7 @@ Installed package with runtime lifecycle state | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Full package manifest | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | | **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | | **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | | **installedAt** | `string` | optional | Installation timestamp | @@ -228,7 +228,7 @@ Installed package with runtime lifecycle state | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Full package manifest | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | | **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | | **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | | **installedAt** | `string` | optional | Installation timestamp | @@ -253,7 +253,7 @@ Installed package with runtime lifecycle state | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | -| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions: legacy string[] or structured plugin block (ADR-0025 §3.2) | +| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | | **objects** | `string[]` | optional | Glob patterns for ObjectQL schemas files | | **datasources** | `string[]` | optional | Glob patterns for Datasource definitions | | **dependencies** | `Record` | optional | Package dependencies | @@ -316,7 +316,7 @@ Installed package row whose manifest is the assembled package body | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | -| **permissions** | `{ name: string; label?: string; description?: string; packageId?: string; … }[]` | optional | Permission Sets | +| **permissions** | `{ name: string; label?: string; description?: string; packageId?: string; … }[]` | optional | Permission Sets — the ADR-0090 collection half of `permissions`; at the manifest/authoring stage the same key is the ADR-0025 capability grant instead (`ManifestSchema.permissions`) | | **objects** | `{ name: string; label?: string; pluralLabel?: string; description?: string; … }[]` | optional | Business Objects definition (owned by this package) | | **datasources** | `{ name: string; label?: string; driver: string; config: Record; … }[]` | optional | External Data Connections | | **dependencies** | `Record` | optional | Package dependencies | @@ -481,7 +481,7 @@ Install package request | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | -| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions: legacy string[] or structured plugin block (ADR-0025 §3.2) | +| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | | **objects** | `string[]` | optional | Glob patterns for ObjectQL schemas files | | **datasources** | `string[]` | optional | Glob patterns for Datasource definitions | | **dependencies** | `Record` | optional | Package dependencies | @@ -605,7 +605,7 @@ Upgrade package request | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | -| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions: legacy string[] or structured plugin block (ADR-0025 §3.2) | +| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | | **objects** | `string[]` | optional | Glob patterns for ObjectQL schemas files | | **datasources** | `string[]` | optional | Glob patterns for Datasource definitions | | **dependencies** | `Record` | optional | Package dependencies | @@ -691,7 +691,7 @@ Resolve dependencies request | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | -| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions: legacy string[] or structured plugin block (ADR-0025 §3.2) | +| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | | **objects** | `string[]` | optional | Glob patterns for ObjectQL schemas files | | **datasources** | `string[]` | optional | Glob patterns for Datasource definitions | | **dependencies** | `Record` | optional | Package dependencies | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 4ea97503b7..b6fd069ff8 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -782,7 +782,7 @@ Disable package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Full package manifest | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | | **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | | **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | | **installedAt** | `string` | optional | Installation timestamp | @@ -826,7 +826,7 @@ Enable package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Full package manifest | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | | **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | | **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | | **installedAt** | `string` | optional | Installation timestamp | @@ -1494,7 +1494,7 @@ Get package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Full package manifest | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | | **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | | **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | | **installedAt** | `string` | optional | Installation timestamp | @@ -1891,7 +1891,7 @@ Install package request | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | -| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions: legacy string[] or structured plugin block (ADR-0025 §3.2) | +| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | | **objects** | `string[]` | optional | Glob patterns for ObjectQL schemas files | | **datasources** | `string[]` | optional | Glob patterns for Datasource definitions | | **dependencies** | `Record` | optional | Package dependencies | @@ -1928,7 +1928,7 @@ Install package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Full package manifest | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | | **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | | **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | | **installedAt** | `string` | optional | Installation timestamp | @@ -2130,7 +2130,7 @@ Installed package with runtime lifecycle state | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Full package manifest | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | | **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | | **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | | **installedAt** | `string` | optional | Installation timestamp | diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index 176fc243ee..5884e3215e 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -35,7 +35,7 @@ const result = ManifestSchema.parse(data); | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | -| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions: legacy string[] or structured plugin block (ADR-0025 §3.2) | +| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | | **objects** | `string[]` | optional | Glob patterns for ObjectQL schemas files | | **datasources** | `string[]` | optional | Glob patterns for Datasource definitions | | **dependencies** | `Record` | optional | Package dependencies | diff --git a/content/docs/references/kernel/package-registry.mdx b/content/docs/references/kernel/package-registry.mdx index fc4438d2e5..d364c6b318 100644 --- a/content/docs/references/kernel/package-registry.mdx +++ b/content/docs/references/kernel/package-registry.mdx @@ -71,7 +71,7 @@ Disable package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Full package manifest | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | | **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | | **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | | **installedAt** | `string` | optional | Installation timestamp | @@ -115,7 +115,7 @@ Enable package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Full package manifest | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | | **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | | **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | | **installedAt** | `string` | optional | Installation timestamp | @@ -158,7 +158,7 @@ Get package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Full package manifest | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | | **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | | **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | | **installedAt** | `string` | optional | Installation timestamp | @@ -199,7 +199,7 @@ Install package request | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | -| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions: legacy string[] or structured plugin block (ADR-0025 §3.2) | +| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | | **objects** | `string[]` | optional | Glob patterns for ObjectQL schemas files | | **datasources** | `string[]` | optional | Glob patterns for Datasource definitions | | **dependencies** | `Record` | optional | Package dependencies | @@ -236,7 +236,7 @@ Install package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Full package manifest | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | | **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | | **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | | **installedAt** | `string` | optional | Installation timestamp | @@ -270,7 +270,7 @@ Installed package with runtime lifecycle state | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Full package manifest | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | | **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | | **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | | **installedAt** | `string` | optional | Installation timestamp | @@ -295,7 +295,7 @@ Installed package with runtime lifecycle state | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | -| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions: legacy string[] or structured plugin block (ADR-0025 §3.2) | +| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | | **objects** | `string[]` | optional | Glob patterns for ObjectQL schemas files | | **datasources** | `string[]` | optional | Glob patterns for Datasource definitions | | **dependencies** | `Record` | optional | Package dependencies | @@ -358,7 +358,7 @@ Installed package with runtime lifecycle state | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Full package manifest | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | | **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | | **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | | **installedAt** | `string` | optional | Installation timestamp | diff --git a/content/docs/references/kernel/package-upgrade.mdx b/content/docs/references/kernel/package-upgrade.mdx index 02d886a583..84c5bd4143 100644 --- a/content/docs/references/kernel/package-upgrade.mdx +++ b/content/docs/references/kernel/package-upgrade.mdx @@ -148,7 +148,7 @@ Upgrade package request | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | -| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions: legacy string[] or structured plugin block (ADR-0025 §3.2) | +| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | | **objects** | `string[]` | optional | Glob patterns for ObjectQL schemas files | | **datasources** | `string[]` | optional | Glob patterns for Datasource definitions | | **dependencies** | `Record` | optional | Package dependencies | @@ -293,7 +293,7 @@ Pre-upgrade state snapshot for rollback capability | **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | | **name** | `string` | ✅ | Human-readable package name | | **description** | `string` | optional | Package description | -| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions: legacy string[] or structured plugin block (ADR-0025 §3.2) | +| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | | **objects** | `string[]` | optional | Glob patterns for ObjectQL schemas files | | **datasources** | `string[]` | optional | Glob patterns for Datasource definitions | | **dependencies** | `Record` | optional | Package dependencies | diff --git a/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts b/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts index 8308f1d6c7..e2b043c227 100644 --- a/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts +++ b/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts @@ -494,3 +494,124 @@ describe('[#12981] a refused suggestion write is distinguishable from a settled expect(warns).toEqual([]); }); }); + +// ──────────────────────────────────────────────────────────────────────── +// #18031 — `permissions` carries two incompatible readings in ONE registry +// slot: the ADR-0025 §3.2 capability GRANT at the authoring stage, and the +// ADR-0090 permission-SET collection at the assembled stage. Source 2 reads +// the assembled one. Handed the authoring one it used to return nothing and +// say nothing — the structured arm fell out of `Array.isArray`, and every +// member of the legacy `string[]` arm fell out of `consider`'s first line. +// AGENTS.md "Route & surface ownership" §3: absence must be loud. +// ──────────────────────────────────────────────────────────────────────── +describe('manifest `permissions` in the ADR-0025 reading is reported, never dropped silently (#18031)', () => { + const STRUCTURED_PACKAGE = { + enabled: true, + manifest: { + id: 'com.example.plugin', + // ADR-0025 §3.2 — an OBJECT, so `Array.isArray` is false. + permissions: { services: ['object', 'http'], hooks: ['record.beforeInsert'], network: [], fs: [] }, + }, + }; + const LEGACY_PACKAGE = { + enabled: true, + manifest: { + id: 'com.example.legacy', + // The legacy flat arm — bare strings, none of which has a `name`. + permissions: ['system.user.read', 'system.data.write'], + }, + }; + + function capture() { + const warns: Array<{ msg: string; meta?: any }> = []; + return { warns, logger: { warn: (msg: string, meta?: any) => { warns.push({ msg, meta }); } } }; + } + + it('names the STRUCTURED arm, what it means, what is lost and where the sets belong', async () => { + const ql = makeQl([STRUCTURED_PACKAGE]); + const { warns, logger } = capture(); + + const out = await syncAudienceBindingSuggestions(ql, undefined, logger); + + // The declaration really did produce nothing — the silence, not the + // outcome, is what this card repairs. + expect(out.created).toBe(0); + expect(ql.tables.sys_audience_binding_suggestion).toHaveLength(0); + + expect(warns).toHaveLength(1); + expect(warns[0].msg).toContain('ADR-0025'); + expect(warns[0].msg).toContain('CONSEQUENCE'); + expect(warns[0].msg).toContain('REMEDY'); + expect(warns[0].msg).toContain('defineStack({ permissions: [ … ] })'); + expect(warns[0].meta).toMatchObject({ + packages: [{ packageId: 'com.example.plugin', arm: 'adr-0025-structured', entries: 0 }], + }); + }); + + it('names the LEGACY string arm and counts the members it dropped', async () => { + const ql = makeQl([LEGACY_PACKAGE]); + const { warns, logger } = capture(); + + const out = await syncAudienceBindingSuggestions(ql, undefined, logger); + + expect(out.created).toBe(0); + expect(warns).toHaveLength(1); + expect(warns[0].meta).toMatchObject({ + packages: [{ packageId: 'com.example.legacy', arm: 'adr-0025-legacy-strings', entries: 2 }], + }); + }); + + it('reports a PARTIALLY readable array — the readable set still lands, the dropped strings are still named', async () => { + const ql = makeQl([{ + enabled: true, + manifest: { + id: 'com.example.mixed', + permissions: [ + 'system.user.read', + { name: 'mixed_readonly', isDefault: true, objects: { crm_account: { allowRead: true } } }, + ], + }, + }]); + const { warns, logger } = capture(); + + const out = await syncAudienceBindingSuggestions(ql, undefined, logger); + + expect(out.created).toBe(1); + expect(warns).toHaveLength(1); + expect(warns[0].meta).toMatchObject({ + packages: [{ packageId: 'com.example.mixed', arm: 'adr-0025-legacy-strings', entries: 1 }], + }); + }); + + it('says it ONCE per engine per package — a shape that cannot change does not repeat on every list call', async () => { + const ql = makeQl([STRUCTURED_PACKAGE]); + const { warns, logger } = capture(); + + await syncAudienceBindingSuggestions(ql, undefined, logger); + await syncAudienceBindingSuggestions(ql, undefined, logger); + await syncAudienceBindingSuggestions(ql, undefined, logger); + + expect(warns).toHaveLength(1); + + // A DIFFERENT engine is a different ledger — the dedupe is per kernel, not + // per process, so a second tenant kernel is still told. + const other = makeQl([STRUCTURED_PACKAGE]); + await syncAudienceBindingSuggestions(other, undefined, logger); + expect(warns).toHaveLength(2); + }); + + it('adds no noise on the readable shape — a package declaring permission SETS is never reported', async () => { + const ql = makeQl([CRM_PACKAGE]); + const { warns, logger } = capture(); + + const out = await syncAudienceBindingSuggestions(ql, undefined, logger); + + expect(out.created).toBe(1); + expect(warns).toEqual([]); + }); + + it('is silent with no logger — passing none is the caller’s choice, not a second channel', async () => { + const ql = makeQl([STRUCTURED_PACKAGE]); + await expect(syncAudienceBindingSuggestions(ql)).resolves.toMatchObject({ created: 0 }); + }); +}); diff --git a/packages/plugins/plugin-security/src/suggested-audience-bindings.ts b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts index 1732027666..2e6a770cd5 100644 --- a/packages/plugins/plugin-security/src/suggested-audience-bindings.ts +++ b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts @@ -229,6 +229,132 @@ function suggestionKey(packageId: string, setName: string, anchor: string): stri return `${packageId}\u0000${setName}\u0000${anchor}`; } +/** + * One installed package whose `manifest.permissions` this reader could not read + * as permission sets, and which arm of `ManifestPermissionsSchema` it turned + * out to be carrying. + * + * `entries` counts the ARRAY members that were dropped, and is `0` for the + * structured arm, which is one object dropped whole. + */ +interface DroppedManifestPermissions { + packageId: string; + arm: 'adr-0025-structured' | 'adr-0025-legacy-strings'; + entries: number; +} + +/** + * Which packages one engine has already been told about, so the report below + * is made ONCE per engine per package+arm rather than once per pass. + * + * The frequency is the whole reason this is not a bare `warn` at the drop + * site: `syncAudienceBindingSuggestions` runs at boot, after every + * package-door `permission` publish AND on every `listAudienceBindingSuggestions` + * call, while a manifest's shape is fixed for as long as that package is + * installed — so an undeduplicated line would repeat on every console page + * load and be skimmed past, which is the same silence with extra steps. + * + * Keyed on the ENGINE rather than held in a module-level `Set`, so a process + * hosting several tenant kernels reports once per kernel, and so a test gets a + * fresh ledger from its own stub with nothing to reset. + */ +const reportedManifestPermissionDrops = new WeakMap>(); + +/** + * Classify a manifest's `permissions` value against what THIS reader needs. + * + * `null` = readable as permission sets, or simply absent. A partially readable + * array is reported with only its unreadable members counted. + */ +function classifyManifestPermissions( + permissions: unknown, +): Omit | null { + if (permissions === undefined || permissions === null) return null; + if (!Array.isArray(permissions)) { + // The ADR-0025 §3.2 structured block — `{ services, hooks, network, fs }`. + // An object, so `Array.isArray` is false and the whole value is dropped. + return typeof permissions === 'object' ? { arm: 'adr-0025-structured', entries: 0 } : null; + } + // The legacy flat arm is `string[]`; a permission set is an object with a + // `name`. Count what `consider` refuses on SHAPE — never what it refuses on + // `isDefault` or on missing provenance, which are decisions, not drops. + const entries = permissions.filter((e) => !e || typeof e !== 'object' || !(e as { name?: unknown }).name).length; + return entries > 0 ? { arm: 'adr-0025-legacy-strings', entries } : null; +} + +/** + * Report — once per engine per package+arm — that a package's + * `manifest.permissions` carried the ADR-0025 reading and therefore + * contributed no audience-binding suggestion. + * + * ## Why this exists: one key, two incompatible readings, one registry slot + * + * `permissions` means different things at the two manifest stages, and the + * registry stores both under the same key: + * + * - AUTHORING stage — `ManifestSchema.permissions` is `ManifestPermissionsSchema` + * (`kernel/manifest.zod.ts`): the capability grant a plugin REQUESTS, either + * the legacy flat `string[]` or the structured `{ services, hooks, network, + * fs }` block (ADR-0025 §3.2); + * - ASSEMBLED stage — the collection wins and the key is `PermissionSet[]` + * (`AssembledPackageBodySchema` in `stack.zod.ts`, ADR-0130 D4), whose own + * table states that precedence key by key. + * + * This reader wants the assembled reading. Handed the authoring one it used to + * return an empty list and say nothing: the structured arm fell out of + * `Array.isArray`, and every member of the legacy arm fell out of `consider`'s + * first line. A package declaring the other reading produced no suggestion, no + * row and no log — AGENTS.md "Route & surface ownership" §3: absence must be + * loud. + * + * ## Why `warn` and not `error` + * + * Same reasoning as {@link reportSuggestionWriteRefusals}, one step weaker: + * nothing here claims to have persisted anything, so this is a FUNCTIONAL + * degradation — a prompt that is not offered — which AGENTS.md "Degradation + * log levels" puts at `warn`. The sink is the same `SuggestionDeps['logger']`, + * which declares no `error`. + */ +function reportDroppedManifestPermissions( + ql: unknown, + logger: SuggestionDeps['logger'] | undefined, + dropped: DroppedManifestPermissions[], +): void { + if (dropped.length === 0 || !logger?.warn) return; + let ledger: Set | undefined; + if (typeof ql === 'object' && ql !== null) { + ledger = reportedManifestPermissionDrops.get(ql); + if (ledger === undefined) { + ledger = new Set(); + reportedManifestPermissionDrops.set(ql, ledger); + } + } + const fresh = ledger ? dropped.filter((d) => !ledger.has(`${d.packageId}|${d.arm}`)) : dropped; + if (fresh.length === 0) return; + for (const d of fresh) ledger?.add(`${d.packageId}|${d.arm}`); + logger.warn( + `[security] ${fresh.length} installed package(s) declare \`manifest.permissions\` in the ADR-0025 ` + + `plugin-GRANT reading, not the ADR-0090 permission-SET collection this reconciler reads — those ` + + `declarations contribute NO audience-binding suggestion, and until now they said nothing at all. ` + + `One key carries two incompatible readings and the registry stores both in the same slot: at the ` + + `AUTHORING stage \`ManifestSchema.permissions\` is the capability grant a plugin requests (the legacy ` + + `flat \`string[]\`, or the structured \`{ services, hooks, network, fs }\` block, ADR-0025 §3.2); at ` + + `the ASSEMBLED stage the collection wins and the key is \`PermissionSet[]\` ` + + `(\`AssembledPackageBodySchema\`, ADR-0130 D4). This pass reads the ASSEMBLED one, so the authoring ` + + `one is skipped whole — the structured arm is an object and never enters the loop, and every member ` + + `of the legacy arm is a bare string with no \`name\`. CONSEQUENCE: if any of these packages meant to ` + + `ship a permission set with \`isDefault: true\`, no \`sys_audience_binding_suggestion\` row exists ` + + `for it and no admin is ever prompted to bind it — the console shows one fewer suggestion and the ` + + `deployment goes on looking healthy. If they meant the ADR-0025 grant, nothing is lost and this line ` + + `is informational; it prints either way because this reader cannot tell the two intents apart. ` + + `REMEDY: declare permission sets in the package's OWN stack collection — ` + + `\`defineStack({ permissions: [ … ] })\` — which is what the assembled body carries and what this ` + + `pass reads, and leave the manifest-stage \`permissions\` to ADR-0025. Reported once per engine per ` + + `package and arm, so a shape that does not change does not repeat on every list call.`, + { packages: fresh }, + ); +} + /** * Collect every currently-declared audience-binding suggestion from BOTH * declaration sources: @@ -243,8 +369,18 @@ function suggestionKey(packageId: string, setName: string, anchor: string): stri * Today the only declarable suggestion is `isDefault: true` → `everyone` * (ADR-0090 D5); the shape is anchor-keyed so the D9 `guest` generalization * slots in without a schema change. + * + * `logger` is optional and carries ONE report: source 2 reads its key at the + * assembled stage while the same key has an authoring-stage meaning, and a + * manifest carrying the authoring one is skipped — see + * {@link reportDroppedManifestPermissions}. Passing no logger is silent, which + * is why every in-repo caller passes one. */ -export function collectDeclaredSuggestions(ql: any, metadata?: any): DeclaredSuggestion[] { +export function collectDeclaredSuggestions( + ql: any, + metadata?: any, + logger?: SuggestionDeps['logger'], +): DeclaredSuggestion[] { const out = new Map(); const consider = (ps: any, packageId: string | undefined) => { @@ -268,6 +404,7 @@ export function collectDeclaredSuggestions(ql: any, metadata?: any): DeclaredSug } catch { /* metadata facade optional */ } // Source 2 — installed package manifests (live at install time). + const dropped: DroppedManifestPermissions[] = []; try { const packages: any[] = ql?.registry?.getAllPackages?.() ?? []; for (const pkg of packages) { @@ -275,8 +412,14 @@ export function collectDeclaredSuggestions(ql: any, metadata?: any): DeclaredSug const manifest = pkg?.manifest; const declared = Array.isArray(manifest?.permissions) ? manifest.permissions : []; for (const ps of declared) consider(ps, manifest?.id); + // The same value, asked the OTHER question: was any of it unreadable + // here? An unowned package still counts — its declaration is dropped by + // this reader either way, and naming it is how an operator finds it. + const drop = classifyManifestPermissions(manifest?.permissions); + if (drop) dropped.push({ packageId: manifest?.id ?? manifest?.name ?? '(unidentified package)', ...drop }); } } catch { /* registry shape optional (test stubs) */ } + reportDroppedManifestPermissions(ql, logger, dropped); return [...out.values()]; } @@ -424,7 +567,7 @@ export async function syncAudienceBindingSuggestions( // steady-state boot — and the summary line is gated on that same zero. const refusals = createSeedWriteRefusals(); - const declared = collectDeclaredSuggestions(ql, metadata); + const declared = collectDeclaredSuggestions(ql, metadata, logger); const declaredKeys = new Set(declared.map((d) => suggestionKey(d.packageId, d.set.name, d.anchor))); const anchors = await findAnchorPositions(ql, organizationId); @@ -787,7 +930,7 @@ export async function confirmAudienceBindingSuggestion( // or env-owned name is refused, never clobbered. let setRow = (await tryFind(ql, 'sys_permission_set', { name: row.permission_set_name }, 1, organizationId))[0] ?? null; if (!setRow) { - const declared = collectDeclaredSuggestions(ql, deps.metadata).find( + const declared = collectDeclaredSuggestions(ql, deps.metadata, deps.logger).find( (d) => d.packageId === row.package_id && d.set.name === row.permission_set_name && d.anchor === row.anchor, ); if (declared) { diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index 37ad1d4efb..6864833732 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -386,11 +386,33 @@ export const ManifestSchema = strictObject({ * structured plugin permission block ({@link PluginPermissionsSchema}, * ADR-0025 §3.2) that maps to service / hook / network / fs capabilities. * + * ⚠️ **This key carries a DIFFERENT declaration one stage along, and the two + * are not compatible.** At this AUTHORING stage `permissions` is the + * capability grant above. At the ASSEMBLED stage the collection wins and the + * same key is `PermissionSet[]` — the ADR-0090 app-category sets, with + * `name` / `isDefault` — declared by `AssembledPackageBodySchema` in + * `../stack.zod.ts`, whose own stage table names this key beside `objects` + * and `datasources` as one of the three that change meaning. A package + * writes the set collection in its own stack (`defineStack({ permissions: + * [ … ] })`); a manifest-stage `permissions` has no expression in an + * assembled body, because the flatten order overrides it. + * + * ⇒ A consumer reading `permissions` off an installed-package RECORD is + * reading whichever stage that row was installed at, so it must say which + * one it wants and report the other rather than dropping it. The one such + * reader today is `collectDeclaredSuggestions` in + * `@objectstack/plugin-security` (`suggested-audience-bindings.ts`), which + * wants the assembled reading and now names the authoring one when it meets + * it. ⛔ The fix for that collision is never to widen this union with the + * set shape: a union at the key would make neither stage checkable, which is + * the road `AssembledPackageBodySchema` records as REJECTED by name + * (Prime Directive #12). + * * @example ["system.user.read", "system.data.write"] * @example { "services": ["object", "http"], "hooks": ["record.beforeInsert"] } */ permissions: ManifestPermissionsSchema.optional() - .describe('Required permissions: legacy string[] or structured plugin block (ADR-0025 §3.2)'), + .describe('Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`)'), /** * Glob patterns specifying ObjectQL schemas files. diff --git a/packages/spec/src/kernel/package-registry.zod.ts b/packages/spec/src/kernel/package-registry.zod.ts index e6965c41ca..bd0a0c948d 100644 --- a/packages/spec/src/kernel/package-registry.zod.ts +++ b/packages/spec/src/kernel/package-registry.zod.ts @@ -53,10 +53,37 @@ export type PackageStatus = z.input; * This is the "row" in the installed packages table. */ export const InstalledPackageSchema = lazySchema(() => z.object({ - /** - * The full package manifest (source of truth for package definition). + /** + * The package manifest at the AUTHORING stage — this row's manifest is + * `ManifestSchema`, and that is a STAGE, not "whatever was stored". + * + * ⚠️ **Do not read this declaration as the shape of every registry row.** + * `SchemaRegistry.installPackage` records whatever its caller handed it, and + * two callers hand it two different stages: + * + * - `POST /api/v1/packages` declares `manifest: ManifestSchema` and passes + * the AUTHORING manifest — the stage this row names, whose `objects` and + * `datasources` are glob patterns and whose `permissions` is the ADR-0025 + * capability grant; + * - a `defineStack()` host reaches the same table through + * `ObjectQL.registerApp`, which installs the ASSEMBLED body — `objects` + * and `datasources` are DEFINITIONS and `permissions` is the ADR-0090 + * `PermissionSet[]` collection. + * + * The assembled stage has its own declaration rather than a widening of this + * one: `AssembledInstalledPackageSchema` (`../api/package-api.zod.ts`) over + * `AssembledPackageBodySchema` (`../stack.zod.ts`), the maintainer's road B + * of 2026-09-02 — «declare the assembled stage rather than widen the + * authoring one». The read doors (`GET /packages`, `GET /packages/:id`) + * therefore serve `InstalledPackageAtEitherStageSchema`, a union of two whole + * closed stages, and a consumer parsing a registry row against THIS schema + * alone will refuse every row a `defineStack()` host installed. + * + * ⛔ Never relax this branch — or `ManifestSchema` — to make an assembled row + * fit. Widening a key into a union of both spellings was road C and was + * rejected by name: it makes neither stage checkable (Prime Directive #12). */ - manifest: ManifestSchema.describe('Full package manifest'), + manifest: ManifestSchema.describe('Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema`'), /** * Current lifecycle status. diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 54f52c7c6b..be30488ca2 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -431,7 +431,25 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = { * ObjectGuard: Security Layer */ positions: z.array(PositionSchema).optional().describe('Positions — flat capability-distribution groups (ADR-0090 D3)'), - permissions: z.array(PermissionSetSchema).optional().describe('Permission Sets'), + /** + * Permission Sets — the ADR-0090 app-category collection. + * + * ⚠️ **`permissions` is one key with two incompatible readings**, and this is + * the collection half. The other half is + * `ManifestSchema.permissions` (`kernel/manifest.zod.ts`): at the AUTHORING + * stage the same key is the ADR-0025 §3.2 capability GRANT a plugin requests + * (`string[]`, or `{ services, hooks, network, fs }`). When a stack is + * assembled, the flatten order puts this collection on top — the stage table + * at {@link AssembledPackageBodySchema} states that precedence — so a + * manifest-stage `permissions` has no expression in an assembled body, and + * writing permission sets on the manifest instead of here reaches no reader. + * + * Both readings land in the SAME registry slot: `SchemaRegistry.installPackage` + * records `manifest.permissions` whichever stage produced it, so a consumer + * reading it off an installed-package row must name the stage it wants and + * report the other rather than dropping it silently. + */ + permissions: z.array(PermissionSetSchema).optional().describe('Permission Sets — the ADR-0090 collection half of `permissions`; at the manifest/authoring stage the same key is the ADR-0025 capability grant instead (`ManifestSchema.permissions`)'), /** * [ADR-0066 D1] Authorization capabilities this package DEFINES. *