Skip to content

Commit e580aa4

Browse files
committed
docs(spec): register the ADR-0087 semantic entry for the positions[] meaning change
A meaning change with no key move: nothing here can be found by grepping for a removed spelling, and there is no schema rejection to catch a stale reader, so the ledger row is the only channel that reaches an upgrader. The acceptance criteria name the discriminator the defect itself hid behind — `org_admin` sits on both axes and cannot tell them apart — and require the check be made against a real session by a name that exists on one side only. Part of #15136 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
1 parent 18cfd84 commit e580aa4

2 files changed

Lines changed: 134 additions & 0 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'session-payload-positions-security-axis',
7+
surface: 'GET /api/v1/auth/get-session -> user.positions[] (and the client CEL root `current_user.positions` bound from it)',
8+
replacement:
9+
'the SAME key, carrying the SECURITY positions — the set `/auth/me/permissions` reports '
10+
+ 'and `resolveUserAuthzGrants` resolves. A reader that wanted the better-auth role '
11+
+ 'scalar reads `user.role`, which is unchanged and still published',
12+
reason:
13+
'A MEANING change, not a rename: no key moved, so nothing in this entry can be found by '
14+
+ 'grepping for a removed spelling — which is exactly why it needs a ledger row. '
15+
+ '`customSession` built `user.positions` from the better-auth `sys_user.role` scalar '
16+
+ 'split on commas, PLUS the active membership mapped to `org_*`, PLUS `platform_admin`, '
17+
+ 'and read NOTHING from `sys_user_position` (ADR-0057 D4), the source of truth for '
18+
+ 'custom positions. The Console binds that array straight through as the CEL root '
19+
+ '`current_user` (objectui `expressionUser.ts`: `positions: user.positions ?? []`), so '
20+
+ 'an `action.visible` / `visibleWhen` / nav `visible` narrowed by a business position '
21+
+ 'answered FALSE for EVERYONE, including the user who genuinely held it. '
22+
+ '⭐ The failure was silent and in the invisible direction: the root was bound and the '
23+
+ 'key was present, so `has(current_user.positions)` was true and CEL raised nothing — '
24+
+ 'the predicate simply returned FALSE. A predicate that FAULTS fails OPEN in the shell '
25+
+ 'and would have shown the button; a successful FALSE shows nothing and reports '
26+
+ 'nothing. The documented example `\'org_admin\' in current_user.positions` kept working '
27+
+ 'throughout, because `org_admin` is the one name that sits on BOTH axes — which is why '
28+
+ 'no example, test or doc could reveal the split. '
29+
+ 'This was a DECLARED contract being violated rather than an ambiguous name: '
30+
+ '`EvalUserSchema` already specified `positions` as "built-in identity names + position '
31+
+ 'names", exposed to "every predicate surface (server formula, server RLS, client UI '
32+
+ 'gates) ... with an identical shape" so a predicate "evaluates identically wherever it '
33+
+ 'is written". `/auth/me/permissions` and every server-side evaluator '
34+
+ '(`ExecutionContext.positions`) already resolved the security axis; the session '
35+
+ 'payload was the one producer that did not, because it derived the value itself '
36+
+ 'instead of asking the authority `resolve-authz-context.ts` reserves that job for. '
37+
+ '⚠️ NO renamed auth-role array accompanies this, and that is a measured disposition '
38+
+ 'rather than an omission: everything the old union contributed beyond the security '
39+
+ 'axis was the `sys_user.role` scalar\'s own tokens, and that scalar is ALREADY '
40+
+ 'published unchanged as `user.role` — the single exception ADR-0090 D3\'s "role" word '
41+
+ 'ban carves out for third-party schema. Minting a `roles` array would revive the exact '
42+
+ 'banned identifier `check:role-word` ratchets against, to publish information the '
43+
+ 'payload already carries. Maintainer ruling 2026-09-05 (#15136, director decision '
44+
+ 'batch #39 item 2, verbatim 「同意」): option A, one name, one meaning. ADR-0068 D1/D2, '
45+
+ 'ADR-0090 D3/D5, ADR-0057 D4.',
46+
acceptanceCriteria:
47+
'No predicate and no client reader treats `current_user.positions` / '
48+
+ '`session.user.positions` as the better-auth role scalar. Audit every authored '
49+
+ '`visible` / `visibleWhen` / RLS predicate that names `current_user.positions` and '
50+
+ 'classify each comparand: a real `sys_position` name, a built-in identity name '
51+
+ '(`platform_admin` / `org_owner` / `org_admin` / `org_member`), or `everyone` needs NO '
52+
+ 'change and starts working where it silently answered FALSE before; a comparand that '
53+
+ 'was only ever a `sys_user.role` token (`user`, and `admin` — note `admin` is NOT a '
54+
+ 'built-in identity name; a membership `admin` is projected as `org_admin`) either '
55+
+ 'moves to `user.role`, or — the supported route — becomes a real position assigned '
56+
+ 'through `sys_user_position`, the governed ADR-0090 D12 channel. '
57+
+ '⚠️ Verify against a REAL session rather than a fixture, and assert the axis by a name '
58+
+ 'that exists on ONE side only: `org_admin` sits on both and cannot discriminate, which '
59+
+ 'is precisely how this defect survived its own documented example. Sign in as a user '
60+
+ 'holding a custom position, read `GET /api/v1/auth/get-session`, and assert that '
61+
+ 'position is in `user.positions` and that the payload agrees set-for-set with `GET '
62+
+ '/api/v1/auth/me/permissions`. '
63+
+ 'Assert the absence of a role-scalar token by VALUE rather than by the predicate\'s '
64+
+ 'verdict: a gate that reads `false` cannot tell "the name is gone" from "the name was '
65+
+ 'never there", and both of those from a faulting predicate, which fails OPEN in the '
66+
+ 'shell and renders anyway. A deployment that stored business role names in '
67+
+ '`sys_user.role` instead of assigning positions is the one that must act; a name in '
68+
+ '`sys_member.role` is still projected, so membership-derived names are unaffected.',
69+
};

