diff --git a/.changeset/13458-manifest-permissions-string-list-retired.md b/.changeset/13458-manifest-permissions-string-list-retired.md new file mode 100644 index 0000000000..ba966cbf27 --- /dev/null +++ b/.changeset/13458-manifest-permissions-string-list-retired.md @@ -0,0 +1,37 @@ +--- +'@objectstack/spec': minor +'@objectstack/runtime': patch +'@objectstack/plugin-security': patch +--- + +feat(spec)!: a package manifest's `permissions` no longer takes a flat list of permission strings — the structured `{ services, hooks, network, fs }` block is the only form (#13458) + +Clause-②: no (narrowing) + + + +**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not yet in on `main`). + +`ManifestPermissionsSchema` was a union of a flat `string[]` and the structured ADR-0025 §3.2 block. Nothing ever acted on the list: the loader registers the consented `grantedPermissions` set from the environment artifact with the permission enforcer, never the manifest's request, and the only code that met a list on a manifest was two reports saying it had been skipped. The marketplace install disclosure reads only the four lists, so a list was shown to an installer as "Requests no special permissions." (ADR-0049 enforce-or-remove). `ManifestPermissionsSchema` is now the structured block itself. + +### FROM → TO + +| before | what to write instead | +| --- | --- | +| `permissions: ['system.user.read', 'system.data.write']` | `permissions: { services: [...], hooks: [...], network: [...], fs: [...] }`, naming the platform services the plugin resolves, the lifecycle hooks it registers, the network hosts it reaches and the filesystem paths it touches. | +| `permissions: []` | delete `permissions`: absence is the spelling for "requests nothing". | +| a list on an app manifest that ships no code | delete `permissions`. An app's record access is its permission SETS, in the stack's own top-level `permissions` collection. | + +**The one-line fix: replace every manifest `permissions` list with the structured block, or delete it.** A permission string has no mechanical mapping onto the four lists, so the translation is done by hand. `os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + +**What an author now sees.** Writing a list fails `tsc` (the key's type is the block), and `os validate`, `os build`, `os plugin build` and `defineStack` refuse it at `manifest.permissions` with the block's own answer: `Expected the plugin permission block { services?, hooks?, network?, fs? }, received a flat list.`, followed by the prescription. Every structured block that parsed before still parses, unchanged. + +### The retirement kit + +- **Schema.** `ManifestPermissionsSchema` is `PluginPermissionsSchema`, by identity, and both export names stay. The block answers a list with its prescription on its own error map, the bare-array pattern `ListViewExportOptionsSchema` uses. The block also carries `EnvironmentArtifactSchema.grantedPermissions` values, so the answer is worded true there too, where a list was never legal. +- **D2 conversion `manifest-permissions-string-list-removed`** (step 18, retired from the load path): a lossless delete of an all-string list from the stack's `manifest` and every `packages[].manifest`. The notice carries the dropped strings. A built artifact replays it at the artifact door, so an artifact built while the list was legal still boots. It never touches the structured block, an array of objects, or the top-level ADR-0090 permission-set collection. +- **D3 entry `manifest-permissions-string-list-retired`** carries the judgement the delete cannot make: what each dropped string meant in services, hooks, hosts and paths. +- **Liveness.** `manifest.permissions` stays `live` on corrected evidence. Its consumer is the marketplace install disclosure, and it refuses nothing at load. The four keys are now drilled. +- **The two skip reports reworded.** `AppPlugin`'s security registrar and `@objectstack/plugin-security`'s audience-binding reconciler both report a manifest-stage `permissions` they cannot read as permission sets. They now name the flat list as the retired legacy form. They behave as before. + +**Measured producers: none outside tests.** On origin/main e67ba80049, no manifest in `examples/`, `apps/`, `packages/`, `skills/` or `content/docs/` writes a list. The exceptions are five `@objectstack/spec` `manifest.test.ts` fixtures, re-triaged here, and the skip-report tests of `@objectstack/runtime` and `@objectstack/plugin-security`, which hand a list to an unparsed bundle on purpose and still pass. The same instrument finds those five fixtures, which is its control. At the objectui pin `a58626c88dc8`, nothing reads `manifest.permissions` (control: 91 `manifest.(id|name|version)` reads), and the install disclosure reads the structured block alone. Deployed and cloud-held manifests NOT MEASURED. diff --git a/content/docs/protocol/kernel/plugin-spec.mdx b/content/docs/protocol/kernel/plugin-spec.mdx index 14f6889d7f..8d4f735af0 100644 --- a/content/docs/protocol/kernel/plugin-spec.mdx +++ b/content/docs/protocol/kernel/plugin-spec.mdx @@ -631,9 +631,11 @@ consent request; what the runtime enforces is the *granted* set. ### Declared Permissions -`ManifestSchema.permissions` accepts either the legacy flat `string[]` or the structured -`PluginPermissionsSchema` block — four keys, and no `system` list -(`packages/spec/src/kernel/manifest.zod.ts`): +`ManifestSchema.permissions` takes the structured `PluginPermissionsSchema` block — four +keys, and no `system` list (`packages/spec/src/kernel/manifest.zod.ts`). Its legacy flat +`string[]` form was retired in `@objectstack/spec` 17: nothing ever read the list, a list +is refused at parse with the migration prescription, and `os migrate meta --from 17` lists +where to delete one — translating each string into the four lists is done by hand. ```typescript permissions: { diff --git a/content/docs/references/api/package-api-assembled.mdx b/content/docs/references/api/package-api-assembled.mdx index 135ca19028..54454ca1be 100644 --- a/content/docs/references/api/package-api-assembled.mdx +++ b/content/docs/references/api/package-api-assembled.mdx @@ -272,7 +272,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 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`) | +| **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and 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/package-api.mdx b/content/docs/references/api/package-api.mdx index 2d29ba891b..e532941486 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -148,7 +148,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 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`) | +| **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and 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 | @@ -192,7 +192,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 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`) | +| **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and 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 | @@ -212,8 +212,6 @@ Install package request ### Nested Shape: `PackageInstallBody[option 2].permissions` -Structured plugin permission grants (ADR-0025 §3.2) - | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **services** | `string[]` | optional | Platform services the plugin may resolve (e.g. "object", "http") | @@ -311,7 +309,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 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`) | +| **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and 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 | @@ -444,7 +442,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 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`) | +| **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and 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 | @@ -539,7 +537,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 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`) | +| **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and 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 935f0ca8af..b99a33373f 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1934,7 +1934,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 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`) | +| **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and 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/manifest.mdx b/content/docs/references/kernel/manifest.mdx index da36f94a41..884269800a 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -36,7 +36,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 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`) | +| **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and 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 | @@ -56,8 +56,6 @@ const result = ManifestSchema.parse(data); ### Nested Shape: `Manifest.permissions` -Structured plugin permission grants (ADR-0025 §3.2) - | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **services** | `string[]` | optional | Platform services the plugin may resolve (e.g. "object", "http") | @@ -128,18 +126,6 @@ A navigation contribution: a package injecting nav items into an app it does not ## ManifestPermissions -### Union Options - -This schema accepts one of the following structures: - -#### Option 1 - -Type: `string[]` - ---- - -#### Option 2 - Structured plugin permission grants (ADR-0025 §3.2) ### Properties @@ -151,8 +137,6 @@ Structured plugin permission grants (ADR-0025 §3.2) | **network** | `string[]` | optional | Network hosts the plugin may reach (e.g. "api.acme.com") | | **fs** | `string[]` | optional | Filesystem paths the plugin may access | ---- - --- diff --git a/content/docs/references/kernel/package-registry.mdx b/content/docs/references/kernel/package-registry.mdx index e68d26166b..7a2344cffe 100644 --- a/content/docs/references/kernel/package-registry.mdx +++ b/content/docs/references/kernel/package-registry.mdx @@ -200,7 +200,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 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`) | +| **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and 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 | @@ -296,7 +296,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 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`) | +| **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and 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-upgrade.mdx b/content/docs/references/kernel/package-upgrade.mdx index 8ff3ad5963..13f90e44a8 100644 --- a/content/docs/references/kernel/package-upgrade.mdx +++ b/content/docs/references/kernel/package-upgrade.mdx @@ -149,7 +149,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 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`) | +| **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and 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 | @@ -294,7 +294,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 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`) | +| **permissions** | `{ services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: the structured plugin block `{ services, hooks, network, fs }` (ADR-0025 §3.2) — the legacy flat string list is retired — and 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.ts b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts index 30c2ad7ce7..fdeb5b1861 100644 --- a/packages/plugins/plugin-security/src/suggested-audience-bindings.ts +++ b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts @@ -232,8 +232,11 @@ function suggestionKey(packageId: string, setName: string, anchor: string): stri /** * 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. + * as permission sets, and which ADR-0025 form it turned out to be carrying: + * the structured block `ManifestPermissionsSchema` declares, or the flat + * string list that schema RETIRED (`adr-0025-legacy-strings`) — refused at + * parse since, but still able to sit on a registry row installed from a + * bundle that never passed that parse. * * `entries` counts the ARRAY members that were dropped, and is `0` for the * structured arm, which is one object dropped whole. @@ -276,8 +279,8 @@ function classifyManifestPermissions( // 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 + // The retired legacy flat list 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; @@ -294,17 +297,18 @@ function classifyManifestPermissions( * 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); + * (`kernel/manifest.zod.ts`): the capability grant a plugin REQUESTS, the + * structured `{ services, hooks, network, fs }` block (ADR-0025 §3.2). Its + * legacy flat `string[]` form is retired and refused at parse, so a list + * here is a row whose bundle never passed that parse; * - 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 + * `Array.isArray`, and every member of a (now retired) flat list 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. * @@ -338,12 +342,13 @@ function reportDroppedManifestPermissions( `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 ` + + `AUTHORING stage \`ManifestSchema.permissions\` is the capability grant a plugin requests (the ` + + `structured \`{ services, hooks, network, fs }\` block, ADR-0025 §3.2 — its legacy flat \`string[]\` ` + + `form is retired and refused at parse, so a list here is a row that never passed that parse); 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 ` + + `one is skipped whole — the structured block is an object and never enters the loop, and every ` + + `member of a retired flat list 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 ` + diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index cc4e168533..ad3227f5a4 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -1134,8 +1134,9 @@ export class AppPlugin implements Plugin { + `. ${field === 'permissions' ? 'Nothing is lost if an ADR-0025 §3.2 capability GRANT was meant — ' + '`manifest.permissions` is the manifest-stage grant a package requests ' - + '(a flat list of permission strings, or `{ services, hooks, network, fs }`), ' - + 'and this registrar only reads ADR-0090 permission sets. But if permission ' + + '(`{ services, hooks, network, fs }`; a flat list of permission strings is ' + + 'its retired legacy form, refused at parse and never read at load), and ' + + 'this registrar only reads ADR-0090 permission sets. But if permission ' + 'sets were meant, none is registered, no audience-binding suggestion is ' + 'offered, and the boot goes on looking healthy' : `\`${field}\` is not a key \`ManifestSchema\` declares, so this value reached ` diff --git a/packages/spec/authorable-surface/kernel.json b/packages/spec/authorable-surface/kernel.json index b2e931f40e..33f19c3927 100644 --- a/packages/spec/authorable-surface/kernel.json +++ b/packages/spec/authorable-surface/kernel.json @@ -281,6 +281,10 @@ "kernel/Manifest:scope", "kernel/Manifest:type", "kernel/Manifest:version", + "kernel/ManifestPermissions:fs", + "kernel/ManifestPermissions:hooks", + "kernel/ManifestPermissions:network", + "kernel/ManifestPermissions:services", "kernel/MetadataBulkResult:errors", "kernel/MetadataBulkResult:failed", "kernel/MetadataBulkResult:succeeded", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index b55ed9b69e..12f762b906 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -986,7 +986,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | validation | seeded 2026-08-01 (#4488). The ADR-0020 carrier: the evaluator honors active/events/priority/severity/type/condition/message (the zod header's "only reads type/condition/…" prose is STALE — trust the ledger). Dead 3 = label/description/tags, declared governance metadata, kept unmarked. Union walk boundary recorded: only base + `script` keys walked; per-variant keys are governed by the evaluator's tests, not ledger rows. **No longer a registered metadata kind** — #4509 retired it under ADR-0088 (a standalone rule had no object-binding key and every variant is `.strict()`, so it bound to nothing and gated no write; a state machine authored that way saved cleanly and did nothing). The rule VOCABULARY is untouched and fully live via `object.validations[]`, so the ledger keeps governing it through the gate's spec-only override, alongside `webhook` and `query`. The contrast with the two bridges in the same batch is the point: enforce-or-remove picked ENFORCE where the feature existed and only the wiring was missing, and REMOVE where the shape itself could not carry the feature | | api | seeded 2026-08-04 (#5271, part of #5206; PR #5312) — **not a metadata type until that same change made it one**, which is the row's point: governance and registration landed together, the treatment `datasource` did not get (#4487) and paid for with six inert keys found by hand. What #5206 measured before the fix: `api` was in neither `DEFAULT_METADATA_TYPE_REGISTRY` nor `BUILTIN_METADATA_TYPE_SCHEMAS`, so `saveMetaItem`'s `resolveOverlaySchema('api', …)` → `getMetadataTypeSchema('api')` returned `undefined` and took its own documented branch — an unregistered type is stored **unvalidated** — while `getMetaTypes()` could not enumerate the type at all, so Studio rendered neither list nor form. That issue names the shape precisely and it is the inverse of this ledger's usual one: **enforced but undeclared** (the matcher was already indexing these entries, #5089), where `dead` is declared-but-unenforced. The seeding pass classified 27 keys — live 25 / planned 2 / dead 0 — each cited `file:line` at the consumer layer that reads it: the MATCHER (`packages/metadata/src/endpoint-matcher.ts`) indexes `name`/`path`/`method`; the EXECUTOR (`packages/runtime/src/endpoint-executor.ts`) dispatches on `type` and reads `target`/`objectParams`; the POLICY chain (`packages/runtime/src/endpoint-policy.ts` + `security/inbound-rate-limit.ts`) enforces `authRequired`/`rateLimit`/`cacheTtl`; the MAPPING layer (`packages/runtime/src/api-mapping.ts`) applies `inputMapping`/`outputMapping`; and OpenAPI enrichment (`packages/rest/src/openapi-endpoints.ts`) emits `summary`/`description`. Timing was the reason it was cheap: #5040's E-series had built every one of those consumers and all of it was on main, so each key had a real evidence path rather than a promise. **Planned 2 = `inputMapping.transform` + `outputMapping.transform`, and `planned` rather than `dead` is load-bearing**: `dead` here means parsed with no consumer — a silent no-op — and these are the opposite, parsed and then LOUDLY REFUSED at publish (`endpoint-publish-gate.ts` mappingGate) and again at runtime, because no transformation-function registry exists anywhere in the platform. An author who writes one is told so and told what to do instead, so there is nothing for enforce-or-remove to chase; they stay in the vocabulary because admitting them needs a function registry **and** a sandbox ruling (#5040 §3.4), which is a design decision, not a key to quietly delete. Zero dead | | capability | seeded 2026-08-08 (#5961; commit ae31a1912) — `CapabilityDeclarationSchema`, the DECLARATION side of ADR-0066 D1's three-way separation: packages DEFINE a capability, permission sets GRANT it via `systemPermissions`, resources REQUIRE it via `requiredPermissions`. **The gate's 12 and the seeding PR's 5 are the same measurement at two granularities** — commit ae31a1912 call-graph-closed **5 authorable properties**, every one to a real reader in `packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts` (the one consumer that turns a declaration into a `sys_capability` row), all `live`, with no `PENDING_GOVERNANCE` debt recorded; the other 7 are the ADR-0010 protection-envelope keys the gate auto-classifies `live` and which carry `null` verdicts in the file, exactly as on `permission`/`position`. The same worked example as `api` above and commit ae31a1912 says so in those words — **enforced but undeclared**, the mirror of the hole #5271 closed. What #5961 measured: absent from `DEFAULT_METADATA_TYPE_REGISTRY`, `BUILTIN_METADATA_TYPE_SCHEMAS` and `HAND_CRAFTED_SCHEMAS`, so `isRuntimeCreateAllowed()` took its no-static-entry fallback (permanently true) and `saveMetaItem` its no-schema branch — `PUT /api/v1/meta/capability/:name` accepted **arbitrary JSON** onto an authorization surface whose names `systemPermissions`/`requiredPermissions` resolve by string, while `/meta/types` synthesised a false `allowRuntimeCreate: true` descriptor Studio drew a raw-JSON create form from. #5870 did not open that path (the write gate reads the registry, not the item store); it only made the type visible in `getMetaTypes()`, and both the issue and this row say so to stop the next reader filing it as a regression. Landed as ruling A on ADR-0066 D1's own authority: `allowRuntimeCreate: false` **and** `allowOrgOverride: false`, the second self-judged inside the ruling's rationale and flagged for veto — a tenant overlay of a package declaration would lift `scope` from `org` to `platform`, which is the one field on this type that is an escalation rather than display. Its reverse verification is worth copying: deleting the registry entry gave 7 red / 3 green and measured something **sharper than predicted** — a garbage payload turned 422 rather than resolving, i.e. the schema binding is a real second line of defence behind the registry row, not a restatement of it; deleting the schema binding alone gave exactly 3 red. `packageId` is the one key that reads oddly: deliberately a FALLBACK, not the primary, since #5870 added `capabilities` to the ObjectQL stamped-collection list so `_packageId` now reaches a declaration and wins — it stays `live` because the fallback branch still decides materialization for any declaration arriving unstamped. Zero dead | -| manifest | seeded 2026-08-23 (commit 3fc606687) — **not a metadata type and not a stack collection either**, which is the row's whole point. `ManifestSchema` (`packages/spec/src/kernel/manifest.zod.ts:132`) is what an author writes as `objectstack.config.ts` or a packaged manifest; it is parsed at `packages/objectql/src/registry.ts:2950` and by `os plugin build`, and it sat outside the ratchet's universe entirely — `GOVERNED` listed no `plugin`/`manifest`/`package`, `SPEC_ONLY_SCHEMAS` covered only webhook/query/validation/qa, `PENDING_GOVERNANCE` was empty so the gate reported itself **complete**, and `liveness/` had no file for it. A ratchet extended only to unregistered KINDS would not have reached it either: the retired-key entry `17.kernel__Manifest__loading.ts` records that `PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry, so a manifest is never walked as a stack collection member. That blind spot was paid for twice, by hand and after the fact — `loading`'s ten inert keys (#4914, one of them `sandboxing`, which isolated nothing while looking like isolation) and the `contributes` census (recorded in commit be21955ba), which found exactly ONE reader of the 11-member block monorepo-wide. Dead 20 = the ten dead `contributes` members (`kinds` is the sole live one — `engine.ts:4504` → `registerKind`), the five `capabilities.*` and two `configuration.*` keys (all three containers have ZERO reads of the container itself, so no key beneath one can be read), plus `extensions`, `integrity`, and the tombstoned `loading` whose row must stay because `retiredKey()` keeps the key in the walked shape. **`integrity` is security-shaped and not retired here**: it declares per-file digests the future runtime loader is designed to re-verify at unpack (ADR-0025 §3.5 steps 4–7) while nothing re-verifies them today (since #13464 the publisher computes the map at build and self-checks it at the `os plugin publish` preflight, but nothing re-checks it after publish; the leg is the loader's, NOT the cloud control plane's). `runtime` — the ADR-0025 §3.6 trust tier, the other security-shaped key, read locally only by two CLI lines that ECHO the value with no `runtime === 'sandbox'` branch anywhere, while `loading`'s own tombstone used to redirect upgrading authors to it as something "which [is] enforced" — is the family's SPLIT verdict and the ledger's first `live-elsewhere` row (#13483): #12400 measured the cloud leg (cloud @15f55df, 2026-08-29) and found a real consumer — the marketplace publish gate hard-rejects (HTTP 422) an unverified publisher requesting the `node` tier — so the maintainer ruling of 2026-08-30 (executed by commit a9ee98992) took "say it truthfully" over retirement, and retirement is ruled OUT because deleting the key would tear out that gate's input. The tombstone, both `runtime` describes and the D3 entry state the split (publish-gate enforced; load-side NOT implemented); the row's verdict now says it as data rather than as a qualifying note, under the elsewhere criteria (foreign pointer + cross-repo scope + attestation with a 180d expiry), and load-side enforcement is a v18 direction rather than a removal. The `contributes` dispositions have since MOVED: the cloud leg was measured CLEAN 2026-08-24 (recorded in commit be21955ba; cloud `origin/main` @ 5b5925a, zero `manifest.contributes` reads, controls held), and commit be21955ba then executed the retirement — the nine mechanically-dead members are `retiredKey()` tombstones (D3 `plugin-manifest-contributes-dead-members-retired`), their rows staying because a tombstone keeps the key in the walked shape. Commit bc56e1881 then executed the second retirement too (ruled B 2026-08-22; D3 `plugin-manifest-contributes-routes-retired`), tombstoning `routes` and leaving `kinds` the block's sole live member. The remaining non-`contributes` `dead` rows keep their three-repo census verdicts as an enforce-or-remove worklist, not a licence to delete | +| manifest | seeded 2026-08-23 (commit 3fc606687) — **not a metadata type and not a stack collection either**, which is the row's whole point. `ManifestSchema` (`packages/spec/src/kernel/manifest.zod.ts:132`) is what an author writes as `objectstack.config.ts` or a packaged manifest; it is parsed at `packages/objectql/src/registry.ts:2950` and by `os plugin build`, and it sat outside the ratchet's universe entirely — `GOVERNED` listed no `plugin`/`manifest`/`package`, `SPEC_ONLY_SCHEMAS` covered only webhook/query/validation/qa, `PENDING_GOVERNANCE` was empty so the gate reported itself **complete**, and `liveness/` had no file for it. A ratchet extended only to unregistered KINDS would not have reached it either: the retired-key entry `17.kernel__Manifest__loading.ts` records that `PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry, so a manifest is never walked as a stack collection member. That blind spot was paid for twice, by hand and after the fact — `loading`'s ten inert keys (#4914, one of them `sandboxing`, which isolated nothing while looking like isolation) and the `contributes` census (recorded in commit be21955ba), which found exactly ONE reader of the 11-member block monorepo-wide. Dead 20 = the ten dead `contributes` members (`kinds` is the sole live one — `engine.ts:4504` → `registerKind`), the five `capabilities.*` and two `configuration.*` keys (all three containers have ZERO reads of the container itself, so no key beneath one can be read), plus `extensions`, `integrity`, and the tombstoned `loading` whose row must stay because `retiredKey()` keeps the key in the walked shape. **`integrity` is security-shaped and not retired here**: it declares per-file digests the future runtime loader is designed to re-verify at unpack (ADR-0025 §3.5 steps 4–7) while nothing re-verifies them today (since #13464 the publisher computes the map at build and self-checks it at the `os plugin publish` preflight, but nothing re-checks it after publish; the leg is the loader's, NOT the cloud control plane's). `runtime` — the ADR-0025 §3.6 trust tier, the other security-shaped key, read locally only by two CLI lines that ECHO the value with no `runtime === 'sandbox'` branch anywhere, while `loading`'s own tombstone used to redirect upgrading authors to it as something "which [is] enforced" — is the family's SPLIT verdict and the ledger's first `live-elsewhere` row (#13483): #12400 measured the cloud leg (cloud @15f55df, 2026-08-29) and found a real consumer — the marketplace publish gate hard-rejects (HTTP 422) an unverified publisher requesting the `node` tier — so the maintainer ruling of 2026-08-30 (executed by commit a9ee98992) took "say it truthfully" over retirement, and retirement is ruled OUT because deleting the key would tear out that gate's input. The tombstone, both `runtime` describes and the D3 entry state the split (publish-gate enforced; load-side NOT implemented); the row's verdict now says it as data rather than as a qualifying note, under the elsewhere criteria (foreign pointer + cross-repo scope + attestation with a 180d expiry), and load-side enforcement is a v18 direction rather than a removal. The `contributes` dispositions have since MOVED: the cloud leg was measured CLEAN 2026-08-24 (recorded in commit be21955ba; cloud `origin/main` @ 5b5925a, zero `manifest.contributes` reads, controls held), and commit be21955ba then executed the retirement — the nine mechanically-dead members are `retiredKey()` tombstones (D3 `plugin-manifest-contributes-dead-members-retired`), their rows staying because a tombstone keeps the key in the walked shape. Commit bc56e1881 then executed the second retirement too (ruled B 2026-08-22; D3 `plugin-manifest-contributes-routes-retired`), tombstoning `routes` and leaving `kinds` the block's sole live member. The remaining non-`contributes` `dead` rows keep their three-repo census verdicts as an enforce-or-remove worklist, not a licence to delete. `permissions` stays `live` on corrected evidence after its legacy flat-list arm was retired (ADR-0049, conversion `manifest-permissions-string-list-removed`): the block is the structured ADR-0025 one alone, its measured consumer is objectui's install-consent disclosure (it refuses nothing at load), and the `collectDeclaredSuggestions` evidence it used to cite was the assembled-stage `PermissionSet[]` reader, which only reports an authoring-stage value as skipped | | crud_endpoints | seeded 2026-09-02 (commit a3d5724c8) — one of the FOUR `RestServerConfig` sub-objects, and the family that made the `SPEC_ONLY_SCHEMAS` boundary explicit: SERVER CONFIGURATION. An author writes `RestServerConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) as the REST server's construction argument — not a metadata item, not a request body, not a manifest — so no registry has ever held it and no ratchet rooted in one could ask who reads it. Rooted on the four sub-schemas rather than on the whole config on purpose: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with `RestServerConfigSchema` as the root the sub-objects would BE the drilled level and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row of their own, their container's blanket `live` silently covering a dead key — #4956's shape, in the ledger written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is now enrolled SEPARATELY as `rest_api` (#14640, 2026-09-21): when these four landed its consumption seam was still validate-only, so a census of it would have recorded a half that was about to move — that half has moved (`normalizeConfig` builds the `api` block from its parsed output, released), so the fence expired and the fifth sub-object was measured on the settled seam. ⛔ Its ledger is `rest_api.json`, never `api.json`, which is a different surface entirely. **What #11984 settled and what it did not**: that PR made `RestServer.normalizeConfig` PARSE and CONSUME these four instead of casting them, so an out-of-enum or out-of-range value is now refused at construction — accept/reject. Executing a declared contract does not give a key a consumer, and this family is that distinction's worked example. Dead 5 = `objectParamStyle` (every CRUD route takes the object name as a PATH segment; `'query'` is validated against the enum and mounts exactly what `'path'` mounts) and the four members of `patterns` (custom URL patterns are normalized and never read — every route is mounted from hard-coded method/path pairs in `registerCrudEndpoints`). Live 6 = the five `operations.*` switches, each gating a route mount, and `dataPrefix`, which has five independent consumers and moves the mounted paths and the advertised discovery document together. A different question was open about `patterns` (its `z.record` input type demands all five operations) — a declaration defect, not a liveness one, not re-derived here **Commit b3a63d32c RETIRED the dead set (2026-09-03, ADR-0049 enforce-or-remove)**: `patterns` and `objectParamStyle` are `retiredKey()` tombstones (the schema is a non-strict `z.object`, so the rows STAY `dead` with a REMOVED note — the rls.priority precedent) and the four `patterns.*` child rows collapse into the one `patterns` row because the value def `CrudEndpointPattern` left the shape with its key (RETIRED_DEFS_BY_MAJOR[18]). REMOVE, not ENFORCE, because the mounted CRUD paths are the contract the client SDK, the discovery document and /openapi.json all describe — a per-operation pattern knob could only make them lie — and an endpoint on a custom path is a declarative `api` endpoint. `evidenceScope` widened to `cross-repo` on the cloud reading (#14796 @9b6abe0f2fd5: zero, structural). The `z.partialRecord` question closes with the record | | metadata_endpoints | seeded 2026-09-02 (commit a3d5724c8) — one of the FOUR `RestServerConfig` sub-objects, and the family that made the `SPEC_ONLY_SCHEMAS` boundary explicit: SERVER CONFIGURATION. An author writes `RestServerConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) as the REST server's construction argument — not a metadata item, not a request body, not a manifest — so no registry has ever held it and no ratchet rooted in one could ask who reads it. Rooted on the four sub-schemas rather than on the whole config on purpose: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with `RestServerConfigSchema` as the root the sub-objects would BE the drilled level and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row of their own, their container's blanket `live` silently covering a dead key — #4956's shape, in the ledger written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is now enrolled SEPARATELY as `rest_api` (#14640, 2026-09-21): when these four landed its consumption seam was still validate-only, so a census of it would have recorded a half that was about to move — that half has moved (`normalizeConfig` builds the `api` block from its parsed output, released), so the fence expired and the fifth sub-object was measured on the settled seam. ⛔ Its ledger is `rest_api.json`, never `api.json`, which is a different surface entirely. **What #11984 settled and what it did not**: that PR made `RestServer.normalizeConfig` PARSE and CONSUME these four instead of casting them, so an out-of-enum or out-of-range value is now refused at construction — accept/reject. Executing a declared contract does not give a key a consumer, and this family is that distinction's worked example. Dead 2 = `cacheTtl` and `endpoints.schema`. `enableCache` is live and `cacheTtl` is not, which is the pair worth reading together: the cached branch delegates to the protocol's `getMetaItemCached`, whose signature takes no TTL, and no cache header anywhere is built from this value. Its negative-bound observation travels in that row by triage ruling rather than as a separate defect — the schema declares `z.number().int()` with no lower bound, so `-1` is accepted, and #11984 pins it as accepted because that is what the contract says. `endpoints.schema` is the sharpest case in the family for per-key rows: its three siblings each gate a route mount and it gates nothing, because `GET /meta/:type/:name/schema` does not exist — `packages/rest/src` mounts no path ending in `/schema` at all **Commit b3a63d32c RETIRED both (2026-09-03, ADR-0049)**: `cacheTtl` and `endpoints.schema` are `retiredKey()` tombstones, rows kept `dead` with a REMOVED note. The negative-bound observation dies with `cacheTtl` (its #11984 acceptance pin is reversed to a refusal pin); `endpoints.schema` had no route to gate, so there was nothing to enforce. `evidenceScope` widened to `cross-repo` (#14796) | | batch_endpoints | seeded 2026-09-02 (commit a3d5724c8) — one of the FOUR `RestServerConfig` sub-objects, and the family that made the `SPEC_ONLY_SCHEMAS` boundary explicit: SERVER CONFIGURATION. An author writes `RestServerConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) as the REST server's construction argument — not a metadata item, not a request body, not a manifest — so no registry has ever held it and no ratchet rooted in one could ask who reads it. Rooted on the four sub-schemas rather than on the whole config on purpose: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with `RestServerConfigSchema` as the root the sub-objects would BE the drilled level and `metadata.endpoints.schema` / `batch.operations.upsertMany` would have no row of their own, their container's blanket `live` silently covering a dead key — #4956's shape, in the ledger written to end it. `RestApiConfigSchema` (the fifth sub-object, `api`) is now enrolled SEPARATELY as `rest_api` (#14640, 2026-09-21): when these four landed its consumption seam was still validate-only, so a census of it would have recorded a half that was about to move — that half has moved (`normalizeConfig` builds the `api` block from its parsed output, released), so the fence expired and the fifth sub-object was measured on the settled seam. ⛔ Its ledger is `rest_api.json`, never `api.json`, which is a different surface entirely. **What #11984 settled and what it did not**: that PR made `RestServer.normalizeConfig` PARSE and CONSUME these four instead of casting them, so an out-of-enum or out-of-range value is now refused at construction — accept/reject. Executing a declared contract does not give a key a consumer, and this family is that distinction's worked example. Dead 2 = `operations.upsertMany` and `defaultAtomic`. `upsertMany` is `endpoints.schema`'s twin — a switch declared for a route that was never built (`this.protocol` carries `createManyData` / `updateManyData` / `deleteManyData` and no upsert counterpart), so `false` disables nothing. `defaultAtomic` promises a transaction default that no batch handler consults. Live 5 = `maxBatchSize` (load-bearing since #11984 gave it a real parse — before that a configured `0` was the live cap, because `0` is not nullish), `enableBatchEndpoint`, and the three `operations.*` switches that do gate a mount **Commit b3a63d32c RETIRED both (2026-09-03, ADR-0049)**: `operations.upsertMany` and `defaultAtomic` are `retiredKey()` tombstones, rows kept `dead` with a REMOVED note. `defaultAtomic` is the family's worked enforce-or-remove call: the per-request `options.atomic` (ADR-0119 D4, opt-in) IS the contract, and a server default that flipped it silently is the move that ADR refused, so the key was removed rather than wired; upsert lives on as an operation type of the generic batch endpoint. `evidenceScope` widened to `cross-repo` (#14796) | diff --git a/packages/spec/liveness/manifest.json b/packages/spec/liveness/manifest.json index d61fa598d1..4910c77432 100644 --- a/packages/spec/liveness/manifest.json +++ b/packages/spec/liveness/manifest.json @@ -60,10 +60,41 @@ }, "permissions": { "status": "live", - "evidence": "packages/plugins/plugin-security/src/suggested-audience-bindings.ts#collectDeclaredSuggestions (declared permission strings from every enabled installed package feed the suggested audience bindings — `Array.isArray(manifest?.permissions) ? manifest.permissions : []`, which is also where the legacy-arm-only reading below is measured)", - "verifiedAt": "2026-08-28", + "evidence": "objectui: packages/app-shell/src/console/marketplace/PluginDisclosure.tsx#PluginDisclosure @a58626c88dc8 (the install-consent disclosure reads the marketplace version's `permissions` and renders its four lists — `services`, `hooks`, `network`, `fs` — under \"This package requests:\", one group per list)", + "producer": "packages/cli/src/commands/plugin/publish.ts#PluginPublish (`os plugin publish` uploads the compiled manifest whole as `plugin_manifest`; the cloud control plane's projection of it onto the version row the disclosure reads was NOT measured from this seat — see the note)", + "verifiedAt": "2026-10-07", "evidenceScope": "cross-repo", - "note": "LIVE ON ONE ARM ONLY, and the split matters. `ManifestPermissionsSchema` (manifest.zod.ts:54) is a union of the legacy flat `string[]` and the structured `PluginPermissionsSchema` (services / hooks / network / fs, ADR-0025 §3.2). The single reader is guarded by `Array.isArray(manifest?.permissions)`, so it reads the LEGACY arm and skips the structured one entirely; no other reader exists in objectstack or objectui. PluginPermissionEnforcer (packages/core/src/security/plugin-permission-enforcer.ts:94-104) is not the missing consumer — it registers the set the install-time consent flow persisted to `sys_package_installation.granted_permissions`, explicitly \"independent of whatever the manifest *requested*\", and nothing in this repo feeds the manifest declaration into it. The verdict is `live` because the key is read; the structured arm's zero is recorded apart (commit aaacf1d5c, the `PluginPermissionsSchema` docblock) rather than being flattened into this one-level row. Cloud unmeasured (see `_note`) — the consent flow ADR-0025 §3.5 describes lives there. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — the citation was still ACCURATE (`suggested-audience-bindings.ts:252` names the read), so this is a grammar migration. What the anchor buys here is specific to this row's shape: the whole `live` verdict rests on ONE reader, so if `collectDeclaredSuggestions` is deleted or renamed the entry now goes red instead of pointing at whatever line 252 has become. Re-closed by hand against c459da6bc." + "note": "RE-VERDICTED 2026-10-07 by the retirement of the legacy flat-list arm (ADR-0049 enforce-or-remove, ruled option A; ADR-0087 conversion `manifest-permissions-string-list-removed`): `ManifestPermissionsSchema` is now the structured `PluginPermissionsSchema` block itself (services / hooks / network / fs, ADR-0025 §3.2), and a list is refused at parse with its prescription. The verdict stays `live`, on CORRECTED evidence. This row used to rest on `collectDeclaredSuggestions` (plugin-security) reading the legacy arm — a misattribution: that reader wants the ASSEMBLED stage's `PermissionSet[]` collection, which shares this key (the two-readings collision `AssembledPackageBodySchema`'s stage table records), and it, like `AppPlugin`'s security registrar, only REPORTS an authoring-stage value as skipped. LOCAL HALF, measured at e67ba80049: every `manifest.permissions` / `manifest?.permissions` / `manifest[...]` spelling and every `ManifestPermissions*` / `PluginPermissions*` importer — zero runtime consumers of the manifest-stage value. The permission enforcer is not one: it registers `grantedPermissions` from the environment artifact (the CONSENTED set, whose values are this same schema), never the manifest's request. Control on the same instrument: it finds those two drop reports and the assembled-stage read in the CLI's permission-set-name-collisions test, so the zero is about this key's readers, not the pattern. FOREIGN HALF: objectui at the `.objectui-sha` pin a58626c88dc8 reads the structured block in the marketplace install dialog (`PluginDisclosure`) and shows the installer each requested service, hook, host and path — 0 reads of `manifest.permissions` there, control 91 `manifest.(id|name|version)` reads at the same pin. A flat list carries none of the four lists, so the retired arm disclosed nothing there either. PRODUCER: `os plugin publish` uploads the manifest; the cloud control plane's projection onto the version row is the unmeasured hop — `add_repo` on cloud was denied from this seat on 2026-10-07, so a cloud-capable seat re-reads that projection and pins its commit. NOT ENFORCED AT LOAD: the block records a consent request and REFUSES NOTHING (the `PluginPermissionsSchema` docblock), so `live` here means authoring it changes what an installer is shown, not what the plugin may touch.", + "children": { + "services": { + "status": "live", + "evidence": "objectui: packages/app-shell/src/console/marketplace/PluginDisclosure.tsx#PluginDisclosure @a58626c88dc8 (`perms.services` — counted into the disclosure's has-any test and rendered as its \"Platform services\" group)", + "verifiedAt": "2026-10-07", + "evidenceScope": "cross-repo", + "note": "Drilled 2026-10-07 when the retirement of the flat-list arm left this block the whole of `permissions`, so the walk began to see it as a container. Shown to the installer, NOT enforced at load — the container's note carries the producer hop and the not-enforced half." + }, + "hooks": { + "status": "live", + "evidence": "objectui: packages/app-shell/src/console/marketplace/PluginDisclosure.tsx#PluginDisclosure @a58626c88dc8 (`perms.hooks` — counted into the disclosure's has-any test and rendered as its \"Lifecycle hooks\" group)", + "verifiedAt": "2026-10-07", + "evidenceScope": "cross-repo", + "note": "Drilled 2026-10-07 when the retirement of the flat-list arm left this block the whole of `permissions`, so the walk began to see it as a container. Shown to the installer, NOT enforced at load — the container's note carries the producer hop and the not-enforced half." + }, + "network": { + "status": "live", + "evidence": "objectui: packages/app-shell/src/console/marketplace/PluginDisclosure.tsx#PluginDisclosure @a58626c88dc8 (`perms.network` — counted into the disclosure's has-any test and rendered as its \"Network access\" group)", + "verifiedAt": "2026-10-07", + "evidenceScope": "cross-repo", + "note": "Drilled 2026-10-07 when the retirement of the flat-list arm left this block the whole of `permissions`, so the walk began to see it as a container. Shown to the installer, NOT enforced at load — the container's note carries the producer hop and the not-enforced half." + }, + "fs": { + "status": "live", + "evidence": "objectui: packages/app-shell/src/console/marketplace/PluginDisclosure.tsx#PluginDisclosure @a58626c88dc8 (`perms.fs` — counted into the disclosure's has-any test and rendered as its \"Filesystem access\" group)", + "verifiedAt": "2026-10-07", + "evidenceScope": "cross-repo", + "note": "Drilled 2026-10-07 when the retirement of the flat-list arm left this block the whole of `permissions`, so the walk began to see it as a container. Shown to the installer, NOT enforced at load — the container's note carries the producer hop and the not-enforced half." + } + } }, "objects": { "status": "live", diff --git a/packages/spec/liveness/state-counts/manifest.md b/packages/spec/liveness/state-counts/manifest.md index c646c3f5cd..9be886cb04 100644 --- a/packages/spec/liveness/state-counts/manifest.md +++ b/packages/spec/liveness/state-counts/manifest.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `manifest` | 23 | 0 | 1 | 15 | 0 | 39 | +| `manifest` | 26 | 0 | 1 | 15 | 0 | 42 | diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index d707d83fdb..93ec717f46 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8757,6 +8757,112 @@ const recordHighlightsFieldIconRemoved: MetadataConversion = { }, }; +/** + * A package manifest's `permissions` as a flat list of permission strings → + * dropped (protocol 18 — ADR-0049 enforce-or-remove, ruled option A: retire + * the legacy arm so the structured ADR-0025 §3.2 block is the only one). + * + * `ManifestPermissionsSchema` was a union of that list and the structured + * `{ services, hooks, network, fs }` block. Nothing in this repository ever + * acted on the list: the loader registers the CONSENTED grant set + * (`grantedPermissions` on the environment artifact) with the permission + * enforcer, never the manifest's request, and the only code that met a list + * on a manifest was two drop reports saying it had been skipped. So the delete + * changes no load, no grant and no refusal. + * + * ## Why a strip and not a rewrite + * + * A capability string (`system.user.read`) names no platform service, hook, + * network host or filesystem path, and no table maps one onto the four lists — + * inventing one would write grants nobody asked for into a consent request. So + * the list is removed whole and the notice carries the strings it dropped + * (`from`), which is what the author translates by hand; the schema's own + * answer to a list says the same. + * + * ## Reach + * + * A manifest sits in two places a converted definition carries it: the stack's + * own `manifest`, and each `packages[].manifest` of a multi-package artifact + * (ADR-0130 D4). Only a list whose every member is a string is this entry's + * surface — that is exactly what the retired arm accepted, so an array of + * objects (a permission-set collection written one stage too early) is left as + * stored for the schema to refuse, never deleted. `devPlugins[]` is not walked: + * its entries are assembly instructions that may be live plugin objects, which + * a copy-on-write spread would strip of their prototype. + * + * The top-level `permissions` key is the ADR-0090 permission-SET collection + * and is never touched. + * + * ## Why `retiredFromLoadPath` + * + * The schema refuses the list with its prescription, so a live author is + * taught the structured block rather than silently rewritten; the entry + * exists so a stored artifact built while the list was legal replays clean at + * the artifact door, and so `os migrate meta --from 17` lists the mechanical + * edits for author sources. Deletion is idempotent by construction: a manifest + * without the key is returned as is. + */ +const manifestPermissionsStringListRemoved: MetadataConversion = { + id: 'manifest-permissions-string-list-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', + surface: 'manifest.permissions', + summary: + "manifest 'permissions' as a flat list of permission strings removed (ADR-0049 — no loader ever read " + + 'the list, so dropping it changes no grant; the structured { services, hooks, network, fs } block is ' + + 'the only form, and a permission string has no mechanical mapping onto it)', + apply(stack, emit) { + const stripList = (manifest: unknown, path: string): unknown => { + if (!isDict(manifest)) return manifest; + const list = manifest.permissions; + if (!Array.isArray(list) || !list.every((entry) => typeof entry === 'string')) return manifest; + const next: Dict = { ...manifest }; + delete next.permissions; + emit({ from: `permissions: ${JSON.stringify(list)}`, to: '(removed)', path: `${path}.permissions` }); + return next; + }; + + let next = stack; + const manifest = stripList(stack.manifest, 'manifest'); + if (manifest !== stack.manifest) next = { ...next, manifest }; + + const packages = stack.packages; + if (Array.isArray(packages)) { + let touched = false; + const nextPackages = packages.map((entry, i) => { + if (!isDict(entry)) return entry; + const converted = stripList(entry.manifest, `packages[${i}].manifest`); + if (converted === entry.manifest) return entry; + touched = true; + return { ...entry, manifest: converted }; + }); + if (touched) next = { ...next, packages: nextPackages }; + } + return next; + }, + fixture: { + before: { + manifest: { id: 'com.acme.reports', permissions: ['system.user.read', 'system.data.write'] }, + packages: [ + { manifest: { id: 'com.acme.reports.export', permissions: ['system.object.read'] } }, + // The structured block is the canonical form and rides through + // untouched — the strip dispatches on a list, never on the key. + { manifest: { id: 'com.acme.reports.share', permissions: { services: ['object'] } } }, + ], + }, + after: { + manifest: { id: 'com.acme.reports' }, + packages: [ + { manifest: { id: 'com.acme.reports.export' } }, + { manifest: { id: 'com.acme.reports.share', permissions: { services: ['object'] } } }, + ], + }, + // One per stripped list — the stack's own manifest and one package entry. + expectedNotices: 2, + }, +}; + /** * `mapping.fieldMapping[].params` lookup keys removed (commit 15d58dbf1, ADR-0049 * enforce-or-remove — the sub-walk half of the 17.0.0 #4509 mapping cleanup). @@ -14794,6 +14900,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: hookTimeoutToTimeoutMs, order: 21 }, { conversion: jobTimeoutToTimeoutMs, order: 22 }, { conversion: listViewSortStringClauseToArray, order: 30 }, + { conversion: manifestPermissionsStringListRemoved, order: 61 }, { conversion: mappingLookupParamsRemoved, order: 11 }, { conversion: memoryPersistenceAutoSaveIntervalToMs, order: 27 }, { conversion: metricFiltersRemoved, order: 7 }, diff --git a/packages/spec/src/kernel/manifest-permissions-string-list.test.ts b/packages/spec/src/kernel/manifest-permissions-string-list.test.ts new file mode 100644 index 0000000000..f15541df83 --- /dev/null +++ b/packages/spec/src/kernel/manifest-permissions-string-list.test.ts @@ -0,0 +1,218 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The flat string-list arm of a package manifest's `permissions` is RETIRED + * (ADR-0049 enforce-or-remove; ADR-0087 conversion + * `manifest-permissions-string-list-removed`, D3 entry + * `manifest-permissions-string-list-retired`). + * + * `ManifestPermissionsSchema` was `z.union([z.array(z.string()), block])`. No + * loader ever read the list — what a package is granted at load is the + * consented `grantedPermissions` set, never the manifest's request — so the + * structured ADR-0025 §3.2 block is now the only form. + * + * What this file holds, in the order an upgrading author meets it: + * + * 1. the PARSE refuses a list, with the block's own prescription rather + * than zod's bare type error, on both carriers of the one declaration; + * 2. TypeScript refuses it at the authoring site; + * 3. the structured block's accept set did not move (H3 of the dispatch: + * the retirement removed the other arm, nothing else); + * 4. the D2 conversion strips a list from the stack's manifest and every + * `packages[].manifest`, leaves everything else alone, is idempotent, and + * is NOT replayed on the authoring funnel. + * + * ⛔ No tree-scoped absence pin rides with this retirement: the KEY survives, + * only a value form leaves, and the same key is the legal ADR-0090 + * permission-set collection one stage along — `permissions: [` is correct at a + * stack's top level — so no text pattern can tell a retired authoring-stage + * list from a live collection. The parse and `tsc` channels are the sweep. + */ + +import { describe, expect, it } from 'vitest'; + +import { applyConversions } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import type { ConversionNotice } from '../conversions/types'; +import { normalizeStackInput } from '../shared/metadata-collection.zod'; +import { formatZodError } from '../shared/error-map.zod'; +import { + ManifestPermissionsSchema, + ManifestSchema, + PluginPermissionsSchema, + type ManifestPermissions, + type ObjectStackManifest, +} from './manifest.zod'; + +const legal = () => ({ + id: 'com.example.probe', + namespace: 'probe', + version: '1.0.0', + type: 'plugin' as const, + name: 'Probe', + engines: { protocol: '^17' }, +}); + +/** + * The prescription, as the parts an upgrading author needs: what the slot + * takes, that the list was removed and in which release, and the house + * two-clause `os migrate meta` sentence naming the case the conversion covers. + */ +const PRESCRIPTION = + /^Expected the plugin permission block `\{ services\?, hooks\?, network\?, fs\? \}`, received a flat list\..*removed in @objectstack\/spec 17 \(ADR-0049 enforce-or-remove\).*translate each one by hand, or delete `permissions` when the plugin needs none\. Run `os migrate meta --from 17` to list the mechanical edits for the package manifest case; a granted-permission record is not a source it reads\.$/s; + +const permissionsIssue = (input: unknown) => { + const result = ManifestSchema.safeParse(input); + expect(result.success, 'the manifest must be refused').toBe(false); + if (result.success) throw new Error('unreachable'); + const issue = result.error.issues.find((i) => i.path.length === 1 && i.path[0] === 'permissions'); + expect(issue, 'the refusal answers at `permissions`').toBeDefined(); + return { issue: issue!, error: result.error }; +}; + +describe('manifest.permissions — the flat string list is refused at parse, with its prescription', () => { + it('a list of permission strings is refused at `permissions` with the block\'s own answer', () => { + const { issue } = permissionsIssue({ ...legal(), permissions: ['system.user.read', 'system.data.write'] }); + expect(issue.code).toBe('invalid_type'); + expect(issue.message).toMatch(PRESCRIPTION); + }); + + it('the EMPTY list is refused too — it was the retired arm\'s shape, and absence is the spelling for "nothing"', () => { + const { issue } = permissionsIssue({ ...legal(), permissions: [] }); + expect(issue.code).toBe('invalid_type'); + expect(issue.message).toMatch(PRESCRIPTION); + }); + + it('an array of permission-set objects is refused with the same answer — that collection is one stage along, never on the manifest', () => { + const { issue } = permissionsIssue({ ...legal(), permissions: [{ name: 'support_agent', isDefault: true }] }); + expect(issue.code).toBe('invalid_type'); + expect(issue.message).toMatch(PRESCRIPTION); + }); + + it('the author reads the prescription through `formatZodError`, not a bare type error', () => { + const { error } = permissionsIssue({ ...legal(), permissions: ['system.user.read'] }); + const rendered = formatZodError(error); + expect(rendered).toContain('permissions: Expected the plugin permission block'); + expect(rendered).not.toContain('expected object, received array'); + }); + + it('a non-list wrong type keeps zod\'s own message — "was removed" would misinform its author', () => { + const { issue } = permissionsIssue({ ...legal(), permissions: 'system.user.read' }); + expect(issue.code).toBe('invalid_type'); + expect(issue.message).not.toMatch(/removed in @objectstack\/spec/); + }); + + it('the second carrier of the one declaration — a `grantedPermissions` value — gets the same answer', () => { + // `EnvironmentArtifactSchema.grantedPermissions` is a record whose values + // ARE this block (`system/environment-artifact.test.ts` pins the identity), + // so the list answer is worded true on both: the clause naming the + // conversion says which case it covers. + const value = PluginPermissionsSchema.safeParse(['system.user.read']); + expect(value.success).toBe(false); + if (value.success) return; + expect(value.error.issues[0]!.message).toMatch(PRESCRIPTION); + }); + + it('`ManifestPermissionsSchema` IS the structured block — one declaration, by identity', () => { + expect(ManifestPermissionsSchema).toBe(PluginPermissionsSchema); + }); + + it('TypeScript refuses a list at the authoring site', () => { + // @ts-expect-error — the retired arm: a list is not the block. + const list: ManifestPermissions = ['system.user.read']; + const manifest: ObjectStackManifest = { + ...legal(), + // @ts-expect-error — the same refusal on the manifest itself. + permissions: ['system.user.read'], + }; + expect(Array.isArray(list) && Array.isArray(manifest.permissions)).toBe(true); + }); +}); + +describe('manifest.permissions — the structured block\'s accept set did not move', () => { + it('accepts every declared key alone, the full block, and the empty block', () => { + for (const key of ['services', 'hooks', 'network', 'fs']) { + expect(ManifestSchema.safeParse({ ...legal(), permissions: { [key]: ['x'] } }).success, key).toBe(true); + } + expect(ManifestSchema.safeParse({ + ...legal(), + permissions: { services: ['object', 'http'], hooks: ['record.beforeInsert'], network: ['api.acme.com'], fs: [] }, + }).success).toBe(true); + expect(ManifestSchema.safeParse({ ...legal(), permissions: {} }).success).toBe(true); + expect(ManifestSchema.safeParse({ ...legal() }).success, 'absent is still legal').toBe(true); + }); + + it('refuses what it always refused — an unknown key, and a non-string list member', () => { + expect(ManifestSchema.safeParse({ ...legal(), permissions: { hoooks: ['x'] } }).success).toBe(false); + expect(ManifestSchema.safeParse({ ...legal(), permissions: { services: [1] } }).success).toBe(false); + }); +}); + +describe('ADR-0087 D2 `manifest-permissions-string-list-removed`', () => { + const entry = ALL_CONVERSIONS.find((c) => c.id === 'manifest-permissions-string-list-removed'); + + const replay = (stack: Record) => { + const notices: ConversionNotice[] = []; + const out = applyConversions(stack, { includeRetired: true, onNotice: (n) => notices.push(n) }); + return { out, notices: notices.filter((n) => n.conversionId === 'manifest-permissions-string-list-removed') }; + }; + + it('is registered at protocol 18 and retired from the authoring load path', () => { + expect(entry, 'premise: the entry exists').toBeDefined(); + expect(entry!.toMajor).toBe(18); + expect(entry!.retiredFromLoadPath).toBe(true); + }); + + it('strips a list from the stack\'s manifest and names the dropped strings in the notice', () => { + const { out, notices } = replay({ manifest: { id: 'com.acme.x', permissions: ['system.user.read'] } }); + expect(out.manifest).toEqual({ id: 'com.acme.x' }); + expect(notices).toHaveLength(1); + expect(notices[0]!.path).toBe('manifest.permissions'); + expect(notices[0]!.from).toBe('permissions: ["system.user.read"]'); + expect(notices[0]!.to).toBe('(removed)'); + }); + + it('strips an EMPTY list — the retired arm accepted it, and the block would refuse it', () => { + const { out, notices } = replay({ manifest: { id: 'com.acme.x', permissions: [] } }); + expect(out.manifest).toEqual({ id: 'com.acme.x' }); + expect(notices).toHaveLength(1); + }); + + it('strips a list from each `packages[].manifest`, and only there', () => { + const { out, notices } = replay({ + packages: [ + { manifest: { id: 'com.acme.a', permissions: ['x.y'] } }, + { manifest: { id: 'com.acme.b' } }, + ], + }); + expect(out.packages).toEqual([{ manifest: { id: 'com.acme.a' } }, { manifest: { id: 'com.acme.b' } }]); + expect(notices.map((n) => n.path)).toEqual(['packages[0].manifest.permissions']); + }); + + it('never touches the structured block, an array of objects, or the top-level permission-set collection', () => { + const stack = { + manifest: { id: 'com.acme.x', permissions: { services: ['object'] } }, + packages: [{ manifest: { id: 'com.acme.y', permissions: [{ name: 'set_written_one_stage_early' }] } }], + // The ADR-0090 collection — the SAME key, one stage along. Never this entry's surface. + permissions: [{ name: 'support_agent', label: 'Support Agent' }], + }; + const { out, notices } = replay(structuredClone(stack)); + expect(out).toEqual(stack); + expect(notices).toEqual([]); + }); + + it('is idempotent — the converted stack replays to itself with nothing applied', () => { + const once = replay({ manifest: { id: 'com.acme.x', permissions: ['system.user.read'] } }).out; + const twice = replay(once); + expect(twice.out).toBe(once); + expect(twice.notices).toEqual([]); + }); + + it('⛔ is NOT replayed on the authoring funnel — an author meets the refusal, never a silent rewrite', () => { + const notices: ConversionNotice[] = []; + const authored = { manifest: { ...legal(), permissions: ['system.user.read'] } }; + const out = normalizeStackInput(structuredClone(authored), { onConversionNotice: (n) => notices.push(n) }); + expect((out.manifest as { permissions?: unknown }).permissions).toEqual(['system.user.read']); + expect(notices.filter((n) => n.conversionId === 'manifest-permissions-string-list-removed')).toEqual([]); + }); +}); diff --git a/packages/spec/src/kernel/manifest-unknown-keys.test.ts b/packages/spec/src/kernel/manifest-unknown-keys.test.ts index e3504ba2ef..50915520ba 100644 --- a/packages/spec/src/kernel/manifest-unknown-keys.test.ts +++ b/packages/spec/src/kernel/manifest-unknown-keys.test.ts @@ -357,7 +357,7 @@ describe('the accept side does not move, and `main` is declared', () => { }); }); -describe('the `permissions` union door names the surface and the rename, like every other door', () => { +describe('the `permissions` door names the surface and the rename, like every other door', () => { // ## What #16328 reported, and what was actually wrong // // The card measured `{ services: ['object'], hoooks: ['x'] }` refused with a @@ -381,27 +381,32 @@ describe('the `permissions` union door names the surface and the rename, like ev // sibling doors (`ManifestSchema` through `devPlugins[]`, and // `ActionRef` / `GuardRef`) carry all three. // - // So this pins the CONTENT of the nested line, not the union's shape. The - // union is deliberately untouched: reshaping it costs either the accept set - // or the published JSON Schema, which is the standing finding recorded on - // the `devPlugins[]` guard above. + // So this pins the CONTENT of the line, not the slot's shape. + // + // ## Since the flat-list arm retired, there is no union to descend + // + // `ManifestPermissionsSchema` WAS `z.union([z.array(z.string()), block])`; + // the list arm retired (ADR-0049, ADR-0087 conversion + // `manifest-permissions-string-list-removed`), so the slot is the block + // itself and the block's own `unrecognized_keys` issue sits at + // `['permissions']` directly, with no `invalid_union` envelope around it. + // The CONTENT pinned below is unchanged — the same surface, the same rename, + // the same curated aliases — which is the point: the retirement moved the + // envelope, never the words. The list's own refusal is pinned in + // `manifest-permissions-string-list.test.ts`. const near = () => ({ ...legal(), permissions: { services: ['object'], hoooks: ['x'] } }); /** - * The object arm's own `unrecognized_keys` issue, carried inside the union - * issue at `['permissions']` — the shape the first pin below measures raw. + * The block's own `unrecognized_keys` issue at `['permissions']` — the shape + * the first pin below measures raw. */ const permissionsRefusal = (result: ReturnType) => { if (result.success) throw new Error('expected the manifest to be refused'); - const union = result.error.issues.find((i) => i.code === 'invalid_union') as - | { path: (string | number)[]; errors: Array> } - | undefined; - expect(union, 'the refusal is a union issue at `permissions`').toBeDefined(); - expect(union!.path).toEqual(['permissions']); - const nested = union!.errors.flat().find((i) => i.code === 'unrecognized_keys') as - | { code: string; keys: string[] } + const nested = result.error.issues.find((i) => i.code === 'unrecognized_keys') as + | { code: string; path: (string | number)[]; keys: string[] } | undefined; - expect(nested, 'the named refusal is carried inside the union issue').toBeDefined(); + expect(nested, 'the named refusal is the block\'s own issue').toBeDefined(); + expect(nested!.path).toEqual(['permissions']); return nested!; }; @@ -416,31 +421,27 @@ describe('the `permissions` union door names the surface and the rename, like ev expect(rendered, 'the rename is offered').toContain('Did you mean `hoooks` → `hooks`?'); }); - it('the named refusal is the object arm\'s own issue, carried inside the union issue', () => { - // The raw shape, stated because it is the half the card measured: the - // top-level issue IS a keyless `invalid_union` and that is not the defect. + it('the named refusal is the block\'s own issue at `permissions` — no union envelope since the list arm retired', () => { + // The raw shape: one `unrecognized_keys` issue, and no keyless + // `invalid_union` above it any more. const result = ManifestSchema.safeParse(near()); expect(result.success).toBe(false); if (result.success) return; - const union = result.error.issues.find((i) => i.code === 'invalid_union') as - | { path: (string | number)[]; errors: Array> } - | undefined; - expect(union).toBeDefined(); - expect(union!.path).toEqual(['permissions']); - const nested = union!.errors.flat().find((i) => i.code === 'unrecognized_keys'); - expect(nested, 'the named refusal is carried inside the union issue').toBeDefined(); - expect(nested!.keys).toEqual(['hoooks']); - expect(nested!.message).toContain('Did you mean `hoooks` → `hooks`?'); + expect(result.error.issues.some((i) => i.code === 'invalid_union'), 'no union envelope').toBe(false); + const nested = permissionsRefusal(result) as { keys: string[]; message?: string }; + expect(nested.keys).toEqual(['hoooks']); + expect(nested.message).toContain('Did you mean `hoooks` → `hooks`?'); }); - it('the accept set does not move — both arms of the union still parse', () => { + it('the structured block\'s accept set does not move', () => { // #16328's negative control, and the reason a "fix" here could be worse - // than the defect. `strictObject` is `z.object(shape, { error }).strict()`: + // than the defect. The block is `z.object(shape, { error }).strict()`: // the shape and the strictness are unchanged, and an error map is consulted - // only once an issue is already being raised. + // only once an issue is already being raised. The retirement removed the + // OTHER arm; nothing the block accepted is refused, nothing it refused is + // accepted (the list's refusal is `manifest-permissions-string-list.test.ts`). expect(ManifestSchema.safeParse({ ...legal(), permissions: { services: ['object'] } }).success).toBe(true); - expect(ManifestSchema.safeParse({ ...legal(), permissions: ['read', 'write'] }).success).toBe(true); - expect(ManifestSchema.safeParse({ ...legal(), permissions: [] }).success).toBe(true); + expect(ManifestSchema.safeParse({ ...legal(), permissions: {} }).success).toBe(true); expect(ManifestSchema.safeParse({ ...legal(), permissions: { services: ['object'], hooks: ['record.beforeInsert'], network: ['api.acme.com'], fs: [] }, diff --git a/packages/spec/src/kernel/manifest.test.ts b/packages/spec/src/kernel/manifest.test.ts index 966a5c432b..c55a18091f 100644 --- a/packages/spec/src/kernel/manifest.test.ts +++ b/packages/spec/src/kernel/manifest.test.ts @@ -104,12 +104,12 @@ describe('ManifestSchema', () => { version: '1.0.0', type: 'plugin', name: 'Admin Tools', - permissions: [ - 'system.user.read', - 'system.user.write', - 'system.data.read', - 'system.data.write', - ], + // The structured ADR-0025 §3.2 block — the only form since the flat + // string list retired (`manifest-permissions-string-list.test.ts`). + permissions: { + services: ['object', 'auth'], + hooks: ['record.beforeInsert'], + }, }; expect(() => ManifestSchema.parse(manifest)).not.toThrow(); @@ -143,17 +143,9 @@ describe('ManifestSchema', () => { type: 'app', name: 'ObjectStack CRM', description: 'Complete customer relationship management solution with sales, marketing, and service modules', - permissions: [ - 'app.access.crm', - 'crm.lead.read', - 'crm.lead.write', - 'crm.opportunity.read', - 'crm.opportunity.write', - 'crm.account.read', - 'crm.account.write', - 'crm.contact.read', - 'crm.contact.write', - ], + // No `permissions`: an app's record access is its permission SETS, in the + // stack's own `permissions` collection — the manifest-stage key is a + // plugin's capability grant, and an app ships no code that needs one. objects: [ './objects/lead.object.ts', './objects/opportunity.object.ts', @@ -202,10 +194,10 @@ describe('ManifestSchema', () => { type: 'plugin', name: 'SAML Authentication Plugin', description: 'Enables SAML 2.0 single sign-on authentication', - permissions: [ - 'system.auth.configure', - 'system.user.create', - ], + permissions: { + services: ['auth'], + network: ['idp.example.com'], + }, // `extensions` retired (commit dce5cd4f0) — nothing ever read the container. }; @@ -219,9 +211,9 @@ describe('ManifestSchema', () => { type: 'driver', name: 'PostgreSQL Driver', description: 'PostgreSQL database driver with advanced features', - permissions: [ - 'system.datasource.manage', - ], + permissions: { + network: ['db.internal'], + }, // `extensions` retired (commit dce5cd4f0) — nothing ever read the container. }; @@ -259,9 +251,9 @@ describe('ManifestSchema', () => { type: 'gateway', name: 'GraphQL Gateway', description: 'GraphQL API protocol gateway for ObjectStack', - permissions: [ - 'system.api.configure', - ], + permissions: { + services: ['object', 'http'], + }, }; expect(() => ManifestSchema.parse(graphqlGateway)).not.toThrow(); diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index f691948f5c..b422387cec 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { CORE_PLUGIN_TYPES } from './plugin.zod'; import { SEMVER_2_0_0_VERSION_PATTERN } from './version-grammar'; import { retiredKey } from '../shared/retired-key'; -import { strictObject } from '../shared/strict-object'; +import { closedObject, strictObject, strictObjectError } from '../shared/strict-object'; import { formatSuggestion } from '../shared/suggestions.zod'; import { SeedSchema } from '../data/seed.zod'; import { NavigationContributionSchema } from '../ui/app.zod'; @@ -19,6 +19,75 @@ import { NavigationContributionSchema } from '../ui/app.zod'; // (see cloud docs/design/plugin-distribution-framework-tasks.md F1). // ───────────────────────────────────────────────────────────────────── +/** + * The block's own answer to a flat LIST — the one `invalid_type` it names, + * carried on the block's error map because that is the only map a type + * failure at this position consults (a map on the enclosing manifest is never + * reached, and a manifest-level refinement never runs once a property has + * failed its type). Same shape as `ListViewExportOptionsSchema`'s bare-array + * answer (`ui/list-view-export-options.ts`). + * + * Worded to be true on BOTH carriers, because both read this one declaration: + * a package manifest's `permissions`, where a list was the retired legacy + * form and the ADR-0087 conversion strips it from existing sources, and + * `EnvironmentArtifactSchema.grantedPermissions`, whose values are this very + * schema and where a list was never legal — hence the two-clause + * `os migrate meta` sentence naming the case the conversion covers. + */ +const PLUGIN_PERMISSIONS_LIST_FORM = + 'Expected the plugin permission block `{ services?, hooks?, network?, fs? }`, received a flat list. ' + + 'A list of permission strings was the legacy form of a package manifest\'s `permissions`, removed in ' + + '@objectstack/spec 17 (ADR-0049 enforce-or-remove) — no loader ever read it: what a package is ' + + 'granted at load is the consented grant set, never the manifest\'s list. Name what the plugin may touch ' + + 'in the four lists instead — platform services, lifecycle hooks, network hosts and filesystem paths, ' + + 'e.g. `{ services: [\'object\'], network: [\'api.acme.com\'] }`. A permission string has no mechanical ' + + 'mapping onto them, so translate each one by hand, or delete `permissions` when the plugin needs none. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for the package manifest case; a granted-permission record is not a source it reads.'; + +const PLUGIN_PERMISSIONS_SHAPE = { + services: z.array(z.string()).optional() + .describe('Platform services the plugin may resolve (e.g. "object", "http")'), + hooks: z.array(z.string()).optional() + .describe('Lifecycle hooks the plugin may register (e.g. "record.beforeInsert")'), + network: z.array(z.string()).optional() + .describe('Network hosts the plugin may reach (e.g. "api.acme.com")'), + fs: z.array(z.string()).optional() + .describe('Filesystem paths the plugin may access'), +}; + +/** + * The unknown-key map `strictObject()` would build for this block. Spelled out + * rather than through `strictObject()` only so the list answer above can sit + * in front of it; the surface, the history, the aliases and the registered + * declaration are the ones the block has always had. + */ +const pluginPermissionsUnknownKeyError = strictObjectError({ + surface: 'the `permissions` block of this package manifest', + history: + 'This block has refused unknown keys since it was introduced, but through zod\'s own ' + + 'bare message: a transposed `hoooks` was echoed back and nothing else — no surface, no ' + + 'rename — while every neighbouring block on this manifest named all three. Reaching ' + + 'the author one level down inside the `permissions` union made that the whole message, ' + + 'and this block decides which services, hooks, network hosts and filesystem paths the ' + + 'plugin may touch. The declared keys are `services`, `hooks`, `network` and `fs`.', + aliases: { + // These two are the unreachable case: edit distance cannot reach a + // two-letter abbreviation from the word it abbreviates, and `fs` is the + // one key here an author is most likely to spell out in full. + filesystem: 'fs', + paths: 'fs', + // `hosts` is the opposite case, and the stronger reason to curate an + // entry: it IS within budget of `hooks`. The fallback budget is + // `Math.max(2, Math.floor(key.length / 3))` (`shared/suggestions.zod.ts`), + // so a 5-character key gets 2, and `hosts`/`hooks` differ by exactly 2. + // Without this line the fallback answers `hosts` -> `hooks`, pointing the + // author at lifecycle hooks on the one block that also grants network + // access. This alias overrides a confident WRONG suggestion rather than + // filling a silent gap, and `manifest-unknown-keys.test.ts` pins that. + hosts: 'network', + }, +}, PLUGIN_PERMISSIONS_SHAPE); + /** * Structured permission grants requested by a plugin (ADR-0025 §3.2). * Each list scopes one capability surface the plugin may touch. The @@ -42,59 +111,51 @@ import { NavigationContributionSchema } from '../ui/app.zod'; * `sys_package_installation` directly (ADR-0003 / cloud ADR-0007). Absent * there = no consent record; `{}` = consented to nothing. * + * This block is the ONLY form a manifest's `permissions` takes. The flat + * `string[]` it used to be unioned with was retired (ADR-0049 + * enforce-or-remove, ADR-0087 conversion `manifest-permissions-string-list-removed`): + * no reader in this repository ever acted on that list — the loader registers + * the consented grant set, never the manifest's request — and a list has no + * mechanical mapping onto the four surfaces below, so the conversion strips it + * and the author translates. A list reaching this block is answered by + * {@link PLUGIN_PERMISSIONS_LIST_FORM} rather than by zod's bare type error. + * + * Closed exactly as `strictObject()` closes a shape; the `prime` handle is + * forwarded so the unknown-key map is still built on the refusal path + * (`closedObject`'s contract). The accept set is the block's own — the list + * answer only rewords the type error a non-object already raised. + * * @example * ```jsonc * { "services": ["object", "http"], "hooks": ["record.beforeInsert"], * "network": ["api.acme.com"], "fs": [] } * ``` */ -export const PluginPermissionsSchema = strictObject({ - surface: 'the `permissions` block of this package manifest', - history: - 'This block has refused unknown keys since it was introduced, but through zod\'s own ' - + 'bare message: a transposed `hoooks` was echoed back and nothing else — no surface, no ' - + 'rename — while every neighbouring block on this manifest named all three. Reaching ' - + 'the author one level down inside the `permissions` union made that the whole message, ' - + 'and this block decides which services, hooks, network hosts and filesystem paths the ' - + 'plugin may touch. The declared keys are `services`, `hooks`, `network` and `fs`.', - aliases: { - // These two are the unreachable case: edit distance cannot reach a - // two-letter abbreviation from the word it abbreviates, and `fs` is the - // one key here an author is most likely to spell out in full. - filesystem: 'fs', - paths: 'fs', - // `hosts` is the opposite case, and the stronger reason to curate an - // entry: it IS within budget of `hooks`. The fallback budget is - // `Math.max(2, Math.floor(key.length / 3))` (`shared/suggestions.zod.ts`), - // so a 5-character key gets 2, and `hosts`/`hooks` differ by exactly 2. - // Without this line the fallback answers `hosts` -> `hooks`, pointing the - // author at lifecycle hooks on the one block that also grants network - // access. This alias overrides a confident WRONG suggestion rather than - // filling a silent gap, and `manifest-unknown-keys.test.ts` pins that. - hosts: 'network', - }, -}, { - services: z.array(z.string()).optional() - .describe('Platform services the plugin may resolve (e.g. "object", "http")'), - hooks: z.array(z.string()).optional() - .describe('Lifecycle hooks the plugin may register (e.g. "record.beforeInsert")'), - network: z.array(z.string()).optional() - .describe('Network hosts the plugin may reach (e.g. "api.acme.com")'), - fs: z.array(z.string()).optional() - .describe('Filesystem paths the plugin may access'), -}).describe('Structured plugin permission grants (ADR-0025 §3.2)'); +export const PluginPermissionsSchema = closedObject(z.object(PLUGIN_PERMISSIONS_SHAPE, { + error: Object.assign( + (issue: Parameters[0]) => ( + issue.code === 'invalid_type' && Array.isArray(issue.input) + ? PLUGIN_PERMISSIONS_LIST_FORM + : pluginPermissionsUnknownKeyError(issue) + ), + { prime: () => (pluginPermissionsUnknownKeyError as { prime?: () => void }).prime?.() }, + ), +}).strict()).describe('Structured plugin permission grants (ADR-0025 §3.2)'); export type PluginPermissions = z.input; /** - * Backward-compatible manifest `permissions` value: either the legacy flat - * list of permission strings (apps / older packages) or the structured - * plugin permission block above. New code should prefer the structured form. + * A manifest's `permissions` value — the structured plugin permission block + * above, and nothing else. + * + * It used to be a union of that block and a legacy flat list of permission + * strings; the list arm was retired (ADR-0049 enforce-or-remove, ADR-0087 + * conversion `manifest-permissions-string-list-removed`), so this is the block + * itself, by identity. The name survives as the manifest slot's declaration: + * `ManifestSchema.permissions` reads it, and a consumer holding the manifest + * reading imports it under the name it always had. */ -export const ManifestPermissionsSchema = z.union([ - z.array(z.string()), - PluginPermissionsSchema, -]); +export const ManifestPermissionsSchema = PluginPermissionsSchema; export type ManifestPermissions = z.input; @@ -236,9 +297,6 @@ export type PluginIntegrity = z.input; * type: app * name: Acme CRM * description: Customer Relationship Management system - * permissions: - * - system.user.read - * - system.object.create * objects: * - "./src/objects/*.object.yml" * ``` @@ -506,9 +564,14 @@ export const ManifestSchema = strictObject({ /** * Permissions the package requires — the "Scope" requested at installation. * - * Accepts either the legacy flat list of permission strings, or the - * structured plugin permission block ({@link PluginPermissionsSchema}, - * ADR-0025 §3.2) that maps to service / hook / network / fs capabilities. + * Takes the structured plugin permission block ({@link PluginPermissionsSchema}, + * ADR-0025 §3.2) that maps to service / hook / network / fs capabilities — + * and only that block. The legacy flat list of permission strings is retired + * (ADR-0049 enforce-or-remove): a list is refused at parse with its + * prescription, and the ADR-0087 conversion + * `manifest-permissions-string-list-removed` strips it from existing sources, + * stored artifacts and `os migrate meta` output, because no capability + * string maps mechanically onto the four lists. * * ⚠️ **This key carries a DIFFERENT declaration one stage along, and the two * are not compatible.** At this AUTHORING stage `permissions` is the @@ -527,16 +590,15 @@ export const ManifestSchema = strictObject({ * 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 + * it. ⛔ The fix for that collision is never to widen this key 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 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`)'), + .describe('Required permissions at the AUTHORING stage: the structured plugin block { services, hooks, network, fs } (ADR-0025 §3.2) — the legacy flat string list is retired — and 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/migrations/entries/semantic/18.manifest-permissions-string-list-retired.ts b/packages/spec/src/migrations/entries/semantic/18.manifest-permissions-string-list-retired.ts new file mode 100644 index 0000000000..676ef75aff --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.manifest-permissions-string-list-retired.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0049 enforce-or-remove, ruled option A on the plugin-permissions parent +// card (2026-08-30): the legacy flat-list arm of a package manifest's +// `permissions` leaves, and the structured ADR-0025 §3.2 block is the only +// form. The list's deletion is mechanical (the D2 conversion +// `manifest-permissions-string-list-removed`); what each dropped string meant +// in terms of services, hooks, network hosts and filesystem paths is not, and +// that judgement is what this entry carries. +export const entry: SemanticMigration = { + id: 'manifest-permissions-string-list-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code span. + surface: + 'manifest.permissions as a flat list of permission strings (and packages[].manifest.permissions) — ' + + 'the legacy arm of ManifestPermissionsSchema left; the schema is now the structured plugin ' + + 'permission block alone', + replacement: + 'the structured block `permissions: { services, hooks, network, fs }` — each a list naming the ' + + 'platform services the plugin resolves, the lifecycle hooks it registers, the network hosts it ' + + 'reaches and the filesystem paths it touches; or no `permissions` key when the plugin needs none', + reason: + 'ADR-0049 enforce-or-remove: the flat list was parsed and never acted on. The loader registers the ' + + 'consented grant set on the environment artifact with the permission enforcer, never the ' + + 'manifest\'s request, so a list granted, refused and requested nothing at load; the only code that ' + + 'met one was two reports saying it had been skipped. The D2 conversion ' + + '`manifest-permissions-string-list-removed` deletes the list from existing sources and stored ' + + 'artifacts, losslessly for every load. What it cannot do is translate: a capability string such as ' + + '`system.user.read` names no service, hook, host or path, so whether the plugin needs a grant at ' + + 'all, and which, is the author\'s judgement. Authoring now refuses a list at parse with that ' + + 'prescription, and TypeScript rejects it', + acceptanceCriteria: + 'No manifest — the stack\'s own, any packages[] entry, any objectstack.plugin.json — declares ' + + '`permissions` as a list; a list is refused at parse with its prescription, and TypeScript rejects ' + + 'it. Every plugin whose dropped list stood for a real need declares it in the structured block, ' + + 'naming each service, hook, network host and filesystem path it touches, and `os plugin build` ' + + 'parses the manifest clean. A plugin that needs no grant declares no `permissions` key.', + conversionIds: ['manifest-permissions-string-list-removed'], +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index b135be10c9..24365ba6e6 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5821,6 +5821,19 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '`stack.views[]` as a lossless delete, and is retired from the load path, so authors are ' + 'refused at parse rather than rewritten.', }, + { + id: 'manifest-permissions-string-list-retired', + order: 85, + text: + 'It also retires the flat-list form of a package manifest\'s `permissions` (ADR-0049 ' + + 'enforce-or-remove): `ManifestPermissionsSchema` was a union of a list of permission strings ' + + 'and the structured ADR-0025 block `{ services, hooks, network, fs }`, and nothing ever acted ' + + 'on the list — the loader registers the consented grant set, never the manifest\'s request — ' + + 'so the block is now the only form. A list is refused at parse with its prescription, and the ' + + 'D2 conversion `manifest-permissions-string-list-removed` strips it from the stack\'s manifest ' + + 'and every `packages[].manifest` as a lossless delete, retired from the load path; translating ' + + 'what each dropped string meant into the four lists is the author\'s judgement, not a rewrite.', + }, { id: 'mapping-lookup-params-retired', order: 13, @@ -15223,6 +15236,42 @@ const step18: MigrationStep = { + 'package\'s manifest, and no registry listing. If any does, the correct answer is a ' + 'deliberate republish under the new id, not an in-place edit.', }, + // ADR-0049 enforce-or-remove, ruled option A on the plugin-permissions parent + // card (2026-08-30): the legacy flat-list arm of a package manifest's + // `permissions` leaves, and the structured ADR-0025 §3.2 block is the only + // form. The list's deletion is mechanical (the D2 conversion + // `manifest-permissions-string-list-removed`); what each dropped string meant + // in terms of services, hooks, network hosts and filesystem paths is not, and + // that judgement is what this entry carries. + { + id: 'manifest-permissions-string-list-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code span. + surface: + 'manifest.permissions as a flat list of permission strings (and packages[].manifest.permissions) — ' + + 'the legacy arm of ManifestPermissionsSchema left; the schema is now the structured plugin ' + + 'permission block alone', + replacement: + 'the structured block `permissions: { services, hooks, network, fs }` — each a list naming the ' + + 'platform services the plugin resolves, the lifecycle hooks it registers, the network hosts it ' + + 'reaches and the filesystem paths it touches; or no `permissions` key when the plugin needs none', + reason: + 'ADR-0049 enforce-or-remove: the flat list was parsed and never acted on. The loader registers the ' + + 'consented grant set on the environment artifact with the permission enforcer, never the ' + + 'manifest\'s request, so a list granted, refused and requested nothing at load; the only code that ' + + 'met one was two reports saying it had been skipped. The D2 conversion ' + + '`manifest-permissions-string-list-removed` deletes the list from existing sources and stored ' + + 'artifacts, losslessly for every load. What it cannot do is translate: a capability string such as ' + + '`system.user.read` names no service, hook, host or path, so whether the plugin needs a grant at ' + + 'all, and which, is the author\'s judgement. Authoring now refuses a list at parse with that ' + + 'prescription, and TypeScript rejects it', + acceptanceCriteria: + 'No manifest — the stack\'s own, any packages[] entry, any objectstack.plugin.json — declares ' + + '`permissions` as a list; a list is refused at parse with its prescription, and TypeScript rejects ' + + 'it. Every plugin whose dropped list stood for a real need declares it in the structured block, ' + + 'naming each service, hook, network host and filesystem path it touches, and `os plugin build` ' + + 'parses the manifest clean. A plugin that needs no grant declares no `permissions` key.', + conversionIds: ['manifest-permissions-string-list-removed'], + }, // A D3 semantic TODO, not a D2 conversion, and the reason is the widening half. // The mechanical part of this move is trivial in one direction — `01.1.1` // becomes `1.1.1` — but the chain cannot know whether an author who wrote a diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index df494457ab..33d56a7c04 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -471,7 +471,8 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = { * 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 + * (`{ services, hooks, network, fs }`; its legacy flat `string[]` form is + * retired). 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 @@ -1246,7 +1247,7 @@ function assembledPackageBodyShape(): Pick