Skip to content

Commit bbdfc05

Browse files
feat(spec)!: retire the permission set's authored managedBy, a provenance key nothing read (#23989)
Fixes #23891 Clause-②: yes Retires the AUTHORED key `PermissionSetSchema.managedBy` (remedy 1 of the card; the liveness ledger's `dead` verdict of 2026-10-11, ADR-0049 enforce-or-remove) with the full `spec-property-retirement` kit. The key's Setup form row, its help text, its three option labels and their translations in four locales leave with it. The `sys_permission_set.managed_by` COLUMN, its seeders and the ownership ceremony are untouched, and so is `ObjectSchema.managedBy`. ## What changes for an author - `managedBy` on a permission set is a `retiredKey()` tombstone: `tsc` fails at the authoring site, and `definePermissionSet()`, `defineStack({ permissions })` and `PUT /api/v1/meta/permission/:name` refuse it at its own path with the prescription (delete the key; a set's provenance is the package that ships it, decided from that package's artifact and recorded as `packageId`). - A stored `permission` row (what Setup's form row wrote) and an installed package's built artifact still load. The rehydration seams (`applyConversionsToStoredItem`, `applyArtifactForwardConversions`) replay the D2 conversion `permission-set-managed-by-removed` and strip the key, with a notice that names the set. Only the authoring, save and publish doors refuse it. This is the existing ADR-0087 mechanism (`retiredFromLoadPath: true`, replayed with `includeRetired` at the data-at-rest seams), so no kernel built from an installed package or a stored row stops on it. #23904 landed after this head as PR #23962 (`d050a8fc63`); its `ObjectQL.convertPackageBodyForward` door runs the same conversion table, so this entry rides it with no second mechanism. ## Measured before removal (base `6d1660dc3b`) - **Readers of the authored key:** none. The retired `bootstrapDeclaredPermissions` stamped the column and never read `ps.managedBy`. `packaged-permission-set-lock.ts` decides provenance from the artifact. `database-loader.ts:906` maps the `sys_metadata` record envelope's `managed_by`, not a set body. `permissionSetDefinitionFromRow` (core) excludes the column from the body. - **Writers in a permission-set body:** the example apps: 0. `plugin-security`'s default sets: 0. Every `defineStack` fixture: 0 (the tombstone typed every authoring site, and spec's typecheck and the platform-objects typecheck are green). HotCRM 4.0.1 at `1d7148bf2d` (the `v4.0.1` tag): 0 in its seven `*.profile.ts` sets, against the control `objects:` at 7. The one writer was Setup's form row, into stored rows, which the D2 strip heals. - **objectui at the pin `daeb77fc84`** (`.objectui-sha` unchanged since dispatch): `PermissionMatrixEditor.tsx:1365` and `:1606` read `draft.managedBy` for the Platform / Package / Custom badge; `permission-slice.ts:62` declares `managedBy?: string` on objectui's OWN `PermissionSetDraft` type; `permission-set-clone.ts:13` lists it in a comment; `PermissionMatrixEditor.badge.test.tsx:56-74` and `PermissionMatrixEditor.provenanceBadge-7611.test.tsx` test the badge. None imports the spec's type for that field, so the pinned objectui build does not break (Post-Task Checklist step 4). Its reader is a display, and its other two inputs (`packageId`, `codeIsArtifact`) are the real provenance. No objectui file is touched; the follow-up card is the seat's to file at landing. ## The kit - `packages/spec/src/security/permission.zod.ts`: the tombstone `managedBy: retiredKey(PERMISSION_SET_MANAGED_BY_REMOVED)` with its TSDoc; the `packageId` TSDoc no longer cites the key. - `packages/spec/src/security/permission.form.ts`: the `managedBy` row and its decision-record bullet leave, and a one-line note stays in their place. - D2 `permission-set-managed-by-removed` (`conversions/registry.ts`, `MAJOR_18_CONVERSIONS` order 67, defined above `recordChatterPositionVocabulary` per the merge rule); D3 `permission-set-managed-by-retired` (`migrations/entries/semantic/`); `RETIRED_KEYS_BY_MAJOR[18]` `security/PermissionSet:managedBy` (`migrations/entries/retired-keys/`); a `STEP18_RATIONALE` fragment (order 99) in `registry.ts.template`; a JOIN row in `retired-key-removal-version.pin.test.ts`. - `liveness/permission.json`: the row stays (`dead`, a REMOVED note, `verifiedAt: 2026-10-16`). The status does not move, so the README's per-type counts do not either. - Generated: `authorable-surface/security.json` (one row becomes `[RETIRED]`) and `content/docs/references/security/permission.mdx` (`gen:docs`). The four `*.metadata-forms.generated.ts` bundles were regenerated by `node scripts/check-i18n-bundles.mjs --write`, which drops 4 lines per locale, all generator-owned. Nothing generated was hand-edited. - Hand-written: `content/docs/permissions/permission-sets.mdx` (the clone door's dropped-key list no longer names `managedBy`). In `platform-objects`, two pins follow the bundle: `setup-copy-vocabulary.test.ts` drops the leaf from its help-text population and dark leg, and `object-lifecycle-panel-echo-decisions.test.ts` moves its catalog count from 674 to 673 for the one label that left. - `packages/spec/vitest.repo-tests.json`: the new pin's tree walk reads outside the package, so it runs under the `repo` project (`check:cross-package-test-inputs`). - `.changeset/23891-permission-set-managed-by-retired.md`: `@objectstack/spec` minor (BREAKING, FROM → TO, the one-line fix) and `@objectstack/platform-objects` patch, with the ADR-0087 `registered` marker. ## The four ratchets: what the route predicts, and what was read On the tombstone route, the key stays in the walked shape and no def is deleted. The route predicts a `[RETIRED]` mark on one authorable-surface row and no change to the other three. Read on `076958cb`: `authorable-surface` +1/−1 (the mark); `api-surface`, `api-surface-signatures` and `json-schema.manifest` unchanged; `check:generated` reports all 14 artifacts up to date. ## Pins, and the ablations that name them `packages/spec/src/security/permission-set-managed-by-retirement.test.ts` (17 cases): the refusal at the key's path with the prescription, for every value; the `permission` write door; the `defineStack` envelope (`STACK_SCHEMA_INVALID`, 422); a `@ts-expect-error` at an authoring site; the form offers no row; a stored row replays clean through `applyConversionsToStoredItem`; an installed package's artifact is refused before the conversion and accepted after it, with a lit control; strip, idempotence, and retirement from the load path; ADR-0087 registration; and a tree-scoped absence walk. Because `ObjectSchema.managedBy` is live, the walk flags `managedBy` only in an object literal that also carries a key only a permission set declares, and it states that bound. Ablations (`scripts/ablation-replace.mjs`, each restored to the HEAD blob with `git diff HEAD` empty): - **The key restored** as `z.enum(['package','platform','user']).optional()`: 7 tests go red (the parse refusal, every value, the write door, `defineStack`, the tsc-site parse leg, the artifact before/after, and the removal-version JOIN row). - **The D2 conversion unwired** from `MAJOR_18_CONVERSIONS`: 4 go red (the stored row, the artifact, strip/idempotence, chain wiring). - **The `@ts-expect-error` deleted:** `check:test-typecheck` goes red, naming the file. The type channel is really compiled. ## Verification (on `076958cb`, after merging `main`) - `@objectstack/spec`: `build` exit 0; `typecheck` exit 0; `check:generated` exit 0; `check:liveness`, `check:empty-state`, `check:exported-any`, `check:dual-source-exports` and `check:variant-docs` exit 0. Vitest runs: `src/` in four batches, 681 files and 20,447 tests, all passing. After the merge: `src/security`, `src/conversions`, `src/migrations` and `src/shared` plus the two major-18 merge tests and `scripts/liveness`, 83 files and 2,445 tests, all passing. - `@objectstack/platform-objects`: vitest 73 files and 1,136 tests, all passing; `typecheck` exit 0. `@objectstack/runtime` `permission-set-clone.test.ts`: 23 passing. `check:i18n` exit 0. - `dispatch-gates --commands` on this diff: 113 families; `--ran`: 113 accounted, 110 run, 3 NOT MEASURED. **NOT MEASURED** (prerequisites of the local tree, not readings of this diff): `check:plugin-teardown-shape --self-test` (exit 3, shallow clone), `check:skill-examples` (exit 3, `client-react` declarations not built), `check:dual-build-cjs-loads` (exit 3, packages never built here). `check:dts-closure` exited 1 on 54 packages missing `.d.ts`, which an earlier `OS_SKIP_DTS=1` closure build left in this tree. None of these is a package this diff changes, and CI checks out fresh. ## Acceptance notes - `packages/runtime/src/domains/permission-set-clone.ts` still lists `managedBy` in `PERMISSION_SET_CLONE_DROPPED_KEYS`. After this PR, no set body that reaches it can carry the key, so the entry is inert, not wrong. It is outside this card's file surface and is left alone. Carrier: none. - objectui's provenance badge reader (the sites listed above) is the seat's follow-up card at landing. - The out-of-repo consumer population of `@objectstack/spec` beyond HotCRM 4.0.1 is NOT MEASURED. --- _Generated by [Claude Code](https://claude.ai/code/session_01D3Ur6XgfkkaDUCCTkviXAr)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01D3Ur6XgfkkaDUCCTkviXAr --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e18b23f commit bbdfc05

