Skip to content

Commit 739f496

Browse files
baozhoutaoclaude
andauthored
feat(runtime)!: action ctx.session 双发 positions(权威)+ roles(弃用别名) (#5613) (#5991)
* feat(runtime)!: action ctx.session 双发 positions(权威)+ roles(弃用别名) (#5613) buildActionSession() 现同时输出 `positions`(ADR-0090 D3 权威拼法)与 `roles`(同值弃用别名),弃用窗口由 ADR-0087 语义迁移 `action-session-roles-to-positions` 声明;并修正 docblock 中「mirroring hook ctx.session」的失实自述(hook 侧该键已于 #5050 退役),改为如实指向 ActionSessionSchema 与迁移条目。 - 翻转 action-session-shape-contract.test.ts 的预埋键集断言,并断言双发同值; - 新增真实 dispatch 验证(http-dispatcher.test.ts),按迁移条目的验收口径; - ScriptContext.session 由 `unknown` 收窄为 ScriptSession = ActionSession | HookContext['session'] 的联合(两个真实生产者形状),不收窄为 ActionSession 单型——该 seam 确实承载 hook session。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DWUR56YsttL5sTF72Q75TQ * fix(skills): action ctx.session 段不新增 ADR-0090 D3 保留词(check:role-word 红) CI 的 check:role-word 是 shrink-only 棘轮:skills/objectstack-ui/SKILL.md 的保留词计数由 2 涨到 5(弃用别名示例 + 迁移条目 id + 反例代码各一)。 按 ADR-0090 D3 的本意,技能文档只教权威拼法 `ctx.session.positions`, 别名与迁移处方留在其本来的渠道(changeset + protocol-upgrade-guide, 均不在该棘轮扫描面内),正文以 `action-session-*-to-positions` 指路。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DWUR56YsttL5sTF72Q75TQ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b5bdf48 commit 739f496

8 files changed

Lines changed: 255 additions & 49 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/runtime": major
3+
---
4+
5+
feat(runtime)!: action body `ctx.session` emits `positions` (canonical) alongside the deprecated `roles` (#5613)
6+
7+
`buildActionSession()` — the one producer of the action-body `ctx.session` — now
8+
emits the caller's position names under **both** `positions` (canonical) and
9+
`roles` (deprecated alias), with the same array under both names. This is the
10+
**runtime half** of #5613 phase 2 under the maintainer's contract-first ruling
11+
("C skeleton + A semantics"); the spec half (#5779) declared the shape this now
12+
produces, and phase 1 (#5697) declared the shape it produced before.
13+
14+
**What was wrong.** The builder copied `ExecutionContext.positions` into a key
15+
spelled `roles` — the one spelling ADR-0090 D3 bans — while its own docblock
16+
claimed it "mirrors the hook `ctx.session` shape". That sentence stopped being
17+
true at #5050, which retired `HookContext.session.roles` outright: a body author
18+
met two different answers to one key name on one platform, and the comment
19+
pointed at the wrong one. The key set reached no schema and no gate until #5697,
20+
so nothing could see it drift.
21+
22+
**Migration prescription — do this now.**
23+
24+
- Read `ctx.session.positions`. It carries exactly the array `ctx.session.roles`
25+
carried (`ExecutionContext.positions` — the rename is a rename, not a semantic
26+
change), and it is the spelling the platform now uses everywhere: the
27+
execution context, the sharing service, `ctx.user.positions`, and the hook
28+
`ctx.session.positions` (#5605).
29+
- `ctx.session.roles` still resolves for the length of the deprecation window
30+
announced by the ADR-0087 semantic migration
31+
`action-session-roles-to-positions`, and is then removed on the path
32+
`session.tenantId` already walked (#3280 deprecated → #3290 removed in v11). A
33+
body still reading it at that point sees `undefined` with nothing to catch the
34+
change — which is why the read moves **inside** the window, not at its close.
35+
- Do **not** migrate an access check by renaming it. `roles.includes('admin')`
36+
rewritten as `positions.includes('admin')` migrates the defect: neither array
37+
is an authorization input. Privilege is judged by the security service, which
38+
evaluates capability grants, placements and the derived posture (ADR-0095).
39+
- Presence semantics are unchanged: a context with no positions (or a non-array
40+
`positions`) yields **neither** key — `'positions' in ctx.session` answers
41+
`false` exactly when `'roles' in ctx.session` does — and a call with no
42+
identity envelope still yields no session at all rather than `{}` (#3712).
43+
44+
**Also breaking, for TypeScript consumers of the sandbox seam.**
45+
`ScriptContext.session` (`@objectstack/runtime`, `sandbox/script-runner.ts`) was
46+
`unknown` and is now the exported union `ScriptSession = ActionSession |
47+
HookContext['session']` — the two declared producer shapes this one seam
48+
actually carries. Code that read an arbitrary property off it must now
49+
discriminate the body kind (or read one of the keys both shapes declare:
50+
`userId`, `organizationId`, `positions`). It is deliberately **not** narrowed to
51+
`ActionSession` alone: the seam really does carry hook sessions, and declaring
52+
otherwise would re-create the "one key, two realities" defect this change
53+
closes.
54+
55+
The consistency between what the producer builds and what `ActionSessionSchema`
56+
declares stays pinned in
57+
`packages/runtime/src/action-session-shape-contract.test.ts`, and the observed
58+
shape is verified through a real dispatch in `http-dispatcher.test.ts`.

‎packages/runtime/src/action-execution.ts‎

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -739,38 +739,69 @@ export function enforceActionParams(deps: ActionExecutionDeps,
739739
}
740740

741741
/**
742-
* Build the action-body `ctx.session` from the request ExecutionContext,
743-
* mirroring the hook `ctx.session` shape (#3280) so an action author reads
744-
* the caller's active org under the SAME blessed name as a hook author.
742+
* Build the action-body `ctx.session` from the request ExecutionContext.
743+
*
744+
* The shape this returns is DECLARED by {@link ActionSessionSchema}
745+
* (`@objectstack/spec/ui`), for which this function is the ONLY producer —
746+
* #5697 declared the shape as it stood, #5779 added the canonical `positions`
747+
* key and demoted `roles` to a deprecated alias, and this function is the
748+
* matching producer half (#5613). The consistency between what is declared and
749+
* what is built is pinned in `action-session-shape-contract.test.ts`; that pin,
750+
* not this comment, is what keeps the two from drifting.
745751
*
746752
* `organizationId` is the blessed name for the caller's active org — the
747753
* same value as the `organization_id` column and `current_user.organizationId`
748754
* (RLS). The deprecated `session.tenantId` alias (#3280) was removed in v11
749755
* (#3290); the driver-layer `ExecutionContext.tenantId` it is sourced from is
750-
* a distinct, configurable axis and stays. Returns undefined for a genuinely
751-
* context-less / self-invoked call so a body can distinguish "no session" the
752-
* same way hooks do.
756+
* a distinct, configurable axis and stays. Returns `undefined` — never `{}` —
757+
* for a genuinely context-less / self-invoked call, so a body can tell "no
758+
* identity envelope at all" from "an anonymous caller" (#3712).
759+
*
760+
* ## `positions` is canonical, `roles` is its deprecated alias (#5613)
761+
*
762+
* Both keys carry the SAME array, `ExecutionContext.positions` — the ADR-0090
763+
* D3 vocabulary. `positions` is the spelling an action body should read.
764+
* `roles` is emitted only for the length of the deprecation window announced by
765+
* the ADR-0087 semantic migration `action-session-roles-to-positions`, and is
766+
* then removed on the path `session.tenantId` already walked (#3280 deprecate →
767+
* #3290 remove). Emitting both, with identical values, is precisely what makes
768+
* the migration a change of key and nothing else — a body can be moved to
769+
* `positions` and verified while the alias is still live.
770+
*
771+
* The conditional spread is unchanged, so absent still means the KEY IS ABSENT:
772+
* a context with no positions (or a non-array `positions`) yields NEITHER key,
773+
* and since a session is only built at all when the context carries a user or
774+
* an org, neither key can ever appear alone.
775+
*
776+
* ⚠️ Under either spelling this array is NOT an authorization input. Privilege
777+
* is judged by the security service — capability grants, placements, derived
778+
* posture (ADR-0095) — never by a name-string comparison; rewriting
779+
* `roles.includes('admin')` as `positions.includes('admin')` migrates the
780+
* defect rather than the read.
753781
*
754-
* [#5697] The shape this returns is now DECLARED — {@link ActionSessionSchema}
755-
* in `@objectstack/spec/ui`, phase 1 of #5613's contract-first ruling — and the
756-
* annotation here is that declaration, not a restatement of it. Nothing about
757-
* what this builds changed; the consistency between the two is pinned in
758-
* `action-session-shape-contract.test.ts`.
782+
* ## NOT the hook `ctx.session`
759783
*
760-
* ⚠️ Two sentences above are tracked as WRONG, deliberately left standing for
761-
* #5613 phase 2 to correct together with the rename they belong to: "mirroring
762-
* the hook `ctx.session` shape" has not been true since #5050 retired
763-
* `HookContext.session.roles` (the hook session is a different key set from a
764-
* different producer), and the `roles` key this builds carries `ec.positions`,
765-
* i.e. the ADR-0090 D3 vocabulary under the spelling that ADR bans. Read the
766-
* schema's docblock before relying on either.
784+
* This docblock used to say it mirrors the hook `ctx.session` shape (#3280).
785+
* That claim stopped being true at #5050, which retired
786+
* `HookContext.session.roles` outright: the hook session is a DIFFERENT key set
787+
* (`actor`, `accessToken`, `isSystem`, the skip flags) from a different producer
788+
* (ObjectQL's `buildSession()`), so "same key, two realities" was exactly the
789+
* hazard the sentence advertised as a convenience. The two surfaces do now
790+
* agree on `positions` — same vocabulary, same "descriptive, never an
791+
* authorization input" boundary (#5605 declared it hook-side) — which is the
792+
* POINT of this rename, not a coincidence.
767793
*/
768794
export function buildActionSession(_deps: ActionExecutionDeps, ec: any): ActionSession | undefined {
769795
if (!ec || (ec.userId == null && ec.tenantId == null)) return undefined;
770796
return {
771797
...(ec.userId != null ? { userId: String(ec.userId) } : {}),
772798
...(ec.tenantId != null ? { organizationId: String(ec.tenantId) } : {}),
773-
...(Array.isArray(ec.positions) && ec.positions.length ? { roles: ec.positions } : {}),
799+
// Dual-emitted for the #5613 deprecation window: `positions` canonical,
800+
// `roles` the alias, ONE array under two names (same value, by
801+
// construction — not two reads that could drift apart).
802+
...(Array.isArray(ec.positions) && ec.positions.length
803+
? { positions: ec.positions, roles: ec.positions }
804+
: {}),
774805
};
775806
}
776807

‎packages/runtime/src/action-session-shape-contract.test.ts‎

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* [#5697] The action-body `ctx.session` consistency pin — what
4+
* [#5697 / #5613] The action-body `ctx.session` consistency pin — what
55
* `buildActionSession()` BUILDS against what `ActionSessionSchema`
66
* (`@objectstack/spec/ui`) DECLARES.
77
*
@@ -12,6 +12,15 @@
1212
* runs the real producer — the `hook-input-shape-contract.test.ts` shape from
1313
* #5668, one surface over.
1414
*
15+
* Phase 2 has now landed on both sides: the spec half (#5779) added the
16+
* canonical `positions` key and demoted `roles` to a deprecated alias of it,
17+
* and the runtime half (#5613) made the producer DUAL-EMIT them. So the key-set
18+
* assertions below are no longer "what the builder happens to do" — they are
19+
* the deprecation window itself, pinned: both keys, same array, for exactly as
20+
* long as the ADR-0087 semantic migration `action-session-roles-to-positions`
21+
* says. When that window closes, `roles` comes out of the expectations here in
22+
* the same change that stops producing it.
23+
*
1524
* It lives in `packages/runtime` for the same reason that one lives in
1625
* `packages/objectql`: the pin must EXECUTE the producer, and `packages/spec`
1726
* cannot import the runtime without inverting the dependency.
@@ -37,16 +46,30 @@ const build = (ec: unknown) => buildActionSession(deps, ec as any);
3746

3847
describe('#5697 — action `ctx.session` matches its declared contract', () => {
3948
it('builds exactly the declared keys, and the declaration covers all of them', () => {
40-
const built = build({ userId: 'u_1', tenantId: 'org_acme', positions: ['sales_rep', 'org_admin'] });
49+
const positions = ['sales_rep', 'org_admin'];
50+
const built = build({ userId: 'u_1', tenantId: 'org_acme', positions });
51+
52+
// FLIPPED by #5613's runtime half. Before it, the builder emitted only
53+
// the deprecated `roles`; it now emits the canonical `positions` too,
54+
// which is what opens the migration window the spec half (#5779)
55+
// declared. Both keys, or this is not a window.
56+
expect(Object.keys(built!).sort()).toEqual(['organizationId', 'positions', 'roles', 'userId']);
4157

42-
// `roles`, not `positions` — the deprecated spelling is what the
43-
// builder emits today and what the contract therefore declares. This
44-
// assertion is the one #5613 phase 2 flips.
45-
expect(Object.keys(built!).sort()).toEqual(['organizationId', 'roles', 'userId']);
58+
// The window's load-bearing property: SAME VALUE under both spellings,
59+
// so migrating a body from `roles` to `positions` is a change of key
60+
// and nothing else. Asserted against `ec.positions` on both sides
61+
// rather than just key-to-key, so a builder that started deriving one
62+
// of them from something else could not satisfy it.
63+
expect((built as { positions?: string[] }).positions).toEqual(positions);
64+
expect((built as { roles?: string[] }).roles).toEqual(positions);
65+
expect((built as { roles?: string[] }).roles).toEqual((built as { positions?: string[] }).positions);
4666

4767
// Non-strict parse: an UNDECLARED key would be silently stripped here,
4868
// so deep equality — not `.success` — is what proves the contract
49-
// covers everything the producer emits.
69+
// covers everything the producer emits. This is also what proves the
70+
// spec half is actually in: before #5779 declared `positions`, a
71+
// dual-emitting builder would fail HERE (key stripped) rather than on
72+
// the key-set assertion above.
5073
expect(ActionSessionSchema.parse(built)).toEqual(built);
5174
expect(ActionSessionSchema.safeParse(built).success).toBe(true);
5275
});
@@ -66,15 +89,33 @@ describe('#5697 — action `ctx.session` matches its declared contract', () => {
6689
expect('tenantId' in built!).toBe(false);
6790
});
6891

69-
it('carries `ec.positions` verbatim under the deprecated `roles` spelling', () => {
92+
it('carries `ec.positions` verbatim under BOTH the canonical and the deprecated spelling', () => {
7093
const positions = ['sales_rep', 'org_admin'];
7194
const built = build({ userId: 'u_1', positions });
72-
// Phase 2 (#5613) renames the KEY; this pins that the VALUE is, and
95+
// Phase 2 (#5613) renamed the KEY; this pins that the VALUE is, and
7396
// stays, the ADR-0090 D3 vocabulary — so the rename is a rename and
7497
// not a semantic change smuggled inside one.
98+
expect((built as { positions?: string[] }).positions).toEqual(positions);
7599
expect((built as { roles?: string[] }).roles).toEqual(positions);
76100
expect(ActionSessionSchema.safeParse(built).success).toBe(true);
77101
});
102+
103+
it('emits the alias only alongside the canonical key — never `roles` on its own', () => {
104+
// The direction that matters when the window CLOSES: the removal
105+
// deletes `roles` from the producer and from the expectation above,
106+
// and this assertion is what says the canonical key was never the
107+
// thing that could go missing. A builder that regressed to alias-only
108+
// would satisfy the value assertions and fail here.
109+
for (const ec of [
110+
{ userId: 'u_1', positions: ['org_admin'] },
111+
{ tenantId: 'org_acme', positions: ['org_admin'] },
112+
{ userId: 'u_1', tenantId: 'org_acme', positions: ['org_admin'] },
113+
]) {
114+
const keys = Object.keys(build(ec)!);
115+
expect(keys).toContain('positions');
116+
expect(keys).toContain('roles');
117+
}
118+
});
78119
});
79120

80121
describe('#5697 — conditional-spread semantics: absent means the KEY is absent', () => {
@@ -94,15 +135,22 @@ describe('#5697 — conditional-spread semantics: absent means the KEY is absent
94135
it('omits `userId` entirely for an org-scoped call with no user', () => {
95136
const built = build({ tenantId: 'org_acme', positions: ['org_admin'] });
96137
expect('userId' in built!).toBe(false);
97-
expect(Object.keys(built!).sort()).toEqual(['organizationId', 'roles']);
138+
expect(Object.keys(built!).sort()).toEqual(['organizationId', 'positions', 'roles']);
98139
});
99140

100-
it('omits `roles` for an empty or absent positions array', () => {
141+
it('omits BOTH position spellings for an empty or absent positions array', () => {
142+
// The dual emission is one conditional spread, so the "non-empty only"
143+
// semantics is identical for the canonical key and the alias — neither
144+
// appears as an empty array, and `'positions' in ctx.session` answers
145+
// false exactly when `'roles' in ctx.session` does.
101146
expect(Object.keys(build({ userId: 'u_1', positions: [] })!)).toEqual(['userId']);
102147
expect(Object.keys(build({ userId: 'u_1' })!)).toEqual(['userId']);
103148
// A non-array `positions` is ignored rather than passed through — the
104149
// declared `string[]` would otherwise be a lie the parse catches.
105150
expect(Object.keys(build({ userId: 'u_1', positions: 'org_admin' })!)).toEqual(['userId']);
151+
for (const ec of [{ userId: 'u_1', positions: [] }, { userId: 'u_1' }, { userId: 'u_1', positions: 'org_admin' }]) {
152+
expect(ActionSessionSchema.parse(build(ec))).toEqual(build(ec));
153+
}
106154
});
107155
});
108156

@@ -114,9 +162,9 @@ describe('#5697 — no identity envelope yields NO session, never an empty one',
114162
])('%s → undefined', (_label, ec) => {
115163
// #3712's distinction, on the action side: a body can tell "no identity
116164
// envelope at all" from "an anonymous caller" only because this is
117-
// `undefined` rather than `{}`. The third row is why `roles` can never
118-
// appear alone — positions without a user and without an org produce no
119-
// session for it to appear on.
165+
// `undefined` rather than `{}`. The third row is why neither position
166+
// spelling can ever appear alone — positions without a user and without
167+
// an org produce no session for them to appear on.
120168
expect(build(ec)).toBeUndefined();
121169
});
122170
});

‎packages/runtime/src/http-dispatcher.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3643,6 +3643,28 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => {
36433643
expect(session.tenantId).toBeUndefined();
36443644
});
36453645

3646+
it('hands the body its positions under the canonical `positions`, with `roles` as the equal deprecated alias (#5613)', async () => {
3647+
// Verified through a REAL dispatch rather than by calling the builder — the
3648+
// ADR-0087 semantic migration `action-session-roles-to-positions` asks for
3649+
// exactly this shape of evidence: invoke an action as a caller holding
3650+
// positions, then assert what the BODY observed.
3651+
const { dispatcher, executeAction, ctx } = captureCtx({
3652+
userId: 'user_42',
3653+
positions: ['sales_rep', 'org_admin'],
3654+
tenantId: 'org_acme',
3655+
});
3656+
await dispatcher.handleActions('/lead/convert', 'POST', {}, ctx);
3657+
const session = actionSession(executeAction);
3658+
// The canonical key an action body should read (ADR-0090 D3 vocabulary).
3659+
expect(session.positions).toEqual(['sales_rep', 'org_admin']);
3660+
// The alias, dual-emitted with the SAME value for the length of the
3661+
// deprecation window — which is what makes migrating a body a change of
3662+
// key and nothing else. This assertion is removed by the change that
3663+
// stops producing `roles`, not before.
3664+
expect(session.roles).toEqual(session.positions);
3665+
expect(Object.keys(session).sort()).toEqual(['organizationId', 'positions', 'roles', 'userId']);
3666+
});
3667+
36463668
it('falls back to a `system` principal for a SELF-INVOKED call — the anonymous door is 401 now (#5519)', async () => {
36473669
// REPLACED, not re-spelled. This was driven with NO execution context —
36483670
// the shape an anonymous HTTP request has — and asserted over the action

‎packages/runtime/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export type {
146146
ScriptOrigin,
147147
ScriptResult,
148148
ScriptRunOptions,
149+
ScriptSession,
149150
QuickJSScriptRunnerOptions,
150151
} from './sandbox/index.js';
151152

‎packages/runtime/src/sandbox/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type {
99
ScriptOrigin,
1010
ScriptResult,
1111
ScriptRunOptions,
12+
ScriptSession,
1213
} from './script-runner.js';
1314
export { QuickJSScriptRunner, SandboxError } from './quickjs-runner.js';
1415
export type { QuickJSScriptRunnerOptions } from './quickjs-runner.js';

0 commit comments

Comments
 (0)