Skip to content

Commit d4e0809

Browse files
os-zhuangclaude
andauthored
feat(spec): ActionSession 补 positions 权威键,roles 降为弃用别名 (#5779) (#5849)
* feat(spec): ActionSession declares `positions` as canonical, `roles` as its deprecated alias (#5779) 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 <noreply@anthropic.com> * fix(spec): keep the removed `session.tenantId` token out of published describe text (#5779) `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 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 72c3c86 commit d4e0809

8 files changed

Lines changed: 400 additions & 25 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `ActionSession` declares `positions` as canonical and deprecates `roles` (#5779)
6+
7+
The action-body `ctx.session` contract gains `positions`, the ADR-0090 D3 spelling
8+
of the caller's position names, and demotes `roles` to a deprecated alias of it.
9+
This is the **spec half** of #5613 phase 2, under the maintainer's contract-first
10+
ruling ("C skeleton + A semantics"): phase 1 (#5697) declared the shape the runtime
11+
already built, and this opens the rename on top of that declaration.
12+
13+
**What was wrong.** `buildActionSession()` copies `ExecutionContext.positions` into
14+
a key spelled `roles` — the one spelling ADR-0090 D3 bans — so an author met two
15+
different answers to one key name on one platform: `session.roles` is rejected in a
16+
hook (retired in #5050) and live, populated, and load-bearing in an action body.
17+
Phase 1 declared that reality without endorsing it and deliberately withheld a
18+
`positions` key, because minting a second live spelling with no closing date is the
19+
defect rather than the fix. This change mints it **with** a closing date.
20+
21+
**Migration prescription — do this now.**
22+
23+
- Read `ctx.session.positions`. It is the canonical key and it carries exactly the
24+
array `roles` carried; the rename is a rename, not a semantic change.
25+
- `ctx.session.roles` still resolves for the length of the deprecation window and
26+
is removed after it, on the path `session.tenantId` already walked (#3280
27+
deprecated, #3290 removed in v11). A body still reading it at that point sees
28+
`undefined` with nothing to catch the change — which is why the read moves inside
29+
the window, not at its close.
30+
- Do **not** migrate an access check by renaming it. `roles.includes('admin')`
31+
rewritten as `positions.includes('admin')` migrates the defect: neither array is
32+
an authorization input. Privilege is judged by the security service, which
33+
evaluates capability grants, placements and the derived posture (ADR-0095).
34+
35+
**Sequencing — the contract leads its producer.** This release ships the contract
36+
only. The producer change (`buildActionSession()` emitting both keys, plus the two
37+
already-tracked wrong sentences in its docblock) is #5613's runtime half and lands
38+
separately. Until it does, a built session still carries only `roles`, so
39+
`positions` is meaning-fixed but not yet presence-guaranteed; both keys are
40+
optional, which is what lets the declaration lead without breaking anything. A
41+
reader that must straddle the seam may read `positions` and fall back to `roles`
42+
for the window's duration only — that fallback expires with the alias.
43+
44+
Additive and non-breaking on its own: adding an optional key rejects nothing that
45+
parsed before, and the runtime consistency pin
46+
(`packages/runtime/src/action-session-shape-contract.test.ts`) is unchanged and
47+
still green.
48+
49+
The reader-facing announcement is the ADR-0087 semantic migration
50+
`action-session-roles-to-positions`, which carries the prescription above and its
51+
acceptance criteria into `spec-changes.json`, the generated upgrade guide and the
52+
`spec_changes` MCP tool.

content/docs/references/ui/action-params.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ Action-body `ctx.session` — the caller identity an action body reads (runtime
6969
| :--- | :--- | :--- | :--- |
7070
| **userId** | `string` | optional | Invoking user id (absent when the call carries no user) |
7171
| **organizationId** | `string` | optional | Active organization id (blessed developer-facing name; absent when the call is org-less) |
72-
| **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. |
72+
| **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. |
73+
| **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. |
7374

7475

7576
---

docs/protocol-upgrade-guide.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,9 @@ Finally it retires the two inert `IndexSchema` keys, `indexes[].type` and `index
339339
- **`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)
340340
- 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.
341341
- 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.
342+
- **`action-session-roles-to-positions`**`ui.actionSession.roles` → ui.actionSession.positions (an action body reads `ctx.session.positions`)
343+
- 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.
344+
- 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.
342345

343346
---
344347

packages/spec/authorable-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6927,6 +6927,7 @@
69276927
"ui/ActionParam:type",
69286928
"ui/ActionParam:visible",
69296929
"ui/ActionSession:organizationId",
6930+
"ui/ActionSession:positions",
69306931
"ui/ActionSession:roles",
69316932
"ui/ActionSession:userId",
69326933
"ui/AddRecordConfig:enabled",

0 commit comments

Comments
 (0)