Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/23891-permission-set-managed-by-retired.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: registered permission-set-managed-by-removed, permission-set-managed-by-retired -->
2 changes: 1 addition & 1 deletion content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/security/permission.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { allowCreate?: boolean; allowRead?: boolean; allowEdit?: boolean; allowDelete?: boolean; … }>` | ✅ | Entity permissions |
| **fields** | `Record<string, { readable?: boolean; editable?: boolean }>` | optional | Field level security |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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.' },
} },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2332,10 +2332,6 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2332,10 +2332,6 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2332,10 +2332,6 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "既定の権限セット",
helpText: "組み込みの everyone ポジションを通じて、サインインするすべてのユーザーにこのセットを付与します。アプリ自身が宣言したセットは起動時に付与され、インストールしたパッケージのセットは管理者が Setup で確認する提案になります。高権限を含むセットはこの方法では付与されません。既定は false です。"
},
managedBy: {
label: "管理元",
helpText: "このセットの所有者です。Package はパッケージに同梱され、パッケージのアップグレードで更新されることを示します。Platform と User はこの環境で作成され、アップグレードで変更されないことを示します。"
},
packageId: {
label: "所属パッケージ ID",
helpText: "このセットを同梱しているパッケージです。別のパッケージを指す値は拒否されます。この環境で作成したセットでは空のままにします。"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2332,10 +2332,6 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "默认权限集",
helpText: "通过内置的 everyone 岗位,把这个权限集授予每一位登录用户。应用自身声明的权限集在启动时授予;已安装包中的权限集则成为一条建议,由管理员在 Setup 中确认。含高权限的权限集不会以这种方式授予。默认 false。"
},
managedBy: {
label: "归属来源",
helpText: "这个权限集归谁所有:Package 表示它随包分发,包升级时会更新它;Platform 和 User 表示它在本环境中创建,升级不会改动它。"
},
packageId: {
label: "所属包 ID",
helpText: "分发这个权限集的包;填写其他包的 id 会被拒绝。在本环境中创建的权限集请留空。"
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/authorable-surface/security.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/liveness/permission.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 71 additions & 0 deletions packages/spec/src/conversions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Loading
Loading