20 files changed

Lines changed: 685 additions & 47 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
feat(spec)!: retire the permission set's authored `managedBy` — a provenance key nothing read (#23891)
7+
8+
**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`.
9+
10+
Clause-②: yes (narrowing)
11+
12+
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.
13+
14+
## FROM → TO
15+
16+
| you wrote (17.7 and earlier) | write instead |
17+
| --- | --- |
18+
| `definePermissionSet({ …, managedBy: 'package' })` | nothing: delete the key; a package-shipped set is the package's because the package ships it (`packageId`) |
19+
| `managedBy: 'platform'` or `managedBy: 'user'` | nothing: delete the key; a set created in this environment carries neither `managedBy` nor `packageId` |
20+
| 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) |
21+
22+
**The one-line fix:** delete `managedBy` from every permission set.
23+
24+
**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:
25+
26+
> `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.
27+
28+
**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.
29+
30+
## The retirement kit
31+
32+
- **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`.
33+
- **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.
34+
- **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.
35+
- **`@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.
36+
- **No deprecation window**, per the project's startup-stage posture.
37+
38+
⚠️ **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.
39+
40+
<!-- adr-0087: registered permission-set-managed-by-removed, permission-set-managed-by-retired -->

‎content/docs/permissions/permission-sets.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ it was copied from, so changes to the source never reach the copy.
429429
- **Every permission facet is copied** — `objects`, `fields`,
430430
`systemPermissions`, `tabPermissions`, `rowLevelSecurity`, `description`.
431431
- **The server drops a declared set of keys**: the package provenance
432-
(`packageId`, `managedBy`, `protection` and the internal protection envelope),
432+
(`packageId`, `protection` and the internal protection envelope),
433433
and two privilege decisions — `adminScope` (delegated administration) and
434434
`isDefault` (the `everyone` suggestion). Grant either deliberately on the copy
435435
if it needs one. The response, `{ permissionSet, notice }`, says what was not