packages/spec/src/migrations/registry.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8076,6 +8076,71 @@ const step18: MigrationStep = {
80768076
+ '`organizationId` still stamps `sys_email.organization_id` without acquiring any overlay '
80778077
+ 'semantics.',
80788078
},
8079+
{
8080+
id: 'session-payload-positions-security-axis',
8081+
surface: 'GET /api/v1/auth/get-session -> user.positions[] (and the client CEL root `current_user.positions` bound from it)',
8082+
replacement:
8083+
'the SAME key, carrying the SECURITY positions — the set `/auth/me/permissions` reports '
8084+
+ 'and `resolveUserAuthzGrants` resolves. A reader that wanted the better-auth role '
8085+
+ 'scalar reads `user.role`, which is unchanged and still published',
8086+
reason:
8087+
'A MEANING change, not a rename: no key moved, so nothing in this entry can be found by '
8088+
+ 'grepping for a removed spelling — which is exactly why it needs a ledger row. '
8089+
+ '`customSession` built `user.positions` from the better-auth `sys_user.role` scalar '
8090+
+ 'split on commas, PLUS the active membership mapped to `org_*`, PLUS `platform_admin`, '
8091+
+ 'and read NOTHING from `sys_user_position` (ADR-0057 D4), the source of truth for '
8092+
+ 'custom positions. The Console binds that array straight through as the CEL root '
8093+
+ '`current_user` (objectui `expressionUser.ts`: `positions: user.positions ?? []`), so '
8094+
+ 'an `action.visible` / `visibleWhen` / nav `visible` narrowed by a business position '
8095+
+ 'answered FALSE for EVERYONE, including the user who genuinely held it. '
8096+
+ '⭐ The failure was silent and in the invisible direction: the root was bound and the '
8097+
+ 'key was present, so `has(current_user.positions)` was true and CEL raised nothing — '
8098+
+ 'the predicate simply returned FALSE. A predicate that FAULTS fails OPEN in the shell '
8099+
+ 'and would have shown the button; a successful FALSE shows nothing and reports '
8100+
+ 'nothing. The documented example `\'org_admin\' in current_user.positions` kept working '
8101+
+ 'throughout, because `org_admin` is the one name that sits on BOTH axes — which is why '
8102+
+ 'no example, test or doc could reveal the split. '
8103+
+ 'This was a DECLARED contract being violated rather than an ambiguous name: '
8104+
+ '`EvalUserSchema` already specified `positions` as "built-in identity names + position '
8105+
+ 'names", exposed to "every predicate surface (server formula, server RLS, client UI '
8106+
+ 'gates) ... with an identical shape" so a predicate "evaluates identically wherever it '
8107+
+ 'is written". `/auth/me/permissions` and every server-side evaluator '
8108+
+ '(`ExecutionContext.positions`) already resolved the security axis; the session '
8109+
+ 'payload was the one producer that did not, because it derived the value itself '
8110+
+ 'instead of asking the authority `resolve-authz-context.ts` reserves that job for. '
8111+
+ '⚠️ NO renamed auth-role array accompanies this, and that is a measured disposition '
8112+
+ 'rather than an omission: everything the old union contributed beyond the security '
8113+
+ 'axis was the `sys_user.role` scalar\'s own tokens, and that scalar is ALREADY '
8114+
+ 'published unchanged as `user.role` — the single exception ADR-0090 D3\'s "role" word '
8115+
+ 'ban carves out for third-party schema. Minting a `roles` array would revive the exact '
8116+
+ 'banned identifier `check:role-word` ratchets against, to publish information the '
8117+
+ 'payload already carries. Maintainer ruling 2026-09-05 (#15136, director decision '
8118+
+ 'batch #39 item 2, verbatim 「同意」): option A, one name, one meaning. ADR-0068 D1/D2, '
8119+
+ 'ADR-0090 D3/D5, ADR-0057 D4.',
8120+
acceptanceCriteria:
8121+
'No predicate and no client reader treats `current_user.positions` / '
8122+
+ '`session.user.positions` as the better-auth role scalar. Audit every authored '
8123+
+ '`visible` / `visibleWhen` / RLS predicate that names `current_user.positions` and '
8124+
+ 'classify each comparand: a real `sys_position` name, a built-in identity name '
8125+
+ '(`platform_admin` / `org_owner` / `org_admin` / `org_member`), or `everyone` needs NO '
8126+
+ 'change and starts working where it silently answered FALSE before; a comparand that '
8127+
+ 'was only ever a `sys_user.role` token (`user`, and `admin` — note `admin` is NOT a '
8128+
+ 'built-in identity name; a membership `admin` is projected as `org_admin`) either '
8129+
+ 'moves to `user.role`, or — the supported route — becomes a real position assigned '
8130+
+ 'through `sys_user_position`, the governed ADR-0090 D12 channel. '
8131+
+ '⚠️ Verify against a REAL session rather than a fixture, and assert the axis by a name '
8132+
+ 'that exists on ONE side only: `org_admin` sits on both and cannot discriminate, which '
8133+
+ 'is precisely how this defect survived its own documented example. Sign in as a user '
8134+
+ 'holding a custom position, read `GET /api/v1/auth/get-session`, and assert that '
8135+
+ 'position is in `user.positions` and that the payload agrees set-for-set with `GET '
8136+
+ '/api/v1/auth/me/permissions`. '
8137+
+ 'Assert the absence of a role-scalar token by VALUE rather than by the predicate\'s '
8138+
+ 'verdict: a gate that reads `false` cannot tell "the name is gone" from "the name was '
8139+
+ 'never there", and both of those from a faulting predicate, which fails OPEN in the '
8140+
+ 'shell and renders anyway. A deployment that stored business role names in '
8141+
+ '`sys_user.role` instead of assigning positions is the one that must act; a name in '
8142+
+ '`sys_member.role` is still projected, so membership-derived names are unaffected.',
8143+
},
80798144
{
80808145
id: 'session-user-language-retired',
80818146
surface: 'api.session.user.language',

0 commit comments

Comments
 (0)