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 *
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
3847describe ( '#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
80121describe ( '#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} ) ;
0 commit comments