‎content/docs/references/security/permission.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ const result = AdminScopeSchema.parse(data);
118118
| **label** | `string` | optional | Display label |
119119
| **description** | `string` | optional | Human-readable description shown in Setup |
120120
| **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 |
121-
| **managedBy** | `Enum<'package' \| 'platform' \| 'user'>` | optional | [ADR-0086 D3] Record provenance: package (upgrade-owned metadata) vs platform/user (env config) |
121+
| **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. |
122122
| **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 |
123123
| **objects** | `Record<string, { allowCreate?: boolean; allowRead?: boolean; allowEdit?: boolean; allowDelete?: boolean; … }>` | ✅ | Entity permissions |
124124
| **fields** | `Record<string, { readable?: boolean; editable?: boolean }>` | optional | Field level security |

‎packages/platform-objects/src/apps/setup-copy-vocabulary.test.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,6 @@ describe('Setup copy carries no design-record vocabulary', () => {
181181
// the four bundles judges the source too.
182182
const FORM_HELP_LEAVES = [
183183
['permission', 'fields', 'isDefault', 'helpText'],
184-
['permission', 'fields', 'managedBy', 'helpText'],
185184
['permission', 'fields', 'packageId', 'helpText'],
186185
['permission', 'fields', 'adminScope', 'helpText'],
187186
['position', 'fields', 'delegatable', 'helpText'],
@@ -209,7 +208,6 @@ describe('Setup copy carries no design-record vocabulary', () => {
209208
const replaced = {
210209
permission: { fields: {
211210
isDefault: { helpText: 'App baseline for the everyone position (ADR-0090 D5): an app-level set is auto-bound at boot.' },
212-
managedBy: { helpText: 'Record provenance (ADR-0086 D3): who owns this set across upgrades.' },
213211
packageId: { helpText: 'Owning package id for a package-shipped set (ADR-0086 D3).' },
214212
adminScope: { helpText: 'Delegated administration (ADR-0090 D12): lets holders of this set administer one business-unit subtree.' },
215213
} },

‎packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2332,10 +2332,6 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
23322332
label: "Is Default",
23332333
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."
23342334
},
2335-
managedBy: {
2336-
label: "Managed By",
2337-
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."
2338-
},
23392335
packageId: {
23402336
label: "Package Id",
23412337
helpText: "The package that ships this set; a value naming any other package is refused. Leave empty for a set created in this environment."

‎packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2332,10 +2332,6 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
23322332
label: "Predeterminado",
23332333
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."
23342334
},
2335-
managedBy: {
2336-
label: "Gestionado por",
2337-
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."
2338-
},
23392335
packageId: {
23402336
label: "ID de paquete",
23412337
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."

‎packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2332,10 +2332,6 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
23322332
label: "既定の権限セット",
23332333
helpText: "組み込みの everyone ポジションを通じて、サインインするすべてのユーザーにこのセットを付与します。アプリ自身が宣言したセットは起動時に付与され、インストールしたパッケージのセットは管理者が Setup で確認する提案になります。高権限を含むセットはこの方法では付与されません。既定は false です。"
23342334
},
2335-
managedBy: {
2336-
label: "管理元",
2337-
helpText: "このセットの所有者です。Package はパッケージに同梱され、パッケージのアップグレードで更新されることを示します。Platform と User はこの環境で作成され、アップグレードで変更されないことを示します。"
2338-
},
23392335
packageId: {
23402336
label: "所属パッケージ ID",
23412337
helpText: "このセットを同梱しているパッケージです。別のパッケージを指す値は拒否されます。この環境で作成したセットでは空のままにします。"

‎packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1199,7 +1199,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
11991199
// 674 since #23809: the options repeaters gain `kind`, the option's
12001200
// lifecycle category — one row label on the field form, one on the
12011201
// object form, authored in all three locales.
1202-
expect(translated.length, `${locale} positive control`).toBe(674);
1202+
// 673 since #23891: the permission-set form's `managedBy` select left
1203+
// with its key (retired — nothing read the authored provenance), taking
1204+
// its label — authored in all three locales — out of the catalog.
1205+
expect(translated.length, `${locale} positive control`).toBe(673);
12031206
}
12041207
// ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the
12051208
// all-three predicate returns 0 while the per-locale one returns 1, so the

‎packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2332,10 +2332,6 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
23322332
label: "默认权限集",
23332333
helpText: "通过内置的 everyone 岗位,把这个权限集授予每一位登录用户。应用自身声明的权限集在启动时授予;已安装包中的权限集则成为一条建议,由管理员在 Setup 中确认。含高权限的权限集不会以这种方式授予。默认 false。"
23342334
},
2335-
managedBy: {
2336-
label: "归属来源",
2337-
helpText: "这个权限集归谁所有:Package 表示它随包分发,包升级时会更新它;Platform 和 User 表示它在本环境中创建,升级不会改动它。"
2338-
},
23392335
packageId: {
23402336
label: "所属包 ID",
23412337
helpText: "分发这个权限集的包;填写其他包的 id 会被拒绝。在本环境中创建的权限集请留空。"

‎packages/spec/authorable-surface/security.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@
121121
"security/PermissionSet:fields",
122122
"security/PermissionSet:isDefault",
123123
"security/PermissionSet:label",
124-
"security/PermissionSet:managedBy",
124+
"security/PermissionSet:managedBy [RETIRED]",
125125
"security/PermissionSet:name",
126126
"security/PermissionSet:objects",
127127
"security/PermissionSet:packageId",

0 commit comments

Comments
 (0)