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..724d27fc4d 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 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 29bef973dd..7c5adfaa30 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 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/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..9607273f0d 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 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": [] @@ -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 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 6b0c49d558..4994eff886 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -1881,6 +1881,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 ' + + '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.', + 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..a745851555 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 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.', + ), + + /** + * @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 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.', ), }).describe('Action-body `ctx.session` — the caller identity an action body reads (runtime shape, never authored)'));