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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .changeset/action-session-positions-canonical-roles-alias.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion content/docs/references/ui/action-params.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |


---
Expand Down
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading