From 2176f0fcc1a148acb545d1fdf22920db9450eefd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 08:00:38 +0000 Subject: [PATCH 1/2] feat(spec): ActionSession declares `positions` as canonical, `roles` as its deprecated alias (#5779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec half of #5613 phase 2, under the maintainer's contract-first ruling ("C skeleton + A semantics"). Phase 1 (#5697) declared the action-body `ctx.session` shape exactly as `buildActionSession()` built it and deliberately withheld a `positions` key — "minting one before the migration would ship two live spellings of one value". That prohibition existed to stop a two-spelling window opening without a closing date; this change opens it WITH one, and rewrites the docstring that carried the ban. `positions` is now the canonical key: the ADR-0090 D3 vocabulary the execution context, the sharing service and (since #5605 / PR #5722) the hook `ctx.session` already speak. `roles` becomes a deprecated alias of it, removed after one window on the path `session.tenantId` already walked (#3280 deprecated, #3290 removed in v11). The ADR-0087 semantic migration `action-session-roles-to-positions` is the reader-facing channel. It is a D3 semantic TODO rather than a D2 conversion on two independent grounds: an action `ctx.session` is constructed per dispatch and never persisted, so there is no source for the chain to rewrite; and the only place the key is ever spelled is inside an action body, i.e. free-form author-written code, which is why the ADR-0090 wave delegated `current_user.roles` at step 13 instead of substituting text. The alias is deliberately NOT tombstoned — a tombstone rejects the key, which is the removal a deprecation window exists to defer. Contract leads producer: `buildActionSession()` dual-emitting both keys, and the two wrong sentences already tracked in its docblock, are #5613's runtime half and are not in this change. `packages/runtime` is untouched. Both keys are optional, so the runtime consistency pin (`action-session-shape-contract.test.ts`) is unchanged and stays green — a non-strict parse of a session without `positions` neither gains the key nor rejects the object. Measured before and after: 10/10 passing both times. Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D Co-authored-by: Claude --- ...session-positions-canonical-roles-alias.md | 52 ++++++ content/docs/references/ui/action-params.mdx | 3 +- docs/protocol-upgrade-guide.md | 3 + packages/spec/authorable-surface.json | 1 + packages/spec/spec-changes.json | 14 ++ packages/spec/src/migrations/registry.ts | 53 ++++++ packages/spec/src/ui/action-params.test.ts | 164 ++++++++++++++++++ packages/spec/src/ui/action-params.zod.ts | 135 +++++++++++--- 8 files changed, 400 insertions(+), 25 deletions(-) create mode 100644 .changeset/action-session-positions-canonical-roles-alias.md diff --git a/.changeset/action-session-positions-canonical-roles-alias.md b/.changeset/action-session-positions-canonical-roles-alias.md new file mode 100644 index 0000000000..3823794093 --- /dev/null +++ b/.changeset/action-session-positions-canonical-roles-alias.md @@ -0,0 +1,52 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `ActionSession` declares `positions` as canonical and deprecates `roles` (#5779) + +The action-body `ctx.session` contract gains `positions`, the ADR-0090 D3 spelling +of the caller's position names, and demotes `roles` to a deprecated alias of it. +This is the **spec half** of #5613 phase 2, under the maintainer's contract-first +ruling ("C skeleton + A semantics"): phase 1 (#5697) declared the shape the runtime +already built, and this opens the rename on top of that declaration. + +**What was wrong.** `buildActionSession()` copies `ExecutionContext.positions` into +a key spelled `roles` — the one spelling ADR-0090 D3 bans — so an author met two +different answers to one key name on one platform: `session.roles` is rejected in a +hook (retired in #5050) and live, populated, and load-bearing in an action body. +Phase 1 declared that reality without endorsing it and deliberately withheld a +`positions` key, because minting a second live spelling with no closing date is the +defect rather than the fix. This change mints it **with** a closing date. + +**Migration prescription — do this now.** + +- Read `ctx.session.positions`. It is the canonical key and it carries exactly the + array `roles` carried; the rename is a rename, not a semantic change. +- `ctx.session.roles` still resolves for the length of the deprecation window and + is removed after it, on the path `session.tenantId` already walked (#3280 + deprecated, #3290 removed in v11). A body still reading it at that point sees + `undefined` with nothing to catch the change — which is why the read moves inside + the window, not at its close. +- Do **not** migrate an access check by renaming it. `roles.includes('admin')` + rewritten as `positions.includes('admin')` migrates the defect: neither array is + an authorization input. Privilege is judged by the security service, which + evaluates capability grants, placements and the derived posture (ADR-0095). + +**Sequencing — the contract leads its producer.** This release ships the contract +only. The producer change (`buildActionSession()` emitting both keys, plus the two +already-tracked wrong sentences in its docblock) is #5613's runtime half and lands +separately. Until it does, a built session still carries only `roles`, so +`positions` is meaning-fixed but not yet presence-guaranteed; both keys are +optional, which is what lets the declaration lead without breaking anything. A +reader that must straddle the seam may read `positions` and fall back to `roles` +for the window's duration only — that fallback expires with the alias. + +Additive and non-breaking on its own: adding an optional key rejects nothing that +parsed before, and the runtime consistency pin +(`packages/runtime/src/action-session-shape-contract.test.ts`) is unchanged and +still green. + +The reader-facing announcement is the ADR-0087 semantic migration +`action-session-roles-to-positions`, which carries the prescription above and its +acceptance criteria into `spec-changes.json`, the generated upgrade guide and the +`spec_changes` MCP tool. diff --git a/content/docs/references/ui/action-params.mdx b/content/docs/references/ui/action-params.mdx index df83ebc1c1..13dab1d011 100644 --- a/content/docs/references/ui/action-params.mdx +++ b/content/docs/references/ui/action-params.mdx @@ -69,7 +69,8 @@ Action-body `ctx.session` — the caller identity an action body reads (runtime | :--- | :--- | :--- | :--- | | **userId** | `string` | optional | Invoking user id (absent when the call carries no user) | | **organizationId** | `string` | optional | Active organization id (blessed developer-facing name; absent when the call is org-less) | -| **roles** | `string[]` | optional | DEPRECATED — the VALUE is the caller's ADR-0090 D3 `positions` (`ExecutionContext.positions`, "Formerly `roles`"), delivered at this boundary under the one spelling that vocabulary forbids. Declared here because `buildActionSession()` produces it today — declaring current reality is not endorsing the name: ADR-0090 D3 makes `role` a reserved-forbidden word, #4839 deleted the last two `roles.includes('admin')` readers, and #5050 retired the hook-side `HookContext.session.roles` outright, so a body author currently meets two different answers to one key name on one platform. The rename to `positions` — with its deprecation window, ADR-0087 semantic migration and the `buildActionSession()` comment correction — is #5613 phase 2. There is deliberately NO `positions` key on this shape yet: minting one before the migration would ship two live spellings of one value, which is the defect, not the fix. Never gate PRIVILEGE on this array — ask the security service, which evaluates capability grants, placements and the derived posture (ADR-0095), never a role-name string comparison. | +| **positions** | `string[]` | optional | Position names held by the caller (ADR-0090 D3 vocabulary; the value of `ExecutionContext.positions`, whose schema comment reads "Formerly `roles`") — the CANONICAL spelling at this boundary and the key an action body should read. Within the #5613 deprecation window `buildActionSession()` emits the same array under both this key and the deprecated `roles`, so migrating is a change of key and nothing else; `roles` is then removed on the #3280 → #3290 `session.tenantId` alias precedent path (deprecate, one window, remove). Never gate PRIVILEGE on this array — ask the security service, which evaluates capability grants, placements and the derived posture (ADR-0095), never a position-name string comparison. | +| **roles** | `string[]` | optional | DEPRECATED alias of `positions` — the same caller position names under the one spelling ADR-0090 D3 forbids (the value is `ExecutionContext.positions`, "Formerly `roles`"). Read `positions` instead: within the #5613 deprecation window `buildActionSession()` emits both keys with identical values, so migrating is a change of key and nothing else. The migration prescription and its acceptance criteria are the ADR-0087 semantic migration `action-session-roles-to-positions`; removal follows the `session.tenantId` alias precedent path (#3280 deprecated → #3290 removed in v11). Never gate PRIVILEGE on this array — ask the security service, which evaluates capability grants, placements and the derived posture (ADR-0095), never a role-name string comparison. | --- diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index be6af3a4ac..fd44b8b804 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -336,6 +336,9 @@ The last enforce-or-remove entry of this step is on the RUNTIME context rather t - **`hook-context-session-roles-retired`** — `data.hookContext.session.roles` → (removed — gate on `session.userId` / `session.isSystem`; for PRIVILEGE ask the security service, which reads `permissions` / `positions` / posture off the execution context, ADR-0095 D3) - Why not automatic: Declared on the runtime hook context, read by exactly two consumers, produced by nobody. The two readers were the approvals record lock and the delegation write guard, each opening with `session.roles?.includes('admin')`; ObjectQL's `buildSession()` builds the session field by field and has never written `roles`, and nothing else feeds a HookContext in objectstack, cloud or objectui (cloud's hook consumers read `hookContext?.session?.userId`; objectui's `roles` are the `/auth/me` user payload, a different surface; an ACTION body's `ctx.session` is a different untyped object that does carry `roles`, tracked apart and unaffected). Both branches were therefore dead on every real engine path — an authorization decision in shape only, and a second admin dialect competing with the one ADR-0090 D3 / ADR-0095 D3 sanction. #4839 (PR #5049) removed the readers; this removes the declaration, per ADR-0049 enforce-or-remove. This is a RUNTIME context, not stored metadata: the engine builds a HookContext per operation and nothing persists one, so no `sys_metadata` row, example or template can carry the key and there is no source for the D2 chain to rewrite — the `openApi31` (#4579) / `activationEvents` (#4657) shape, one semantic TODO rather than a stack conversion. The key IS tombstoned (`HookContextSchema` is deliberately not `.strict()` — a plain delete would strip it silently, #3733 / ADR-0104), so a consumer that parses a context it was handed still meets the prescription. ADR-0049, #5050. - Done when: No hook reads `ctx.session.roles`; caller gating uses `ctx.session.userId` / `ctx.session.isSystem`, and privilege comes from the security service (`permissions` / `positions` / posture). Constructing a HookContext session with `roles` fails `tsc` (the input type is `never`) and fails `HookContextSchema.parse` with the retirement prescription instead of being silently stripped. Nothing regresses at runtime: the key had no producer, so no decision anywhere ever saw a value in it. +- **`action-session-roles-to-positions`** — `ui.actionSession.roles` → ui.actionSession.positions (an action body reads `ctx.session.positions`) + - Why not automatic: The MIRROR-IMAGE neighbour of the entry above, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: "C skeleton + A semantics"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path `session.tenantId` already walked (#3280 deprecated → #3290 removed in v11). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779. + - Done when: No action body reads `ctx.session.roles`; every such read is `ctx.session.positions` and observes the same array (the rename is a rename — the VALUE is `ExecutionContext.positions` on both sides, which the runtime pin `action-session-shape-contract.test.ts` asserts independently of the key name). Privilege is NOT re-derived from either spelling: a read that was `roles.includes('admin')` as an access check is rewritten to ask the security service (capability grants / placements / derived posture, ADR-0095), never renamed to `positions.includes('admin')` — renaming that read migrates the defect rather than the code. Verify against a real dispatch, not a fixture: invoke an action as a caller holding positions and assert the body observed them under the canonical key. During the window both keys are present and equal, so a reader can be migrated and verified before the alias is removed; after it, `roles` is absent and a body still reading it sees `undefined` — which is why the read must be moved inside the window rather than at its close. --- diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 31cd8796ef..c7d71eb1cc 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -6927,6 +6927,7 @@ "ui/ActionParam:type", "ui/ActionParam:visible", "ui/ActionSession:organizationId", + "ui/ActionSession:positions", "ui/ActionSession:roles", "ui/ActionSession:userId", "ui/AddRecordConfig:enabled", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index d147a8658a..dea1b1ac41 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -579,6 +579,13 @@ "migrationId": "hook-context-session-roles-retired", "toMajor": 17, "rationale": "Declared on the runtime hook context, read by exactly two consumers, produced by nobody. The two readers were the approvals record lock and the delegation write guard, each opening with `session.roles?.includes('admin')`; ObjectQL's `buildSession()` builds the session field by field and has never written `roles`, and nothing else feeds a HookContext in objectstack, cloud or objectui (cloud's hook consumers read `hookContext?.session?.userId`; objectui's `roles` are the `/auth/me` user payload, a different surface; an ACTION body's `ctx.session` is a different untyped object that does carry `roles`, tracked apart and unaffected). Both branches were therefore dead on every real engine path — an authorization decision in shape only, and a second admin dialect competing with the one ADR-0090 D3 / ADR-0095 D3 sanction. #4839 (PR #5049) removed the readers; this removes the declaration, per ADR-0049 enforce-or-remove. This is a RUNTIME context, not stored metadata: the engine builds a HookContext per operation and nothing persists one, so no `sys_metadata` row, example or template can carry the key and there is no source for the D2 chain to rewrite — the `openApi31` (#4579) / `activationEvents` (#4657) shape, one semantic TODO rather than a stack conversion. The key IS tombstoned (`HookContextSchema` is deliberately not `.strict()` — a plain delete would strip it silently, #3733 / ADR-0104), so a consumer that parses a context it was handed still meets the prescription. ADR-0049, #5050." + }, + { + "surface": "ui.actionSession.roles", + "replacement": "ui.actionSession.positions (an action body reads `ctx.session.positions`)", + "migrationId": "action-session-roles-to-positions", + "toMajor": 17, + "rationale": "The MIRROR-IMAGE neighbour of the entry above, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: \"C skeleton + A semantics\"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path `session.tenantId` already walked (#3280 deprecated → #3290 removed in v11). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779." } ], "removed": [] @@ -1217,6 +1224,13 @@ "migrationId": "hook-context-session-roles-retired", "toMajor": 17, "rationale": "Declared on the runtime hook context, read by exactly two consumers, produced by nobody. The two readers were the approvals record lock and the delegation write guard, each opening with `session.roles?.includes('admin')`; ObjectQL's `buildSession()` builds the session field by field and has never written `roles`, and nothing else feeds a HookContext in objectstack, cloud or objectui (cloud's hook consumers read `hookContext?.session?.userId`; objectui's `roles` are the `/auth/me` user payload, a different surface; an ACTION body's `ctx.session` is a different untyped object that does carry `roles`, tracked apart and unaffected). Both branches were therefore dead on every real engine path — an authorization decision in shape only, and a second admin dialect competing with the one ADR-0090 D3 / ADR-0095 D3 sanction. #4839 (PR #5049) removed the readers; this removes the declaration, per ADR-0049 enforce-or-remove. This is a RUNTIME context, not stored metadata: the engine builds a HookContext per operation and nothing persists one, so no `sys_metadata` row, example or template can carry the key and there is no source for the D2 chain to rewrite — the `openApi31` (#4579) / `activationEvents` (#4657) shape, one semantic TODO rather than a stack conversion. The key IS tombstoned (`HookContextSchema` is deliberately not `.strict()` — a plain delete would strip it silently, #3733 / ADR-0104), so a consumer that parses a context it was handed still meets the prescription. ADR-0049, #5050." + }, + { + "surface": "ui.actionSession.roles", + "replacement": "ui.actionSession.positions (an action body reads `ctx.session.positions`)", + "migrationId": "action-session-roles-to-positions", + "toMajor": 17, + "rationale": "The MIRROR-IMAGE neighbour of the entry above, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: \"C skeleton + A semantics\"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path `session.tenantId` already walked (#3280 deprecated → #3290 removed in v11). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779." } ], "removed": [] diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 3b0f793dcc..bfb9e0a3d0 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1871,6 +1871,59 @@ const step17: MigrationStep = { + 'regresses at runtime: the key had no producer, so no decision anywhere ever saw a ' + 'value in it.', }, + { + id: 'action-session-roles-to-positions', + surface: 'ui.actionSession.roles', + replacement: 'ui.actionSession.positions (an action body reads `ctx.session.positions`)', + reason: + 'The MIRROR-IMAGE neighbour of the entry above, and the reason both are in this ' + + 'step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed ' + + 'outright, #5050), while the ACTION body\'s `ctx.session` carries it ' + + 'produced-and-really-populated. `buildActionSession()` ' + + '(`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` ' + + 'into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under ' + + 'the one spelling that ADR bans — so a body author met two different answers to one ' + + 'key name on one platform: rejected in a hook, live and full of values in an action. ' + + '#5613 ruled contract-first (maintainer, 2026-08-06: "C skeleton + A semantics"): ' + + 'phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, ' + + 'and phase 2 renames the key. `positions` is now the canonical key on that schema ' + + 'and `roles` a deprecated alias of it (#5779); the producer emits both for one ' + + 'deprecation window (#5613 runtime half), after which `roles` is removed on the path ' + + '`session.tenantId` already walked (#3280 deprecated → #3290 removed in v11). ' + + 'Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: ' + + 'FIRST, there is no source to convert — an action `ctx.session` is constructed per ' + + 'dispatch and never persisted, so no `sys_metadata` row, example or template can ' + + 'carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / ' + + '`hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key ' + + 'is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed ' + + 'script whose `ScriptContext.session` is still `unknown`. A declarative transform ' + + 'cannot safely rewrite an identifier inside free-form code — exactly the reason the ' + + 'ADR-0090 wave delegated `current_user.roles` to the author at step 13 ' + + '(`cel-current-user-roles-to-positions`) instead of substituting text. ' + + 'Note what is deliberately NOT done here: the alias is not tombstoned. A ' + + '`retiredKey()` REJECTS the key, and a deprecation window exists precisely so the ' + + 'old spelling keeps working while its readers move — tombstoning during the window ' + + 'would be the removal it is meant to defer. The tombstone (or the plain deletion the ' + + 'authorable-surface ratchet adjudicates) belongs to the release that closes the ' + + 'window. Until then this entry IS the channel: `spec-changes.json` and the generated ' + + 'upgrade guide are how a reader learns the rename before the removal reaches them. ' + + 'ADR-0090 D3, ADR-0087, #5613 / #5779.', + acceptanceCriteria: + 'No action body reads `ctx.session.roles`; every such read is `ctx.session.positions` ' + + 'and observes the same array (the rename is a rename — the VALUE is ' + + '`ExecutionContext.positions` on both sides, which the runtime pin ' + + '`action-session-shape-contract.test.ts` asserts independently of the key name). ' + + 'Privilege is NOT re-derived from either spelling: a read that was ' + + '`roles.includes(\'admin\')` as an access check is rewritten to ask the security ' + + 'service (capability grants / placements / derived posture, ADR-0095), never ' + + 'renamed to `positions.includes(\'admin\')` — renaming that read migrates the defect ' + + 'rather than the code. Verify against a real dispatch, not a fixture: invoke an ' + + 'action as a caller holding positions and assert the body observed them under the ' + + 'canonical key. During the window both keys are present and equal, so a reader can ' + + 'be migrated and verified before the alias is removed; after it, `roles` is absent ' + + 'and a body still reading it sees `undefined` — which is why the read must be moved ' + + 'inside the window rather than at its close.', + }, ], }; diff --git a/packages/spec/src/ui/action-params.test.ts b/packages/spec/src/ui/action-params.test.ts index f707d2c69f..f7c9ebd0cd 100644 --- a/packages/spec/src/ui/action-params.test.ts +++ b/packages/spec/src/ui/action-params.test.ts @@ -4,8 +4,11 @@ import { describe, it, expect } from 'vitest'; import { validateActionParams, ACTION_PARAM_BUILTIN_KEYS, + ActionSessionSchema, + type ActionSession, type ResolvedActionParam, } from './action-params.zod'; +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; const codes = (issues: ReturnType) => issues.map((i) => i.code).sort(); @@ -89,3 +92,164 @@ describe('validateActionParams (ADR-0104 D2)', () => { expect(issues).toEqual([]); }); }); + +/** + * [#5779] `ActionSession` gains the canonical `positions` key and demotes + * `roles` to a deprecated alias — the SPEC half of #5613 phase 2, under the + * maintainer's "C skeleton + A semantics" ruling. + * + * Phase 1 (#5697) declared the shape the runtime already built and explicitly + * withheld `positions` ("do NOT add a `positions` key here ahead of it"). That + * prohibition is lifted by this change and rewritten, which is why the pins + * below assert the NEW wording rather than merely the new key: a rename whose + * deprecation notice does not survive the next edit is a rename that silently + * becomes two permanent spellings. + * + * ## What the key-preservation assertions are actually guarding + * + * `ActionSessionSchema` is deliberately NOT strict (a runtime shape handed to + * a body — see the schema's docblock). So `safeParse().success` is worthless + * here: an UNDECLARED key parses "successfully" and is silently stripped. The + * load-bearing fact is therefore that a parse PRESERVES the key. This is the + * key-reachability question, not a value-verdict question — the schema judges + * no `positions` VALUE beyond `string[]`, so demanding anything more than + * preservation would pin something the contract does not claim. + * + * ## Reverse verification — directions predicted BEFORE running + * + * - Delete the `positions` declaration from `action-params.zod.ts` → the two + * preservation tests go RED (a non-strict parse strips it), the describe + * pin goes RED (there is no `shape.positions` to read a description off), + * and `pnpm --filter @objectstack/spec typecheck` reports an excess-property + * error on the typed dual-emit literal. Measured: that is what happens. + * - Restore the phase-1 `roles` describe → the alias-wording pin goes RED on + * every clause (`deprecated alias`, the migration id, the #3290 path). + * - Delete the ADR-0087 entry → the registry pin goes RED. + * + * ## What does NOT move, and why that is the point + * + * `packages/runtime/src/action-session-shape-contract.test.ts` — which pins + * `parse(built)` deep-equal `built` and the exact built key set — stays GREEN + * across this change, predicted before it was run. Adding an OPTIONAL key can + * neither reject a previously valid object nor materialise itself in the parse + * output of an input that lacks it, and the producer (still emitting only + * `roles` until #5613's runtime half) is untouched. That suite going red would + * have meant the contract had led its producer into a break, which is exactly + * what a deprecation window exists to prevent. + */ +describe('#5779 — ActionSession `positions` canonical + `roles` deprecated alias', () => { + it('PRESERVES `positions` through a parse instead of stripping it', () => { + const parsed = ActionSessionSchema.parse({ + userId: 'usr_1', + organizationId: 'org_acme', + positions: ['sales_rep', 'org_admin'], + }); + + expect(parsed).toHaveProperty('positions'); + expect(parsed.positions).toEqual(['sales_rep', 'org_admin']); + }); + + it('accepts the DUAL-EMIT shape #5613\'s runtime half will produce — both keys, one value', () => { + // The window's defining property: same array under both spellings, so a + // reader migrates by changing the key it reads and nothing else. Pinned + // here on the contract side BEFORE the producer exists, because that + // ordering (contract first, producer second) is the ruling. + const positions = ['sales_rep', 'org_admin']; + const built = { userId: 'usr_1', organizationId: 'org_acme', positions, roles: positions }; + + const parsed = ActionSessionSchema.parse(built); + expect(parsed).toEqual(built); + expect(parsed.positions).toEqual(parsed.roles); + }); + + it('still parses a session carrying ONLY `roles` — the alias keeps working through the window', () => { + // What `buildActionSession()` builds TODAY. A deprecation that broke this + // would not be a deprecation; it would be the removal, shipped early. + const built = { userId: 'usr_1', roles: ['sales_rep'] }; + + const parsed = ActionSessionSchema.parse(built); + expect(parsed).toEqual(built); + expect(parsed).not.toHaveProperty('positions'); + }); + + it('keeps BOTH keys optional', () => { + // ⚠️ HONEST NOTE — a COMPANION, not a pin (the #5722 distinction). It + // asserts absence, and absence is also what an undeclared/stripped key + // produces, so it stayed green under the reverse verification while its + // siblings went red. It is kept because "declaring `positions` did not + // make it required" is a real regression it catches (drop `.optional()` + // and it turns red) — but it is not evidence the declaration exists. + const parsed = ActionSessionSchema.parse({ userId: 'usr_1' }); + expect(parsed.positions).toBeUndefined(); + expect(parsed.roles).toBeUndefined(); + }); + + it('types the dual-emit window on the `ActionSession` face (the tsc channel)', () => { + // `z.infer` must admit both keys at once, or #5613's runtime half cannot + // annotate `buildActionSession()`'s return with this type while emitting + // both — the type face is a precondition of the producer change, not a + // consequence of it. Explicitly annotated so a narrowed or renamed + // declaration fails here rather than being absorbed by inference. + const dualEmitted: ActionSession = { + userId: 'usr_1', + organizationId: 'org_acme', + positions: ['sales_rep'], + roles: ['sales_rep'], + }; + const canonicalRead: string[] | undefined = dualEmitted.positions; + const aliasRead: string[] | undefined = dualEmitted.roles; + + expect(canonicalRead).toEqual(aliasRead); + }); + + it('pins `positions` as CANONICAL and carries the ADR-0095 privilege boundary', () => { + // A `.describe()` reaches the generated reference page and every + // schema-driven surface, so the vocabulary claim and the security caution + // are load-bearing text, not decoration. The caution belongs to the VALUE, + // which is why it had to survive the rename verbatim. + const doc = ActionSessionSchema.shape.positions.description ?? ''; + + expect(doc).toMatch(/CANONICAL/); + expect(doc).toMatch(/ADR-0090 D3/); + expect(doc).toMatch(/ExecutionContext\.positions/); + expect(doc).toMatch(/security service/i); + expect(doc).toMatch(/ADR-0095/); + }); + + it('pins `roles` as a DEPRECATED ALIAS naming its replacement, migration and removal path', () => { + // The four things a reader needs and cannot infer from the key name: that + // it is deprecated, what replaces it, where the prescription lives, and + // that removal is scheduled rather than hypothetical. + const doc = ActionSessionSchema.shape.roles.description ?? ''; + + expect(doc).toMatch(/DEPRECATED alias of `positions`/); + expect(doc).toMatch(/action-session-roles-to-positions/); + expect(doc).toMatch(/#3290/); + expect(doc).toMatch(/security service/i); + + // ...and that phase 1's now-obsolete prohibition is GONE. Leaving it would + // tell the next reader that `positions` must not exist, on the very schema + // where it now does. + expect(doc).not.toMatch(/deliberately NO `positions` key/); + }); + + it('registers the rename as an ADR-0087 semantic migration (the reader-facing channel)', () => { + // `spec-changes.json`, the generated upgrade guide and the `spec_changes` + // MCP tool are all projections of this registry. Without the entry the + // rename reaches a consumer only when their read starts returning + // `undefined` — which is the failure mode a deprecation window exists to + // replace with an announcement. + const entry = MIGRATIONS_BY_MAJOR[17]?.semantic + .find((s) => s.id === 'action-session-roles-to-positions'); + + expect(entry).toBeDefined(); + expect(entry!.surface).toBe('ui.actionSession.roles'); + expect(entry!.replacement).toMatch(/positions/); + // It is a RENAME under a window, not the retirement its hook-side + // neighbour (`hook-context-session-roles-retired`, #5050) was — the reason + // must not read as a removal notice. + expect(entry!.reason).toMatch(/deprecation window/); + expect(entry!.reason).toMatch(/#5613/); + expect(entry!.acceptanceCriteria).toMatch(/ctx\.session\.positions/); + }); +}); diff --git a/packages/spec/src/ui/action-params.zod.ts b/packages/spec/src/ui/action-params.zod.ts index cffd4567f3..b13ee04a7e 100644 --- a/packages/spec/src/ui/action-params.zod.ts +++ b/packages/spec/src/ui/action-params.zod.ts @@ -157,9 +157,32 @@ export interface ActionEngineFacade { * The caller session an action BODY / handler reads as `ctx.session`. * * This is the CONTRACT for what `packages/runtime`'s `buildActionSession()` - * (`src/action-execution.ts`) produces today — phase 1 of #5613's - * contract-first ruling (#5697). It DECLARES the current shape and changes - * nothing about what the runtime builds. + * (`src/action-execution.ts`) hands an action body. It arrived in two steps of + * #5613's contract-first ruling: phase 1 (#5697) DECLARED the shape exactly as + * the runtime already built it, and phase 2's spec half (#5779) added the + * canonical `positions` key and demoted `roles` to a deprecated alias of it. + * + * ## Two spellings, one value — the #5613 deprecation window + * + * `positions` and `roles` are the SAME array (`ExecutionContext.positions`) + * under two names. `positions` is canonical — it is the ADR-0090 D3 vocabulary + * the rest of the platform already uses — and `roles` is the alias kept alive + * only for the length of the window, then removed on the path + * `session.tenantId` already walked (#3280 deprecate → #3290 removed in v11). + * The announcement a reader migrates from is the ADR-0087 semantic migration + * `action-session-roles-to-positions`. Two live spellings is the MIGRATION, + * not the destination; phase 1 withheld `positions` precisely so that the + * window would open once, deliberately, with a removal path attached. + * + * Contract-first means the two halves land apart, and this file is the first + * of them. The PRODUCER change — `buildActionSession()` emitting both keys — + * is #5613's runtime half and is NOT in yet. Read that literally: a session + * built today still carries only `roles`, so an action body cannot yet rely on + * `positions` being PRESENT, only on its meaning being fixed. Every key here + * is `.optional()`, which is what lets a declaration lead its producer at all + * — and, concretely, what keeps the runtime consistency pin (below) green + * across this change instead of red, since a non-strict parse of a session + * without `positions` neither gains the key nor rejects the object. * * Why a declaration was worth its own change: `actionContext` is a bare `any` * at both dispatch sites (`domains/actions.ts`, `action-execution.ts`) and the @@ -171,7 +194,7 @@ export interface ActionEngineFacade { * * ## Read the shape exactly — absent means the KEY IS ABSENT * - * All three keys are optional and the builder emits them by CONDITIONAL + * All four keys are optional and the builder emits them by CONDITIONAL * SPREAD, so a missing value means the key is **not present**: * `'organizationId' in ctx.session` answers `false`, not `undefined`. The hook * path's `input.id` on a bulk write is the OPPOSITE case — key present, value @@ -181,8 +204,9 @@ export interface ActionEngineFacade { * The session as a whole is `undefined` — never `{}` — for a call carrying * neither `userId` nor `tenantId`, so a body can distinguish "no identity * envelope at all" from "an anonymous caller" the same way a hook does - * (#3712). One consequence worth knowing: `roles` can never appear on its own, - * because a context with positions but no user and no org yields no session. + * (#3712). One consequence worth knowing: neither `positions` nor `roles` can + * ever appear on its own, because a context with positions but no user and no + * org yields no session at all. * * ## NOT the hook `ctx.session` * @@ -190,8 +214,15 @@ export interface ActionEngineFacade { * with a different key set (`actor`, `accessToken`, `isSystem`, the skip flags) * from a different producer (ObjectQL's `buildSession()`). The * `buildActionSession()` docblock still says it mirrors the hook shape; that - * sentence stopped being true at #5050, and correcting it rides with the - * phase-2 rename (#5613), not with this declaration. + * sentence stopped being true at #5050, and correcting it belongs to #5613's + * runtime half — it is a statement about the PRODUCER, so it rides with the + * producer change, not with this contract. + * + * The two sessions do now agree on one thing, which is the point of the rename + * rather than a coincidence: `HookContext.session.positions` was declared in + * #5605 (PR #5722) with the same ADR-0090 D3 vocabulary and the same + * "descriptive, never an authorization input" boundary. After the window + * closes, one platform will have one spelling for one value on both surfaces. * * ## Deliberately NOT strict * @@ -221,24 +252,80 @@ export const ActionSessionSchema = lazySchema(() => z.object({ organizationId: z.string().optional().describe('Active organization id (blessed developer-facing name; absent when the call is org-less)'), /** - * @deprecated ADR-0090 D3 — the forbidden spelling of `positions`. Declared - * because the runtime produces it, not because the name is blessed. The - * rename is #5613 phase 2; do NOT add a `positions` key here ahead of it. + * Position names held by the caller — the CANONICAL spelling of this value + * at the action boundary, and the key an action body should read. Sourced + * verbatim from `ExecutionContext.positions` (ADR-0090 D3; that schema's own + * comment reads "Formerly `roles`"), so the action surface now speaks the + * same vocabulary as the execution context, the sharing service and — since + * #5605 / PR #5722 — the hook `ctx.session`. + * + * Added by #5613 phase 2's spec half (#5779), which is what lifted phase 1's + * deliberate prohibition on minting this key early. The prohibition was + * never about the name: it was about opening a two-spelling window without a + * closing date. This key opens it WITH one — see the deprecated `roles` + * below and the ADR-0087 semantic migration `action-session-roles-to-positions`. + * + * ## Presence, during the window + * + * Once #5613's runtime half lands, `buildActionSession()` emits the same + * array under both keys, so a body reading either sees identical values and + * migrating is a change of key and nothing else. Until it lands the producer + * still emits only `roles` — the contract leads, the producer follows — so + * treat this key as MEANING-fixed but not yet presence-guaranteed. A reader + * that must work across both sides of that seam reads `positions` and falls + * back to `roles` for the window's duration only; that fallback expires with + * the alias and must not outlive it. + * + * ⚠️ Never gate PRIVILEGE on this array — ask the security service, which + * evaluates capability grants, placements and the derived posture + * (ADR-0095), never a name-string comparison. The caution belongs to the + * VALUE, not to the spelling, so it survives the rename intact: an author + * who rewrites `roles.includes('admin')` as `positions.includes('admin')` + * has migrated the defect rather than the read. + */ + positions: z.array(z.string()).optional().describe( + 'Position names held by the caller (ADR-0090 D3 vocabulary; the value of ' + + '`ExecutionContext.positions`, whose schema comment reads "Formerly `roles`") — the CANONICAL ' + + 'spelling at this boundary and the key an action body should read. Within the #5613 ' + + 'deprecation window `buildActionSession()` emits the same array under both this key and the ' + + 'deprecated `roles`, so migrating is a change of key and nothing else; `roles` is then removed ' + + 'on the #3280 → #3290 `session.tenantId` alias precedent path (deprecate, one window, remove). ' + + 'Never gate PRIVILEGE on this array — ask the security service, which evaluates capability ' + + 'grants, placements and the derived posture (ADR-0095), never a position-name string ' + + 'comparison.', + ), + + /** + * @deprecated Use `positions`. Deprecated ALIAS — the same array under the + * one spelling ADR-0090 D3 forbids. Migration prescription and acceptance + * criteria: the ADR-0087 semantic migration + * `action-session-roles-to-positions`. Removal follows the `session.tenantId` + * alias precedent (#3280 deprecated → #3290 removed in v11), i.e. one + * deprecation window after #5613's runtime half lands, not before. + * + * Why it is still declared at all: it is what the runtime produces today, + * and the entire point of a deprecation window is that a body reading it + * keeps working while its author migrates. Phase 1 (#5697) declared it as + * current reality — declaring is not endorsing — and every reason it must + * not survive the window is unchanged: ADR-0090 D3 makes `role` a + * reserved-forbidden word, #4839 deleted the last two + * `roles.includes('admin')` readers, and #5050 retired the hook-side + * `HookContext.session.roles` outright, so until this closes a body author + * still meets two different answers to one key name on one platform. + * + * ⚠️ Never gate PRIVILEGE on this array — see `positions` above. The caution + * is a property of the value, so it applies identically to both spellings. */ roles: z.array(z.string()).optional().describe( - 'DEPRECATED — the VALUE is the caller\'s ADR-0090 D3 `positions` ' - + '(`ExecutionContext.positions`, "Formerly `roles`"), delivered at this boundary under the one ' - + 'spelling that vocabulary forbids. Declared here because `buildActionSession()` produces it ' - + 'today — declaring current reality is not endorsing the name: ADR-0090 D3 makes `role` a ' - + 'reserved-forbidden word, #4839 deleted the last two `roles.includes(\'admin\')` readers, and ' - + '#5050 retired the hook-side `HookContext.session.roles` outright, so a body author currently ' - + 'meets two different answers to one key name on one platform. The rename to `positions` — with ' - + 'its deprecation window, ADR-0087 semantic migration and the `buildActionSession()` comment ' - + 'correction — is #5613 phase 2. There is deliberately NO `positions` key on this shape yet: ' - + 'minting one before the migration would ship two live spellings of one value, which is the ' - + 'defect, not the fix. Never gate PRIVILEGE on this array — ask the security service, which ' - + 'evaluates capability grants, placements and the derived posture (ADR-0095), never a ' - + 'role-name string comparison.', + 'DEPRECATED alias of `positions` — the same caller position names under the one spelling ' + + 'ADR-0090 D3 forbids (the value is `ExecutionContext.positions`, "Formerly `roles`"). Read ' + + '`positions` instead: within the #5613 deprecation window `buildActionSession()` emits both ' + + 'keys with identical values, so migrating is a change of key and nothing else. The migration ' + + 'prescription and its acceptance criteria are the ADR-0087 semantic migration ' + + '`action-session-roles-to-positions`; removal follows the `session.tenantId` alias precedent ' + + 'path (#3280 deprecated → #3290 removed in v11). Never gate PRIVILEGE on this array — ask the ' + + 'security service, which evaluates capability grants, placements and the derived posture ' + + '(ADR-0095), never a role-name string comparison.', ), }).describe('Action-body `ctx.session` — the caller identity an action body reads (runtime shape, never authored)')); From 575a1181284697cf45cc5a498442704b5f361f3a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 08:51:32 +0000 Subject: [PATCH 2/2] fix(spec): keep the removed `session.tenantId` token out of published describe text (#5779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:org-identifier` is a hard-fail guard, and it skips COMMENT lines but not code — so naming the removed alias in a JSDoc note is documentation, while naming it inside a `.describe()` string or a migration `reason` is a published string the gate rightly refuses. Those strings are not incidental: a `.describe()` lands verbatim in `content/docs/references/`, which is exactly the author-facing prose an AI copies a hook or action body from, and the guard exists because a `session.tenantId` read there resolves to `undefined` (#3290 removed it in v11). Three occurrences reworded to "the v11 session-alias removal path", keeping the #3280 → #3290 anchor that makes the precedent findable: two `.describe()` strings on ActionSession (`positions`, `roles`) and the migration entry's `reason`. The JSDoc mentions are left as they are — the gate permits them by design, and the pre-existing `organizationId` docblock already names the alias the same way. `os-allow-tenant-id` was deliberately NOT used: it is documented for the rare genuine driver-layer read, and spending it on prose would launder a documentation reference past a guard that has zero baselined occurrences. Generated artifacts regenerated from the reworded source; the `#3290` assertion in the description pins still holds. Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D Co-authored-by: Claude --- content/docs/references/ui/action-params.mdx | 4 ++-- docs/protocol-upgrade-guide.md | 4 ++-- packages/spec/spec-changes.json | 4 ++-- packages/spec/src/migrations/registry.ts | 2 +- packages/spec/src/ui/action-params.zod.ts | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/content/docs/references/ui/action-params.mdx b/content/docs/references/ui/action-params.mdx index 13dab1d011..724d27fc4d 100644 --- a/content/docs/references/ui/action-params.mdx +++ b/content/docs/references/ui/action-params.mdx @@ -69,8 +69,8 @@ Action-body `ctx.session` — the caller identity an action body reads (runtime | :--- | :--- | :--- | :--- | | **userId** | `string` | optional | Invoking user id (absent when the call carries no user) | | **organizationId** | `string` | optional | Active organization id (blessed developer-facing name; absent when the call is org-less) | -| **positions** | `string[]` | optional | Position names held by the caller (ADR-0090 D3 vocabulary; the value of `ExecutionContext.positions`, whose schema comment reads "Formerly `roles`") — the CANONICAL spelling at this boundary and the key an action body should read. Within the #5613 deprecation window `buildActionSession()` emits the same array under both this key and the deprecated `roles`, so migrating is a change of key and nothing else; `roles` is then removed on the #3280 → #3290 `session.tenantId` alias precedent path (deprecate, one window, remove). Never gate PRIVILEGE on this array — ask the security service, which evaluates capability grants, placements and the derived posture (ADR-0095), never a position-name string comparison. | -| **roles** | `string[]` | optional | DEPRECATED alias of `positions` — the same caller position names under the one spelling ADR-0090 D3 forbids (the value is `ExecutionContext.positions`, "Formerly `roles`"). Read `positions` instead: within the #5613 deprecation window `buildActionSession()` emits both keys with identical values, so migrating is a change of key and nothing else. The migration prescription and its acceptance criteria are the ADR-0087 semantic migration `action-session-roles-to-positions`; removal follows the `session.tenantId` alias precedent path (#3280 deprecated → #3290 removed in v11). Never gate PRIVILEGE on this array — ask the security service, which evaluates capability grants, placements and the derived posture (ADR-0095), never a role-name string comparison. | +| **positions** | `string[]` | optional | Position names held by the caller (ADR-0090 D3 vocabulary; the value of `ExecutionContext.positions`, whose schema comment reads "Formerly `roles`") — the CANONICAL spelling at this boundary and the key an action body should read. Within the #5613 deprecation window `buildActionSession()` emits the same array under both this key and the deprecated `roles`, so migrating is a change of key and nothing else; `roles` is then removed on the v11 session-alias removal path (#3280 deprecate → #3290 remove: one window, then gone). Never gate PRIVILEGE on this array — ask the security service, which evaluates capability grants, placements and the derived posture (ADR-0095), never a position-name string comparison. | +| **roles** | `string[]` | optional | DEPRECATED alias of `positions` — the same caller position names under the one spelling ADR-0090 D3 forbids (the value is `ExecutionContext.positions`, "Formerly `roles`"). Read `positions` instead: within the #5613 deprecation window `buildActionSession()` emits both keys with identical values, so migrating is a change of key and nothing else. The migration prescription and its acceptance criteria are the ADR-0087 semantic migration `action-session-roles-to-positions`; removal follows the v11 session-alias removal path (#3280 deprecated → #3290 removed). Never gate PRIVILEGE on this array — ask the security service, which evaluates capability grants, placements and the derived posture (ADR-0095), never a role-name string comparison. | --- diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index fd44b8b804..7c5adfaa30 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -170,7 +170,7 @@ Separately, `object.managedBy: 'system'` is retired in favour of `'system-data'` Finally, five keys retire because the advisory lint could never have warned about them (#4509): mapping `extractQuery` / `errorPolicy` / `batchSize`, and app `contextSelectors[].includeAll` / `.placement`. Four of the five carry schema DEFAULTS, and a default materialises at parse time — so the liveness lint cannot tell a value the author wrote from one the schema supplied, and marking them would have warned on every mapping and every selector in existence. For a key in that state removal is not the escalation after a warning; it is the only channel that ever reaches the author, which is why they ship inside the 17.0.0 window rather than after a deprecation cycle. What they claimed: `extractQuery` promised an export path no exporter implements (exports go through the ordinary query API); `errorPolicy` offered skip/abort/retry where error handling belongs to the import REQUEST; `batchSize` sized batches the write path sizes itself; `placement` offered a topbar that places nothing. `includeAll` is the one worth reading twice — it was not unread but deliberately DISOBEYED, because context selectors are mandatory-scope and an "All" row would clear the scope: on Studio's package selector that means listing the platform's own system/cloud kernel packages to a developer who scoped to their package. `STUDIO_APP` authored `includeAll: true` against a renderer that ignored it. The mapping prescription for `batchSize` deliberately offers no rename: bulk-action, connector, sync, offline, seed-loader and NoSQL-cursor `batchSize` are all live, but each is a different key sizing its own path — the same trap `datasource.retryPolicy` vs `hook`/`job` `retryPolicy` had to defuse one issue earlier. -The sharpest removal in this step is two keys wide: `app.areas[].visible` and `app.areas[].requiredPermissions` (#4651). Read the class before the count — these were not inert authoring keys but FAIL-OPEN access gates. The server-side authority (`filterAppForUser`) checks the app's `requiredPermissions` and then walks ONLY the top-level `navigation` tree; it never reads `item.areas` at all, and the client renders every area in the switcher. So an author writing `requiredPermissions: ['sales.admin']` on an area got a clean parse, a stored value, and an area visible to everybody — and had every reason to believe otherwise, because the SAME key names are genuinely enforced one level up and one level down: app-level `requiredPermissions` drops the whole app server-side, and a navigation ITEM's `requiredPermissions` / `requiresService` are stripped server-side and re-checked in the shell, whose item-level `visible` is a real CEL gate. Three layers, of which the middle one was theatre. Enforcing instead was weighed and deliberately not taken here: it needs semantics decided first (does filtering an area remove its items everywhere? does the server bind `user` for area CEL?), and a retirement must not invent an authorization mechanism — while shipping a major with the gate still declared would have kept authors writing it for all of 17.x. The rewrite is lossless in outcome (the keys changed nothing), so what an upgrading author has to re-decide is only where the gate really goes: onto the items inside the area, or onto the app. One honest caveat the prescription carries rather than hides — per-item gating INSIDE an area is enforced by the shell only, since the server does not walk `areas`, so anything that must never reach the browser belongs in the top-level tree or in its own app. +The sharpest removal in this step is two keys wide: `app.areas[].visible` and `app.areas[].requiredPermissions` (#4651). Read the class before the count — these were not inert authoring keys but FAIL-OPEN access gates. At the time of the retirement the server-side authority (`filterAppForUser`) checked the app's `requiredPermissions` and then walked ONLY the top-level `navigation` tree; it never read `item.areas` at all, and the client rendered every area in the switcher. So an author writing `requiredPermissions: ['sales.admin']` on an area got a clean parse, a stored value, and an area visible to everybody — and had every reason to believe otherwise, because the SAME key names are genuinely enforced one level up and one level down: app-level `requiredPermissions` drops the whole app server-side, and a navigation ITEM's `requiredPermissions` / `requiresService` are stripped server-side and re-checked in the shell, whose item-level `visible` is a real CEL gate. Three layers, of which the middle one was theatre. Enforcing instead was weighed and deliberately not taken here: it needs semantics decided first (does filtering an area remove its items everywhere? does the server bind `user` for area CEL?), and a retirement must not invent an authorization mechanism — while shipping a major with the gate still declared would have kept authors writing it for all of 17.x. The rewrite is lossless in outcome (the keys changed nothing), so what an upgrading author has to re-decide is only where the gate really goes: onto the items inside the area, or onto the app — and BOTH of those destinations are server-enforced. The caveat this prescription used to carry, that per-item gating INSIDE an area was enforced by the shell only because the server did not walk `areas`, was CLOSED by #4722 inside this same 17.0.0 window: `filterAppForUser` now runs the SAME `filterNav` over every `areas[].navigation`, so an ITEM's `requiredPermissions` / `requiresService` is stripped server-side in BOTH trees and a gated entry never ships in the `/meta` body at all. Read that as the boundary closing, NOT as the area-LEVEL keys coming back: those stay retired and #4722 gave an area no gate of its own — what it enforces are the items inside one. The other half of the asymmetry is unchanged and is why `requiredPermissions` is the key to reach for: `visible` (CEL) and `requiresObject` are still evaluated client-side ONLY at every level, because server-side CEL needs a bound `user` context the read layer does not have — so anything that must never reach the browser goes in `requiredPermissions`, never in `visible`. The same window converges the retry policy (#4661). `@objectstack/spec/automation` and `@objectstack/spec/system` each exported a `RetryPolicy`/`RetryPolicySchema` resolving to a DIFFERENT declaration, so which shape a consumer got depended only on the import path (#4411) — yet both computed `delay = base * multiplier^(retry-1)` and both executors implemented that same formula. One declaration now serves both entries with the union of their capabilities, so `job.retryPolicy` gains the `maxRetryDelayMs` ceiling and `jitter` (both enforced in `runWithPolicy`, not merely declared — jitter is what stops a fleet of jobs that failed on one outage from retrying in lockstep). The single authorable casualty is the automation spelling of the base delay: `retryDelayMs` → `backoffMs`, a pure rename that replays losslessly and is what the already-enforced retry policies (`job.retryPolicy`, `hook.retryPolicy`) call it. @@ -337,7 +337,7 @@ The last enforce-or-remove entry of this step is on the RUNTIME context rather t - Why not automatic: Declared on the runtime hook context, read by exactly two consumers, produced by nobody. The two readers were the approvals record lock and the delegation write guard, each opening with `session.roles?.includes('admin')`; ObjectQL's `buildSession()` builds the session field by field and has never written `roles`, and nothing else feeds a HookContext in objectstack, cloud or objectui (cloud's hook consumers read `hookContext?.session?.userId`; objectui's `roles` are the `/auth/me` user payload, a different surface; an ACTION body's `ctx.session` is a different untyped object that does carry `roles`, tracked apart and unaffected). Both branches were therefore dead on every real engine path — an authorization decision in shape only, and a second admin dialect competing with the one ADR-0090 D3 / ADR-0095 D3 sanction. #4839 (PR #5049) removed the readers; this removes the declaration, per ADR-0049 enforce-or-remove. This is a RUNTIME context, not stored metadata: the engine builds a HookContext per operation and nothing persists one, so no `sys_metadata` row, example or template can carry the key and there is no source for the D2 chain to rewrite — the `openApi31` (#4579) / `activationEvents` (#4657) shape, one semantic TODO rather than a stack conversion. The key IS tombstoned (`HookContextSchema` is deliberately not `.strict()` — a plain delete would strip it silently, #3733 / ADR-0104), so a consumer that parses a context it was handed still meets the prescription. ADR-0049, #5050. - Done when: No hook reads `ctx.session.roles`; caller gating uses `ctx.session.userId` / `ctx.session.isSystem`, and privilege comes from the security service (`permissions` / `positions` / posture). Constructing a HookContext session with `roles` fails `tsc` (the input type is `never`) and fails `HookContextSchema.parse` with the retirement prescription instead of being silently stripped. Nothing regresses at runtime: the key had no producer, so no decision anywhere ever saw a value in it. - **`action-session-roles-to-positions`** — `ui.actionSession.roles` → ui.actionSession.positions (an action body reads `ctx.session.positions`) - - Why not automatic: The MIRROR-IMAGE neighbour of the entry above, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: "C skeleton + A semantics"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path `session.tenantId` already walked (#3280 deprecated → #3290 removed in v11). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779. + - Why not automatic: The MIRROR-IMAGE neighbour of the entry above, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: "C skeleton + A semantics"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path the v11 session-alias removal already walked (#3280 deprecated → #3290 removed). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779. - Done when: No action body reads `ctx.session.roles`; every such read is `ctx.session.positions` and observes the same array (the rename is a rename — the VALUE is `ExecutionContext.positions` on both sides, which the runtime pin `action-session-shape-contract.test.ts` asserts independently of the key name). Privilege is NOT re-derived from either spelling: a read that was `roles.includes('admin')` as an access check is rewritten to ask the security service (capability grants / placements / derived posture, ADR-0095), never renamed to `positions.includes('admin')` — renaming that read migrates the defect rather than the code. Verify against a real dispatch, not a fixture: invoke an action as a caller holding positions and assert the body observed them under the canonical key. During the window both keys are present and equal, so a reader can be migrated and verified before the alias is removed; after it, `roles` is absent and a body still reading it sees `undefined` — which is why the read must be moved inside the window rather than at its close. --- diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index dea1b1ac41..9607273f0d 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -585,7 +585,7 @@ "replacement": "ui.actionSession.positions (an action body reads `ctx.session.positions`)", "migrationId": "action-session-roles-to-positions", "toMajor": 17, - "rationale": "The MIRROR-IMAGE neighbour of the entry above, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: \"C skeleton + A semantics\"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path `session.tenantId` already walked (#3280 deprecated → #3290 removed in v11). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779." + "rationale": "The MIRROR-IMAGE neighbour of the entry above, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: \"C skeleton + A semantics\"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path the v11 session-alias removal already walked (#3280 deprecated → #3290 removed). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779." } ], "removed": [] @@ -1230,7 +1230,7 @@ "replacement": "ui.actionSession.positions (an action body reads `ctx.session.positions`)", "migrationId": "action-session-roles-to-positions", "toMajor": 17, - "rationale": "The MIRROR-IMAGE neighbour of the entry above, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: \"C skeleton + A semantics\"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path `session.tenantId` already walked (#3280 deprecated → #3290 removed in v11). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779." + "rationale": "The MIRROR-IMAGE neighbour of the entry above, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: \"C skeleton + A semantics\"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path the v11 session-alias removal already walked (#3280 deprecated → #3290 removed). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779." } ], "removed": [] diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 0fa15547b9..4994eff886 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1899,7 +1899,7 @@ const step17: MigrationStep = { + 'and phase 2 renames the key. `positions` is now the canonical key on that schema ' + 'and `roles` a deprecated alias of it (#5779); the producer emits both for one ' + 'deprecation window (#5613 runtime half), after which `roles` is removed on the path ' - + '`session.tenantId` already walked (#3280 deprecated → #3290 removed in v11). ' + + 'the v11 session-alias removal already walked (#3280 deprecated → #3290 removed). ' + 'Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: ' + 'FIRST, there is no source to convert — an action `ctx.session` is constructed per ' + 'dispatch and never persisted, so no `sys_metadata` row, example or template can ' diff --git a/packages/spec/src/ui/action-params.zod.ts b/packages/spec/src/ui/action-params.zod.ts index b13ee04a7e..a745851555 100644 --- a/packages/spec/src/ui/action-params.zod.ts +++ b/packages/spec/src/ui/action-params.zod.ts @@ -289,7 +289,7 @@ export const ActionSessionSchema = lazySchema(() => z.object({ + 'spelling at this boundary and the key an action body should read. Within the #5613 ' + 'deprecation window `buildActionSession()` emits the same array under both this key and the ' + 'deprecated `roles`, so migrating is a change of key and nothing else; `roles` is then removed ' - + 'on the #3280 → #3290 `session.tenantId` alias precedent path (deprecate, one window, remove). ' + + 'on the v11 session-alias removal path (#3280 deprecate → #3290 remove: one window, then gone). ' + 'Never gate PRIVILEGE on this array — ask the security service, which evaluates capability ' + 'grants, placements and the derived posture (ADR-0095), never a position-name string ' + 'comparison.', @@ -322,8 +322,8 @@ export const ActionSessionSchema = lazySchema(() => z.object({ + '`positions` instead: within the #5613 deprecation window `buildActionSession()` emits both ' + 'keys with identical values, so migrating is a change of key and nothing else. The migration ' + 'prescription and its acceptance criteria are the ADR-0087 semantic migration ' - + '`action-session-roles-to-positions`; removal follows the `session.tenantId` alias precedent ' - + 'path (#3280 deprecated → #3290 removed in v11). Never gate PRIVILEGE on this array — ask the ' + + '`action-session-roles-to-positions`; removal follows the v11 session-alias removal path ' + + '(#3280 deprecated → #3290 removed). Never gate PRIVILEGE on this array — ask the ' + 'security service, which evaluates capability grants, placements and the derived posture ' + '(ADR-0095), never a role-name string comparison.', ),