diff --git a/.changeset/23891-permission-set-managed-by-retired.md b/.changeset/23891-permission-set-managed-by-retired.md new file mode 100644 index 00000000000..e944db41157 --- /dev/null +++ b/.changeset/23891-permission-set-managed-by-retired.md @@ -0,0 +1,40 @@ +--- +'@objectstack/spec': minor +'@objectstack/platform-objects': patch +--- + +feat(spec)!: retire the permission set's authored `managedBy` — a provenance key nothing read (#23891) + +**BREAKING** — an author can no longer write `managedBy` (`'package' | 'platform' | 'user'`) on a permission set (`PermissionSetSchema`, ADR-0086 D3): it is refused at parse. Nothing ever read the authored key. No seeder, package upgrade or lock consulted it, so `package` never made a set upgrade-owned, and `platform` or `user` never kept an upgrade off a set. Delete the key. A set's provenance is the package that ships it, decided from that package's artifact and recorded as `packageId`; a set created in this environment carries neither. The `sys_permission_set.managed_by` column is a different surface and is untouched, as is `ObjectSchema.managedBy`. + +Clause-②: yes (narrowing) + +Measured before removal, at `6d1660dc3b`: no shipped permission set authored the key (the example apps, `plugin-security`'s default sets, and HotCRM 4.0.1's seven sets at `1d7148bf2d`: zero, against seven `objects:` controls). Its one writer was Setup's Permission Sets form row, which wrote it into stored rows. The liveness ledger had ruled it `dead` on 2026-10-11. + +## FROM → TO + +| you wrote (17.7 and earlier) | write instead | +| --- | --- | +| `definePermissionSet({ …, managedBy: 'package' })` | nothing: delete the key; a package-shipped set is the package's because the package ships it (`packageId`) | +| `managedBy: 'platform'` or `managedBy: 'user'` | nothing: delete the key; a set created in this environment carries neither `managedBy` nor `packageId` | +| relying on `managedBy` to keep a package upgrade off a set | clone the packaged set and edit the clone (the packaged-set lock refuses in-place edits) | + +**The one-line fix:** delete `managedBy` from every permission set. + +**What an author who still writes it sees.** `tsc` fails at the authoring site (`PermissionSet` types the key as retired), and the parse (`definePermissionSet()`, `defineStack({ permissions })`, `PUT /api/v1/meta/permission/:name`) refuses it at its own path with the prescription: + +> `permission.managedBy` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0086 D3) — nothing ever read the authored key: no seeder, package upgrade or lock consulted it, so `package` never made a set upgrade-owned and `platform` or `user` never shielded one from an upgrade. Delete the key; a set's provenance is the package that ships it, decided from that package's artifact and recorded as `packageId`, and a set created in this environment carries neither. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. + +**What does not stop on it.** A stored `permission` row that Setup's form wrote, and an installed package's built artifact, still load: the rehydration seams (`applyConversionsToStoredItem`, `applyArtifactForwardConversions`) replay the D2 conversion below and strip the key, with a notice naming the set. Only the authoring, save and publish doors refuse it. + +## The retirement kit + +- **A `retiredKey()` tombstone** on `PermissionSetSchema`, a `strictObject`, so the refusal carries its prescription and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `security/PermissionSet:managedBy`. +- **The D2 conversion `permission-set-managed-by-removed`** (protocol 18, retired from the load path) deletes the key from every `permissions[]` item, whatever it holds, one notice per set. +- **The D3 entry `permission-set-managed-by-retired`** records the judgement the strip cannot make: any runbook that relied on the value to keep an upgrade off a set needs another path. +- **`@objectstack/platform-objects`:** the Permission Sets form's `managedBy` row, its help text and its three option labels (Package / Platform / User) leave Setup in all four locales. +- **No deprecation window**, per the project's startup-stage posture. + +⚠️ **The out-of-repo consumer population is NOT MEASURED** beyond HotCRM 4.0.1. `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. Studio's provenance badge reads the value as one of three inputs; it retires in an objectui follow-up. + + diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 7d7fecc8ba0..0cea9d2391c 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -429,7 +429,7 @@ it was copied from, so changes to the source never reach the copy. - **Every permission facet is copied** — `objects`, `fields`, `systemPermissions`, `tabPermissions`, `rowLevelSecurity`, `description`. - **The server drops a declared set of keys**: the package provenance - (`packageId`, `managedBy`, `protection` and the internal protection envelope), + (`packageId`, `protection` and the internal protection envelope), and two privilege decisions — `adminScope` (delegated administration) and `isDefault` (the `everyone` suggestion). Grant either deliberately on the copy if it needs one. The response, `{ permissionSet, notice }`, says what was not diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index 1f33443fcf9..6bc580aa265 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -118,7 +118,7 @@ const result = AdminScopeSchema.parse(data); | **label** | `string` | optional | Display label | | **description** | `string` | optional | Human-readable description shown in Setup | | **packageId** | `string` | optional | [ADR-0086 D3] Owning package id for a package-shipped set (absent = env-authored); a value that disagrees with the package that ships the set, or that it is saved into, is refused by the stack schema (defineStack, os validate, os build), the package registration, and the metadata save, restore and publish doors | -| **managedBy** | `Enum<'package' \| 'platform' \| 'user'>` | optional | [ADR-0086 D3] Record provenance: package (upgrade-owned metadata) vs platform/user (env config) | +| **managedBy** | `never` | optional | [REMOVED] `permission.managedBy` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0086 D3) — nothing ever read the authored key: no seeder, package upgrade or lock consulted it, so `package` never made a set upgrade-owned and `platform` or `user` never shielded one from an upgrade. Delete the key; a set's provenance is the package that ships it, decided from that package's artifact and recorded as `packageId`, and a set created in this environment carries neither. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **isDefault** | `boolean` | optional (default: `false`) | [ADR-0090 D5] App baseline for the everyone position: app-level sets are auto-bound at boot (guarded, idempotent); package-level sets become install-time suggestions an admin confirms | | **objects** | `Record` | ✅ | Entity permissions | | **fields** | `Record` | optional | Field level security | diff --git a/packages/platform-objects/src/apps/setup-copy-vocabulary.test.ts b/packages/platform-objects/src/apps/setup-copy-vocabulary.test.ts index bfaecac36c4..b84198d3e2d 100644 --- a/packages/platform-objects/src/apps/setup-copy-vocabulary.test.ts +++ b/packages/platform-objects/src/apps/setup-copy-vocabulary.test.ts @@ -181,7 +181,6 @@ describe('Setup copy carries no design-record vocabulary', () => { // the four bundles judges the source too. const FORM_HELP_LEAVES = [ ['permission', 'fields', 'isDefault', 'helpText'], - ['permission', 'fields', 'managedBy', 'helpText'], ['permission', 'fields', 'packageId', 'helpText'], ['permission', 'fields', 'adminScope', 'helpText'], ['position', 'fields', 'delegatable', 'helpText'], @@ -209,7 +208,6 @@ describe('Setup copy carries no design-record vocabulary', () => { const replaced = { permission: { fields: { isDefault: { helpText: 'App baseline for the everyone position (ADR-0090 D5): an app-level set is auto-bound at boot.' }, - managedBy: { helpText: 'Record provenance (ADR-0086 D3): who owns this set across upgrades.' }, packageId: { helpText: 'Owning package id for a package-shipped set (ADR-0086 D3).' }, adminScope: { helpText: 'Delegated administration (ADR-0090 D12): lets holders of this set administer one business-unit subtree.' }, } }, diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 402f5d93224..bd476bb387f 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -2332,10 +2332,6 @@ export const enMetadataForms: NonNullable = { label: "Is Default", helpText: "Gives this set to everyone who signs in, through the built-in everyone position. A set the app itself declares is granted at startup; a set from an installed package becomes a suggestion an admin confirms in Setup. A set with high-privilege permissions is never granted this way. Default false." }, - managedBy: { - label: "Managed By", - helpText: "Who owns this set: Package means it ships with a package and package upgrades update it; Platform and User mean it was created in this environment and upgrades leave it alone." - }, packageId: { label: "Package Id", helpText: "The package that ships this set; a value naming any other package is refused. Leave empty for a set created in this environment." diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 21d15d70ea6..9d949a4f2aa 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -2332,10 +2332,6 @@ export const esESMetadataForms: NonNullable = label: "Predeterminado", helpText: "Concede este conjunto a todos los que inician sesión, mediante la posición integrada everyone. Un conjunto que declara la propia aplicación se concede al arrancar; uno de un paquete instalado se convierte en una sugerencia que un administrador confirma en Setup. Un conjunto con permisos de alto privilegio nunca se concede así. false por defecto." }, - managedBy: { - label: "Gestionado por", - helpText: "Quién es el propietario de este conjunto: Package indica que se distribuye con un paquete y que las actualizaciones del paquete lo modifican; Platform y User indican que se creó en este entorno y que las actualizaciones no lo tocan." - }, packageId: { label: "ID de paquete", helpText: "El paquete que distribuye este conjunto; un valor que nombra otro paquete se rechaza. Déjalo vacío para un conjunto creado en este entorno." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 13ef3a53da3..6ebf3e6bd23 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -2332,10 +2332,6 @@ export const jaJPMetadataForms: NonNullable = label: "既定の権限セット", helpText: "組み込みの everyone ポジションを通じて、サインインするすべてのユーザーにこのセットを付与します。アプリ自身が宣言したセットは起動時に付与され、インストールしたパッケージのセットは管理者が Setup で確認する提案になります。高権限を含むセットはこの方法では付与されません。既定は false です。" }, - managedBy: { - label: "管理元", - helpText: "このセットの所有者です。Package はパッケージに同梱され、パッケージのアップグレードで更新されることを示します。Platform と User はこの環境で作成され、アップグレードで変更されないことを示します。" - }, packageId: { label: "所属パッケージ ID", helpText: "このセットを同梱しているパッケージです。別のパッケージを指す値は拒否されます。この環境で作成したセットでは空のままにします。" diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 363cfdf9ab9..1ecc33d042b 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1199,7 +1199,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 674 since #23809: the options repeaters gain `kind`, the option's // lifecycle category — one row label on the field form, one on the // object form, authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(674); + // 673 since #23891: the permission-set form's `managedBy` select left + // with its key (retired — nothing read the authored provenance), taking + // its label — authored in all three locales — out of the catalog. + expect(translated.length, `${locale} positive control`).toBe(673); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 105c21ad809..94a43feecf3 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -2332,10 +2332,6 @@ export const zhCNMetadataForms: NonNullable = label: "默认权限集", helpText: "通过内置的 everyone 岗位,把这个权限集授予每一位登录用户。应用自身声明的权限集在启动时授予;已安装包中的权限集则成为一条建议,由管理员在 Setup 中确认。含高权限的权限集不会以这种方式授予。默认 false。" }, - managedBy: { - label: "归属来源", - helpText: "这个权限集归谁所有:Package 表示它随包分发,包升级时会更新它;Platform 和 User 表示它在本环境中创建,升级不会改动它。" - }, packageId: { label: "所属包 ID", helpText: "分发这个权限集的包;填写其他包的 id 会被拒绝。在本环境中创建的权限集请留空。" diff --git a/packages/spec/authorable-surface/security.json b/packages/spec/authorable-surface/security.json index f4303f3ffdc..9f8aaab8c40 100644 --- a/packages/spec/authorable-surface/security.json +++ b/packages/spec/authorable-surface/security.json @@ -121,7 +121,7 @@ "security/PermissionSet:fields", "security/PermissionSet:isDefault", "security/PermissionSet:label", - "security/PermissionSet:managedBy", + "security/PermissionSet:managedBy [RETIRED]", "security/PermissionSet:name", "security/PermissionSet:objects", "security/PermissionSet:packageId", diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 13ce1b0b019..1b574d40672 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -30,8 +30,8 @@ }, "managedBy": { "status": "dead", - "verifiedAt": "2026-10-11", - "note": "ADR-0086 D3 — per-record provenance on the metadata-persistence axis (package | platform | user). VERDICT CORRECTED 2026-10-11 (#15204 stage 6b-2): no code reads the AUTHORED key. The cited consumer, bootstrapDeclaredPermissions, stamped `managed_by: 'package'` on every row it wrote and never read `ps.managedBy`, so this row's `live` verdict pointed at a writer of the COLUMN, not a reader of the key; that seeder is now retired (ADR-0131 D3: the boot writes no catalog row). The sys_permission_set.managed_by column is unaffected. Retirement of the authored key is an enforce-or-remove item for the spec seat." + "verifiedAt": "2026-10-16", + "note": "REMOVED 2026-10-16 — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from stored rows and built artifacts by the protocol-18 conversion permission-set-managed-by-removed. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); a set's provenance is the package that ships it, decided from that package's artifact and recorded as packageId. History: ADR-0086 D3 per-record provenance (package | platform | user); VERDICT CORRECTED 2026-10-11 (#15204 stage 6b-2): no code read the AUTHORED key — bootstrapDeclaredPermissions stamped the sys_permission_set.managed_by COLUMN and never read ps.managedBy, and that seeder is retired (ADR-0131 D3). The sys_permission_set.managed_by column is a different surface and is unaffected." }, "adminScope": { "status": "live", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 7ab536dc825..e1537734cf0 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -6874,6 +6874,76 @@ const fieldMalformedScalePrecisionRemoved: MetadataConversion = { }, }; +/** + * Permission-set `managedBy` removed (protocol 18, #23891 — ADR-0049 + * enforce-or-remove; the liveness ledger's `dead` verdict of 2026-10-11). + * + * The key was the AUTHORED per-record provenance of ADR-0086 D3 + * (`package | platform | user`), and nothing ever read it: the retired + * `bootstrapDeclaredPermissions` seeder stamped the `sys_permission_set.managed_by` + * COLUMN and never read `ps.managedBy`, and the packaged-set lock decides + * provenance from the package's artifact. Setup's form row nevertheless + * promised that package upgrades update a `package` set and leave a + * `platform` / `user` set alone. The tombstone on `PermissionSetSchema` + * refuses it at parse (`PERMISSION_SET_MANAGED_BY_REMOVED` in + * `security/permission.zod.ts`). The column is a different surface and is + * untouched. + * + * ## Why a D2 strip + * + * Setup's form row wrote the key into stored `permission` rows, and a 17.x + * build carried whatever an author wrote into its artifact. After the + * tombstone the boot door and the metadata save door refuse such a set, and + * only the D2 table is replayed at the rehydration seams + * (`applyArtifactForwardConversions`, `applyConversionsToStoredItem`), so a + * stored row or an installed package's artifact loads only through this + * entry. The key leaves whatever it holds: the tombstone refuses every value, + * so a strip that kept one would leave a set that cannot load. The strip is + * lossless — no grant, owner or upgrade read the value; what an admin believed + * it did is the D3 record, the semantic entry `permission-set-managed-by-retired`. + * The emitted path names the set, as + * `permission-admin-scope-binding-authoring-removed` does. + */ +const permissionSetManagedByRemoved: MetadataConversion = { + id: 'permission-set-managed-by-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', + surface: 'permission.managedBy', + summary: + "permission-set key 'managedBy' removed (ADR-0049 enforce-or-remove — nothing ever read the authored " + + "provenance: a set's provenance is the package that ships it, decided from that package's artifact and " + + 'recorded as packageId; the sys_permission_set.managed_by column is unaffected. Delete the key)', + apply(stack, emit) { + return mapCollection(stack, 'permissions', (ps, path) => { + // Name the set, not just its index: the notice is the only record an + // upgrading author gets of WHICH set lost the key. + const where = typeof ps.name === 'string' ? `${path}(${ps.name})` : path; + return stripKeys(ps, ['managedBy'], emit, where); + }); + }, + fixture: { + before: { + permissions: [ + // What Setup's form row wrote into a stored row. + { name: 'crm_sales_rep', label: 'Sales Rep', packageId: 'crm', managedBy: 'package' }, + { name: 'env_reviewer', label: 'Env Reviewer', managedBy: 'user' }, + // Already canonical: rides through untouched, by reference. + { name: 'env_reader', label: 'Env Reader' }, + ], + }, + after: { + permissions: [ + { name: 'crm_sales_rep', label: 'Sales Rep', packageId: 'crm' }, + { name: 'env_reviewer', label: 'Env Reviewer' }, + { name: 'env_reader', label: 'Env Reader' }, + ], + }, + // One per set carrying the key. + expectedNotices: 2, + }, +}; + /** * `record:chatter` / `record:discussion` `position` converges on the * renderer's vocabulary (protocol 18, #8762 — maintainer ruling 2026-08-15: @@ -16836,6 +16906,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: permissionAdminScopeBindingAuthoringRemoved, order: 65 }, { conversion: permissionAllowRestorePurgeRemoved, order: 16 }, { conversion: permissionRlsTagsRemoved, order: 42 }, + { conversion: permissionSetManagedByRemoved, order: 67 }, { conversion: recordChatterPositionVocabulary, order: 2 }, { conversion: recordHighlightsFieldIconRemoved, order: 10 }, { conversion: reportJoinedChartRemoved, order: 38 }, diff --git a/packages/spec/src/migrations/entries/retired-keys/18.security__PermissionSet__managedBy.ts b/packages/spec/src/migrations/entries/retired-keys/18.security__PermissionSet__managedBy.ts new file mode 100644 index 00000000000..f430a9ebc14 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.security__PermissionSet__managedBy.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #23891 (ADR-0049 enforce-or-remove; the liveness ledger's `dead` verdict of +// 2026-10-11). `PermissionSet.managedBy` — the authored per-record provenance +// `package | platform | user` of ADR-0086 D3 — was never read: the retired +// `bootstrapDeclaredPermissions` seeder stamped the `sys_permission_set.managed_by` +// COLUMN without reading the key, and the packaged-set lock decides provenance +// from the package's artifact. Setup's form row and its labels promised upgrade +// behaviour nothing kept. The set shape is `strictObject`, so the route is the +// `retiredKey()` tombstone: the key stays in the walked shape as `[RETIRED]`, +// and authoring it is a tsc error and a parse error carrying the prescription. +// The column is a different surface and is untouched. D2: +// `permission-set-managed-by-removed`; D3: `permission-set-managed-by-retired`. +export const entry = 'security/PermissionSet:managedBy'; diff --git a/packages/spec/src/migrations/entries/semantic/18.permission-set-managed-by-retired.ts b/packages/spec/src/migrations/entries/semantic/18.permission-set-managed-by-retired.ts new file mode 100644 index 00000000000..e43f5d1d510 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.permission-set-managed-by-retired.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #23891 (ADR-0049 enforce-or-remove) — the D3 entry of the +// `permission-set-managed-by-removed` family (one D3 entry per retirement +// family, even when D2 is lossless). Registered key: +// `security/PermissionSet:managedBy`. The strip changes no access decision and +// no upgrade; what it leaves is whatever an admin or author believed the value +// did. +export const entry: SemanticMigration = { + id: 'permission-set-managed-by-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: 'permissions[].managedBy — the authored per-record provenance (package / platform / user) on a ' + + 'permission set', + replacement: 'Nothing: delete the key. A set\'s provenance is the package that ships it, decided from that ' + + 'package\'s artifact and recorded as packageId; a set created in this environment carries neither. The ' + + 'sys_permission_set.managed_by column the platform keeps is a different surface and is unchanged.', + reason: 'The D2 conversion `permission-set-managed-by-removed` deletes `managedBy` from every permission set ' + + 'in author sources, stored permission rows and built artifacts, and the delete is lossless: no seeder, ' + + 'package upgrade, lock or gate ever read the authored key, so no set changes who owns it, what an ' + + 'upgrade does to it, or what it grants. The judgment is about what people believed. Setup\'s form offered ' + + 'Package (upgrade-owned metadata), Platform and User (environment config), and its help text said a ' + + 'Package set is updated by package upgrades while a Platform or User set is left alone. Neither was ' + + 'true: an upgrade updates the sets its package ships, whatever the value; a set marked Package that no ' + + 'package ships was never updated; and a package-shipped set marked Platform or User was never shielded. ' + + 'Any runbook or customization plan that relied on the value to keep an upgrade off a set needs another ' + + 'path — for a packaged set, clone it and edit the clone.', + acceptanceCriteria: 'No authored or stored permission set carries `managedBy`; the parse refuses the key ' + + 'with the prescription. Every set\'s grants are unchanged. Every customization that relied on ' + + 'managedBy to survive a package upgrade lives on a clone of the packaged set, not on the set itself.', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 7bec8dfdf2d..a3e93b7a2ee 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -2592,6 +2592,20 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'at parse rather than rewritten. Its D3 record is the semantic entry ' + '`permission-rls-tags-retired`.', }, + { + id: 'permission-set-managed-by-retired', + order: 99, + text: + 'It also retires the permission set\'s authored `managedBy` (ADR-0049 enforce-or-remove; ADR-0086 D3): ' + + 'the per-record provenance `package` / `platform` / `user`, which nothing ever read — the retired ' + + 'seeder stamped the `sys_permission_set.managed_by` column without reading the key, and the ' + + 'packaged-set lock decides provenance from the package\'s artifact — while Setup\'s form promised ' + + 'that package upgrades update a `package` set and leave the others alone. It is a `retiredKey()` ' + + 'tombstone on `PermissionSetSchema`, its form row leaves Setup, and the D2 conversion ' + + '`permission-set-managed-by-removed` strips it from authored sets, stored rows and built artifacts, ' + + 'retired from the load path. The column is a different surface and is unchanged. Its D3 record is ' + + 'the semantic entry `permission-set-managed-by-retired`.', + }, { id: 'platform-global-object-organization-column-retired', order: 89, diff --git a/packages/spec/src/security/permission-set-managed-by-retirement.test.ts b/packages/spec/src/security/permission-set-managed-by-retirement.test.ts new file mode 100644 index 00000000000..83383ff9698 --- /dev/null +++ b/packages/spec/src/security/permission-set-managed-by-retirement.test.ts @@ -0,0 +1,470 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The permission set's authored `managedBy` RETIRED (#23891) — ADR-0049 + * enforce-or-remove, on the liveness ledger's `dead` verdict of 2026-10-11. + * + * The key was the authored per-record provenance of ADR-0086 D3 + * (`package | platform | user`). Measured before removal: no code read it + * (the retired `bootstrapDeclaredPermissions` seeder stamped the + * `sys_permission_set.managed_by` COLUMN without reading the key, and the + * packaged-set lock decides provenance from the package's artifact); no + * shipped set authored it (the example apps, `plugin-security`'s default sets, + * HotCRM 4.0.1's seven sets); its one writer was Setup's form row, into stored + * rows. The COLUMN is a different surface and is untouched. + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone on `PermissionSetSchema`, a `strictObject`, + * so the refusal carries its prescription and the key's input type is the + * retired mark for `tsc`. + * 2. D2 conversion `permission-set-managed-by-removed` (step 18), a delete of + * the key from every `permissions[]` item, retired from the load path: a + * live author is refused, a stored row and a built artifact replay clean — + * so no kernel built from an installed package or a stored row stops on it. + * 3. `RETIRED_KEYS_BY_MAJOR[18]` carries the key; the family's D3 entry is + * `permission-set-managed-by-retired`. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues + * carry `code` and `path` but no ADR-0112 `status`; that envelope belongs to + * the authoring door, `defineStack`, pinned with its `code` and `status`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { applyConversions, collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { defineStack, ObjectStackDefinitionSchema } from '../stack.zod'; +import { permissionForm } from './permission.form'; +import { PermissionSetSchema, type PermissionSet } from './permission.zod'; + +const KEY = 'managedBy'; +const CONVERSION_ID = 'permission-set-managed-by-removed'; +const D3_ID = 'permission-set-managed-by-retired'; + +/** A well-formed set (`objects` is the set's one required key). */ +const SET = { name: 'crm_sales_rep', label: 'Sales Rep', objects: { account: { allowRead: true } } } as const; + +// Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; +// the key-first house convention is asserted on the issue message itself. +const PRESCRIPTION = + /`permission\.managedBy` was removed in @objectstack\/spec 18 \(ADR-0049 enforce-or-remove, ADR-0086 D3\) — nothing ever read the authored key.*Delete the key; a set's provenance is the package that ships it.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\./s; + +describe('permission-set managedBy retirement — the tombstone, at every door that carries a set', () => { + it('the set refuses `managedBy` at its path, with the prescription', () => { + const r = PermissionSetSchema.safeParse({ ...SET, managedBy: 'package' }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual([KEY]); + expect(issue.message).toMatch(PRESCRIPTION); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue.message.startsWith('`permission.managedBy` was removed')).toBe(true); + }); + + it('refuses EVERY value — each of the three it used to accept included', () => { + for (const value of ['package', 'platform', 'user', 'admin', null, true]) { + const r = PermissionSetSchema.safeParse({ ...SET, managedBy: value }); + expect(r.success, JSON.stringify(value)).toBe(false); + if (r.success) continue; + expect(r.error.issues.map((i) => i.path)).toEqual([[KEY]]); + expect(r.error.issues[0]!.message).toMatch(PRESCRIPTION); + } + }); + + it('the `permission` write door (the registry binding) refuses it at its path', () => { + // `getMetadataTypeSchema('permission')` is what `saveMetaItem` validates a + // `PUT /api/v1/meta/permission` body against — the door Setup's form and + // its Source tab save through. + const door = getMetadataTypeSchema('permission'); + expect(door, 'no schema bound for `permission`').toBeDefined(); + expect(door).toBe(PermissionSetSchema); + const r = door!.safeParse({ ...SET, managedBy: 'user' }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.map((i) => i.path)).toEqual([[KEY]]); + }); + + it('the authoring door, defineStack, refuses it with the STACK_SCHEMA_INVALID envelope — never strips it', () => { + const stack = (set: Record) => ({ + manifest: { id: 'com.example.managed-by-retired', name: 'managed_by_retired', version: '1.0.0', type: 'app' }, + permissions: [set], + }); + let thrown: unknown; + try { + defineStack(stack({ ...SET, managedBy: 'package' }) as never); + } catch (e) { + thrown = e; + } + const refusal = thrown as { code?: string; status?: number; issues?: Array<{ path: PropertyKey[]; message: string }> }; + expect(refusal?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refusal?.status).toBe(422); + expect(refusal.issues?.map((i) => i.path)).toEqual([['permissions', 0, KEY]]); + expect(refusal.issues?.[0]?.message).toMatch(PRESCRIPTION); + // CONTROL: the same stack without the key is accepted by the same door. + expect(() => defineStack(stack({ ...SET }) as never)).not.toThrow(); + }); + + it('CONTROL: the same set without the key passes and keeps its live provenance key', () => { + const r = PermissionSetSchema.safeParse({ ...SET, packageId: 'crm' }); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data).not.toHaveProperty(KEY); + expect(r.data.packageId).toBe('crm'); + }); + + it('the walked shape keeps the key — the authorable-surface row stays reachable as RETIRED', () => { + const shape = (PermissionSetSchema as unknown as { shape?: Record }).shape; + expect(shape, 'PermissionSetSchema must expose a shape').toBeDefined(); + expect(Object.keys(shape!)).toContain(KEY); + expect(Object.keys(shape!), 'CONTROL: a live neighbour').toContain('packageId'); + }); + + it('fails tsc at the authoring site: the input type of the key is the retired mark', () => { + const authored: PermissionSet = { + ...SET, + // @ts-expect-error — `managedBy` is a retiredKey() tombstone: no value is assignable. + managedBy: 'package', + }; + // The parse channel agrees with the type channel on the same literal. + expect(() => PermissionSetSchema.parse(authored)).toThrow(PRESCRIPTION); + }); + + it("Setup's permission-set form offers no control for it — and keeps its live neighbours", () => { + const fields = (permissionForm.sections ?? []).flatMap((s) => (s.fields ?? []).map((f) => (typeof f === 'string' ? f : f.field))); + expect(fields).not.toContain(KEY); + // CONTROL: the rows that sat beside it are still there. + expect(fields).toContain('packageId'); + expect(fields).toContain('isDefault'); + }); +}); + +describe('permission-set managedBy retirement — the D2 conversion (no stored row or installed package stops a kernel on it)', () => { + it('a STORED `permission` row carrying the key replays clean through the rehydration seam', () => { + // What Setup's form row wrote: the seam wraps a `permission` row as + // `{ permissions: [row] }` and replays the full chain, retired entries included. + const notices: { conversionId?: string; path?: string; from?: string; to?: string }[] = []; + const rehydrated = applyConversionsToStoredItem('permission', { ...SET, managedBy: 'platform' }, { + onNotice: (n) => notices.push(n as { conversionId?: string; path?: string; from?: string; to?: string }), + }) as Record; + + expect(notices.map((n) => [n.conversionId, n.from, n.to])).toEqual([[CONVERSION_ID, KEY, '(removed)']]); + // The notice names the set, not just its index. + expect(notices[0]!.path).toContain('(crm_sales_rep).managedBy'); + // CONTROL: every live key on the same row survives byte-for-byte. + expect(rehydrated).toEqual(SET); + // And the rehydrated row is exactly what the write door accepts now. + expect(PermissionSetSchema.safeParse(rehydrated).success).toBe(true); + }); + + it("an installed package's artifact is REFUSED at the boot door before the conversion and ACCEPTED after it", () => { + // The package ships the set: its manifest id is the set's `packageId`. + const artifact = () => ({ + manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app' }, + permissions: [{ ...SET, packageId: 'com.example.crm', managedBy: 'package' }], + }); + const before = ObjectStackDefinitionSchema.safeParse(artifact()); + expect(before.success).toBe(false); + expect(JSON.stringify(before.error?.issues ?? [])).toContain('`permission.managedBy` was removed'); + + // `includeRetired: true` is what `applyArtifactForwardConversions` replays. + const healed = applyConversions(artifact(), { includeRetired: true }); + const after = ObjectStackDefinitionSchema.safeParse(healed); + expect(after.success, `expected the converted artifact to parse; got ${JSON.stringify(after.error?.issues ?? [])}`).toBe(true); + + // LIT CONTROL: a set that was always wrong is refused on BOTH sides, so + // the leg above is a reading and not a tautology. + const wrong = () => ({ permissions: [{ ...SET, managedBy: 'package', managedByz: 'package' }] }); + expect(ObjectStackDefinitionSchema.safeParse(applyConversions(wrong(), { includeRetired: true })).success).toBe(false); + }); + + it('strips the key whatever it holds — one notice per set — leaves the rest, and is idempotent', () => { + const input = { + permissions: [ + { ...SET, name: 'a_one', managedBy: 'package' }, + { ...SET, name: 'b_two', managedBy: 'whatever' }, + // Already canonical: no notice, handed back by reference. + { ...SET, name: 'c_three', packageId: 'crm' }, + ], + }; + const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); + const mine = notices.filter((n) => n.conversionId === CONVERSION_ID); + expect(mine).toHaveLength(2); + expect(mine.every((n) => typeof n.path === 'string' && /\)\.managedBy$/.test(n.path))).toBe(true); + const permissions = stack.permissions as Array>; + for (const set of permissions) expect(set).not.toHaveProperty(KEY); + expect(permissions[2]).toBe(input.permissions[2]); + + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — a live author is refused at parse, never silently rewritten', () => { + const input = { permissions: [{ ...SET, managedBy: 'package' }] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toEqual(input); + }); +}); + +describe('permission-set managedBy retirement — ADR-0087 registration', () => { + it('declares the key under major 18, once', () => { + const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('security/PermissionSet:managedBy'); + expect(all.filter((k) => k === 'security/PermissionSet:managedBy')).toHaveLength(1); + }); + + it('wires the D2 conversion into the step-18 chain as a retired, stamped, lossless strip', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion, 'the D2 conversion must be registered').toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.retiredAfter).toMatch(/^\d+\.\d+\.\d+$/); + expect(conversion!.surface).toBe('permission.managedBy'); + }); + + it('carries ONE D3 entry for the family, naming its D2 conversion', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries, 'the family needs its own D3 entry').toHaveLength(1); + const [entry] = entries; + expect(entry!.reason).toContain(`\`${CONVERSION_ID}\``); + expect(entry!.replacement).toContain('delete the key'); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper: `retiredKey()` types the key as the retired +// mark on `PermissionSetSchema`'s input, so every TYPED authoring site fails to +// compile. The residue is what `tsc` never judges: JSON, YAML, MD/MDX code +// fences, untyped `.js`, and TS literals typed `any` / `unknown` / a record. +// This walk covers that residue across the five repo roots +// `scripts/cross-package-test-inputs.mjs` already declares for +// `@objectstack/spec#test` (mirrored in `turbo.json`), plus the example apps' +// own `src/` trees, declared there as `examples/*/src/**/*.ts`. +// +// Unlike a distinctive key, `managedBy` is also `ObjectSchema.managedBy`, which +// is LIVE, so a key-position match alone judges nothing. The matcher therefore +// reads the object literal a `managedBy` key sits in and flags it only when +// that same literal also carries a key ONLY a permission set declares +// (`objects`, `systemPermissions`, `tabPermissions`, `rowLevelSecurity`, +// `adminScope`). The bound, stated: a permission-set literal that carries +// none of those keys (a bare `{ name, label, managedBy }`), a key under a +// computed name or assembled by spread, and `docs/**`, `.claude/**`, +// `.github/**`, `.changeset/**` and the repo-root files are outside what this +// walk sees — `tsc` and the parse doors own those. +describe('tree-scoped absence: nothing inside the declared radius still authors a permission set with managedBy', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + /** A key only a permission set declares — the literal's discriminator. */ + const SET_ONLY_KEYS = new Set(['objects', 'systemPermissions', 'tabPermissions', 'rowLevelSecurity', 'adminScope']); + const MANAGED_BY_KEY = /(^|[\s{,(])["']?managedBy["']?\s*:(?!\s*retiredKey\()/g; + const KEY_AT = /["']?([A-Za-z_$][\w$]*)["']?\s*:/y; + + /** + * Code with string and comment CONTENTS blanked (same length, so offsets + * hold). A quoted string followed by `:` is a key and is kept — that is how + * JSON and quoted TS keys stay visible. A template literal is blanked whole. + */ + const maskCode = (src: string): string => { + const out = src.split(''); + const blank = (from: number, to: number) => { + for (let k = from; k < to; k += 1) if (out[k] !== '\n') out[k] = ' '; + }; + let i = 0; + while (i < src.length) { + const c = src[i]!; + const next = src[i + 1]; + if (c === '/' && next === '/') { + const end = src.indexOf('\n', i); + const stop = end === -1 ? src.length : end; + blank(i, stop); + i = stop; + } else if (c === '/' && next === '*') { + const end = src.indexOf('*/', i + 2); + const stop = end === -1 ? src.length : end + 2; + blank(i, stop); + i = stop; + } else if (c === '"' || c === "'" || c === '`') { + let j = i + 1; + while (j < src.length && src[j] !== c && (c === '`' || src[j] !== '\n')) j += src[j] === '\\' ? 2 : 1; + const close = Math.min(j, src.length - 1); + const isKey = c !== '`' && /^\s*:/.test(src.slice(close + 1, close + 40)); + if (!isKey) blank(i + 1, close); + i = close + 1; + } else { + i += 1; + } + } + return out.join(''); + }; + + /** The keys at depth 1 of the object literal enclosing `at` (masked text), or none. */ + const siblingKeys = (masked: string, at: number): Set => { + let depth = 0; + let open = -1; + for (let k = at; k >= 0; k -= 1) { + const ch = masked[k]; + if (ch === '}' || ch === ']') depth += 1; + else if (ch === '{' || ch === '[') { + if (depth === 0) { + if (ch === '{') open = k; + break; + } + depth -= 1; + } + } + const keys = new Set(); + if (open === -1) return keys; + depth = 0; + let expectKey = true; + for (let k = open + 1; k < masked.length; k += 1) { + const ch = masked[k]!; + if (ch === '{' || ch === '[' || ch === '(') depth += 1; + else if (ch === '}' || ch === ']' || ch === ')') { + if (depth === 0) break; + depth -= 1; + } else if (depth === 0 && ch === ',') expectKey = true; + else if (depth === 0 && expectKey && !/\s/.test(ch)) { + KEY_AT.lastIndex = k; + const m = KEY_AT.exec(masked); + if (m) keys.add(m[1]!); + expectKey = false; + } + } + return keys; + }; + + const judgeCode = (text: string): boolean => { + const masked = maskCode(text); + for (const m of masked.matchAll(MANAGED_BY_KEY)) { + const keys = siblingKeys(masked, m.index! + m[1]!.length); + if ([...keys].some((k) => SET_ONLY_KEYS.has(k))) return true; + } + return false; + }; + + const FENCE = /^```([\w-]*)[^\n]*\n([\s\S]*?)^```/gm; + const judge = (text: string, ext: string): boolean => { + if (ext === '.md' || ext === '.mdx') { + for (const m of text.matchAll(FENCE)) if (judgeCode(m[2]!)) return true; + return false; + } + return judgeCode(text); + }; + + /** + * Structural exclusions, each with its reason. ⛔ NOT an allowlist file + * (`spec-property-retirement` §4): every entry's JOB is to spell the key. + */ + const EXCLUDED = new Set([ + // This pin authors the key to assert its refusal and its conversion. + THIS_FILE, + // The declaration: the tombstone. + 'packages/spec/src/security/permission.zod.ts', + // The liveness ledger keeps the tombstoned key's row (`dead`, REMOVED + // note), beside the rows of the set's live keys — that is its job. + 'packages/spec/liveness/permission.json', + // The clone door's pin hands the door a pre-retirement packaged body and + // asserts that the copy carries none of the package-authoring keys. + 'packages/runtime/src/domains/permission-set-clone.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement set on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output, reached only because this is a FILESYSTEM walk. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises a permission-set authoring and ignores an object, a mention and the tombstone (anti-vacuity)', () => { + // Offenders — the retired shape, in each syntax the walk reads. + expect(judge("definePermissionSet({ name: 'a', objects: {}, managedBy: 'package' })", '.ts')).toBe(true); + expect(judge('{ "name": "a", "managedBy": "user", "systemPermissions": [] }', '.json')).toBe(true); + expect(judge("Prose.\n\n```ts\nconst s = { managedBy: 'platform', objects: { a: { allowRead: true } } };\n```\n", '.mdx')).toBe(true); + // Neighbours that must NOT match: the LIVE object key, the tombstone, + // comments, strings, prose, and a permission set's key one level down. + expect(judge("defineObject({ name: 'crm_lead', managedBy: 'platform', fields: {} })", '.ts')).toBe(false); + expect(judge("const s = { objects: { crm_lead: { name: 'crm_lead', managedBy: 'platform' } } };", '.ts')).toBe(false); + expect(judge('managedBy: retiredKey(PERMISSION_SET_MANAGED_BY_REMOVED),', '.ts')).toBe(false); + expect(judge("// it once wrote { objects: {}, managedBy: 'package' }\nconst x = 1;", '.ts')).toBe(false); + expect(judge("const s = \"{ objects: {}, managedBy: 'package' }\";", '.ts')).toBe(false); + expect(judge('Delete `managedBy` from every permission set.', '.mdx')).toBe(false); + expect(judge('"security/PermissionSet:managedBy [RETIRED]",', '.json')).toBe(false); + }); + + it('no permission set inside the declared radius authors managedBy outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + let securitySources = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + if (rel.startsWith('packages/plugins/plugin-security/src/')) securitySources += 1; + const text = readIfPresent(full); + if (text === undefined || !text.includes(KEY)) continue; + if (judge(text, ext)) offenders.push(rel); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree, the example apps' + // sources were really read (the showcase's permission sets among them), + // and plugin-security — which ships the default sets — was really reached. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(securitySources).toBeGreaterThan(50); + expect(offenders, 'a permission set authoring managedBy means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/security/permission.form.ts b/packages/spec/src/security/permission.form.ts index b1071413413..a48ef1a3bb9 100644 --- a/packages/spec/src/security/permission.form.ts +++ b/packages/spec/src/security/permission.form.ts @@ -34,26 +34,21 @@ export const permissionForm = defineForm({ { field: 'label', colSpan: 1, helpText: 'Display label for admins' }, // #19331 — four declared keys had no control, so the only door to any of // them was the Source tab's free-text JSON. `description` is persisted on - // sys_permission_set and shown in Setup; the other three are the ADR-0086 - // D3 provenance pair plus the ADR-0090 D5 baseline flag. The help texts - // are an admin's words; the decision records stay here: + // sys_permission_set and shown in Setup; of the other three, `packageId` + // (ADR-0086 D3) and the ADR-0090 D5 baseline flag keep their rows. The + // help texts are an admin's words; the decision records stay here: // - `isDefault` (ADR-0090 D5): an app-level set is auto-bound to the // built-in `everyone` position at boot, after the high-privilege check; // a package-level set materializes a `sys_audience_binding_suggestion` // an admin confirms in Setup. - // - `managedBy` (ADR-0086 D3): `package` rows are re-seeded on upgrade; - // `platform` / `user` rows are environment config package seeding - // never touches. + // - `managedBy` left this form with its key (#23891, ADR-0049 + // enforce-or-remove): nothing read the authored value, so its labels + // promised upgrade behaviour no code kept. // - `packageId` (ADR-0086 D3): a value that disagrees with the package // the set ships in or is saved into is refused // (`judgePermissionSetOwnerBinding`). { field: 'description', type: 'textarea', colSpan: 2, helpText: 'Human-readable description shown in Setup.' }, { field: 'isDefault', type: 'boolean', colSpan: 1, helpText: 'Gives this set to everyone who signs in, through the built-in everyone position. A set the app itself declares is granted at startup; a set from an installed package becomes a suggestion an admin confirms in Setup. A set with high-privilege permissions is never granted this way. Default false.' }, - { field: 'managedBy', type: 'select', colSpan: 1, helpText: 'Who owns this set: Package means it ships with a package and package upgrades update it; Platform and User mean it was created in this environment and upgrades leave it alone.', options: [ - { label: 'Package (upgrade-owned metadata)', value: 'package' }, - { label: 'Platform (environment config)', value: 'platform' }, - { label: 'User (environment config)', value: 'user' }, - ] }, { field: 'packageId', type: 'text', colSpan: 2, helpText: 'The package that ships this set; a value naming any other package is refused. Leave empty for a set created in this environment.' }, ], }, diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index dbb898c02e4..e74ddbdc2f7 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -668,6 +668,18 @@ const ADMIN_SCOPE_AUTHOR_ENVIRONMENT_SETS_REMOVED = + '`includeSubtree`, `manageAssignments`, and `assignablePermissionSets`. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; +/** + * [#23891] The prescription for the retired authored key + * `PermissionSetSchema.managedBy` — see the tombstone on that schema. + */ +const PERMISSION_SET_MANAGED_BY_REMOVED = + '`permission.managedBy` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0086 D3) — ' + + 'nothing ever read the authored key: no seeder, package upgrade or lock consulted it, so `package` never made ' + + 'a set upgrade-owned and `platform` or `user` never shielded one from an upgrade. Delete the key; a set\'s ' + + 'provenance is the package that ships it, decided from that package\'s artifact and recorded as `packageId`, ' + + 'and a set created in this environment carries neither. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + /** * [ADR-0090 D12] Delegated-administration scope. * @@ -885,8 +897,7 @@ export const PermissionSetSchema = lazySchema(() => strictObject( /** * [ADR-0086 D3] Owning package for a package-shipped permission set * (absent ⇒ environment-authored). Persisted on `sys_permission_set` - * records together with the per-record `managedBy` provenance, this is - * what makes the metadata↔config boundary machine-checkable and package + * records, this is what makes the metadata↔config boundary machine-checkable and package * uninstall well-defined (remove the package's own sets). * * The owner is the package that ships the set or that it is saved into: @@ -903,16 +914,19 @@ export const PermissionSetSchema = lazySchema(() => strictObject( packageId: z.string().optional().describe('[ADR-0086 D3] Owning package id for a package-shipped set (absent = env-authored); a value that disagrees with the package that ships the set, or that it is saved into, is refused by the stack schema (defineStack, os validate, os build), the package registration, and the metadata save, restore and publish doors'), /** - * [ADR-0086 D3] Per-record provenance on the existing - * metadata-persistence axis (`package | platform | user`): - * `package` = versioned package metadata (seeded by - * `bootstrapDeclaredPermissions`, re-seeded on upgrade, read-mostly for - * admins per ADR-0010); `platform`/`user` = environment config, live-edited - * and never touched by package seeding. Distinct from the `sys_permission_set` - * TABLE's object-affordance `managedBy: 'config'`, which stays. + * REMOVED (#23891, ADR-0049 enforce-or-remove): the authored per-record + * provenance `package | platform | user` (ADR-0086 D3). No code ever read + * the AUTHORED key — the retired `bootstrapDeclaredPermissions` seeder + * stamped the `sys_permission_set.managed_by` COLUMN and never read + * `ps.managedBy`, and the packaged-set lock decides provenance from the + * package's artifact — so the key promised upgrade behaviour nothing kept. + * The column is a different surface and is untouched, as is the + * `sys_permission_set` TABLE's object-affordance `managedBy: 'config'`. + * The ADR-0087 D2 conversion `permission-set-managed-by-removed` strips the + * key from stored rows and built artifacts; the D3 record is the semantic + * entry `permission-set-managed-by-retired`. */ - managedBy: z.enum(['package', 'platform', 'user']).optional() - .describe('[ADR-0086 D3] Record provenance: package (upgrade-owned metadata) vs platform/user (env config)'), + managedBy: retiredKey(PERMISSION_SET_MANAGED_BY_REMOVED), /** * [ADR-0090 D5] Marks this set as the app's baseline for the built-in diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 68acb129805..a4f424dcd2f 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -86,7 +86,7 @@ import { } from '../automation/builtin-node-config.zod'; import { NotifyConfigSchema } from '../automation/io-node-config.zod'; import { ScriptConfigSchema, SubflowConfigSchema } from '../automation/schemaless-node-config.zod'; -import { AdminScopeSchema } from '../security/permission.zod'; +import { AdminScopeSchema, PermissionSetSchema } from '../security/permission.zod'; import { SharingLevel } from '../security/sharing.zod'; import { DatasourceSchema } from '../data/datasource.zod'; import { PageComponentSchema, PageSchema } from '../ui/page.zod'; @@ -247,6 +247,7 @@ const JOIN: Readonly> = { tombstone(AdminScopeSchema, 'permission.adminScope', 'manageBindings'), tombstone(AdminScopeSchema, 'permission.adminScope', 'authorEnvironmentSets'), ], + 'permission-set-managed-by-removed': [tombstone(PermissionSetSchema, 'permission', 'managedBy')], 'manifest-permissions-string-list-removed': [ refusal('manifest.permissions as a list of strings', () => ManifestPermissionsSchema.safeParse(['system.user.read'])), ], diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 9c6c92742bf..1903b6bd6d4 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -45,6 +45,7 @@ "src/kernel/metadata-type-registry-allow-org-override-absence.test.ts", "src/qa/requires-plugins-retirement.test.ts", "src/security/admin-scope-binding-authoring-retirement.test.ts", + "src/security/permission-set-managed-by-retirement.test.ts", "src/security/rls-tags-retirement.test.ts", "src/shared/retired-key-migrate-sentence.test.ts", "src/system/compliance-families-retirement.test.ts",