@@ -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