@@ -355,6 +355,169 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => {
355355 const ctx = await resolveAuthzContext ( { ql, headers : H ( ) , getSession : session ( 'u1' ) , nowMs : NOW } ) ;
356356 expect ( ctx . positions ) . not . toContain ( 'approver' ) ;
357357 } ) ;
358+
359+ // ── #10982 — the sys_member half: ONE row, ONE answer ───────────────────
360+ //
361+ // `resolveUserAuthzGrants` reads `sys_member` ONCE and derives two facts from
362+ // it: `accessible_org_ids` (the `group` posture's read reach) and the
363+ // org-administration role projection into `positions`. Only the first applied
364+ // the ADR-0091 window, so a lapsed membership granted no org ACCESS while
365+ // still conferring its org-admin ROLE — two answers from one row.
366+ //
367+ // Maintainer ruling, 2026-08-22 live session (item 2): **A — the role
368+ // projection honours the window. A lapsed membership is NO MEMBERSHIP, not
369+ // merely no org access.** Fail-closed per ADR-0091 D2. The fix is one
370+ // `isGrantActive` call in the `activeMembers` filter, placed BEFORE the
371+ // derivation — the shape §6 already gives `sys_user_permission_set`.
372+ //
373+ // ⚠️ `sys_member` declares NEITHER bound today, and `isGrantActive` reads an
374+ // absent bound as unbounded, so no shipped row can be lapsed and none changes
375+ // answer. The unbounded leg below is what says that out loud; without it this
376+ // block would be satisfied by an implementation that filtered everything and
377+ // silently un-admined every real org member.
378+ describe ( '#10982 — a lapsed sys_member row confers no role either' , ( ) => {
379+ it ( 'a lapsed membership projects NO org role, and no org access — one row, one answer' , async ( ) => {
380+ const ql = makeQl ( {
381+ sys_user : [ { id : 'u1' } ] ,
382+ sys_member : [ { user_id : 'u1' , role : 'member' , organization_id : 'o1' , valid_until : PAST } ] ,
383+ sys_user_position : [ ] ,
384+ sys_user_permission_set : [ ] ,
385+ } ) ;
386+ const ctx = await resolveAuthzContext ( { ql, headers : H ( ) , getSession : session ( 'u1' , { org : 'o1' } ) , nowMs : NOW } ) ;
387+ // The half that was already correct.
388+ expect ( ctx . accessible_org_ids ) . toEqual ( [ ] ) ;
389+ // The half this card fixes — it used to carry `org_member`.
390+ expect ( ctx . positions ) . not . toContain ( 'org_member' ) ;
391+ // `everyone` is the ADR-0090 D5 audience anchor, held by every
392+ // AUTHENTICATED principal and NOT membership-derived: asserted present so
393+ // the line above cannot pass by the resolver having returned nothing.
394+ expect ( ctx . positions ) . toContain ( 'everyone' ) ;
395+ } ) ;
396+
397+ it ( 'a not-yet-active membership (future valid_from) projects no role either' , async ( ) => {
398+ const ql = makeQl ( {
399+ sys_user : [ { id : 'u1' } ] ,
400+ sys_member : [ { user_id : 'u1' , role : 'admin' , organization_id : 'o1' , valid_from : FUTURE } ] ,
401+ sys_user_position : [ ] ,
402+ sys_user_permission_set : [ ] ,
403+ } ) ;
404+ const ctx = await resolveAuthzContext ( { ql, headers : H ( ) , getSession : session ( 'u1' , { org : 'o1' } ) , nowMs : NOW } ) ;
405+ expect ( ctx . positions ) . not . toContain ( 'org_admin' ) ;
406+ expect ( ctx . accessible_org_ids ) . toEqual ( [ ] ) ;
407+ } ) ;
408+
409+ // ⛔ LOAD-BEARING. An implementation that dropped every membership row would
410+ // satisfy every lapsed assertion above while un-admining the entire
411+ // installed base. Both rows sit in the SAME organization on purpose: the
412+ // active-org filter cannot explain either verdict, so the ONLY thing
413+ // separating them is the validity window — a blanket filter fails the first
414+ // assertion, and no filter at all fails the second.
415+ it ( 'an ACTIVE membership still projects its role — the lapsed legs cannot pass vacuously' , async ( ) => {
416+ const tables = {
417+ sys_user : [ { id : 'u1' } ] ,
418+ sys_member : [
419+ { user_id : 'u1' , role : 'owner' , organization_id : 'o2' , valid_until : PAST } ,
420+ { user_id : 'u1' , role : 'member' , organization_id : 'o2' , valid_from : PAST , valid_until : FUTURE } ,
421+ ] ,
422+ sys_user_position : [ ] ,
423+ sys_user_permission_set : [ ] ,
424+ } ;
425+ const ctx = await resolveAuthzContext ( {
426+ ql : makeQl ( tables ) , headers : H ( ) , getSession : session ( 'u1' , { org : 'o2' } ) , nowMs : NOW ,
427+ } ) ;
428+ expect ( ctx . positions ) . toContain ( 'org_member' ) ; // the in-window row still grants
429+ expect ( ctx . positions ) . not . toContain ( 'org_owner' ) ; // the lapsed one, same org, does not
430+ expect ( ctx . accessible_org_ids ) . toEqual ( [ 'o2' ] ) ; // and the two halves agree
431+ } ) ;
432+
433+ // ⛔ LOAD-BEARING, the safe-to-land-now leg. `sys_member` has no
434+ // `valid_from`/`valid_until` columns, so EVERY shipped row looks like this.
435+ // If this reddens, the change is not a tightening — it is a regression on
436+ // every existing deployment.
437+ it ( 'a membership with NO bounds is unbounded — every shipped row is unaffected' , async ( ) => {
438+ const ql = makeQl ( {
439+ sys_user : [ { id : 'u1' } ] ,
440+ sys_member : [ { user_id : 'u1' , role : 'owner,member' , organization_id : 'o1' } ] ,
441+ sys_user_position : [ ] ,
442+ sys_user_permission_set : [ ] ,
443+ } ) ;
444+ const ctx = await resolveAuthzContext ( { ql, headers : H ( ) , getSession : session ( 'u1' , { org : 'o1' } ) , nowMs : NOW } ) ;
445+ expect ( ctx . positions ) . toContain ( 'org_owner' ) ;
446+ expect ( ctx . positions ) . toContain ( 'org_member' ) ;
447+ expect ( ctx . accessible_org_ids ) . toEqual ( [ 'o1' ] ) ;
448+ } ) ;
449+
450+ // ── The escalation the card is filed on ──────────────────────────────
451+ //
452+ // Make the lapsed row's role `owner` and the question stops being tidiness:
453+ // `org_owner` is a capability-bearing name. A suite that only checked
454+ // `org_member` would not catch a regression here, so the rung is pinned in
455+ // BOTH directions on the same fixture shape.
456+ //
457+ // ⚠️ Which escalation path this closes, stated precisely, because the two
458+ // differ: `derivePosture` reads HELD CAPABILITY GRANTS, never the
459+ // better-auth role (ADR-0095 D2/D3). The role reaches TENANT_ADMIN by two
460+ // routes — (P) `org_owner` resolves a `sys_position` row whose bound set is
461+ // an org-admin grant, entirely inside this resolver; and (D) plugin-security's
462+ // `reconcileOrgAdminGrant` provisions a direct `sys_user_permission_set`
463+ // row from the role. This fix closes (P). It deliberately does NOT reach
464+ // into (D): that row is standing authority in its own right, carrying its
465+ // OWN ADR-0091 window, and revoking someone else's grant row from a read
466+ // path is not this resolver's job. Both are pinned below so the boundary is
467+ // a measured fact rather than an assumption.
468+ const orgOwnerPositionTables = ( memberRow : Record < string , unknown > ) => ( {
469+ sys_user : [ { id : 'esc' } ] ,
470+ sys_member : [ { user_id : 'esc' , role : 'owner' , organization_id : 'o1' , ...memberRow } ] ,
471+ sys_user_position : [ ] ,
472+ sys_user_permission_set : [ ] ,
473+ sys_position : [ { id : 'pos_owner' , name : 'org_owner' } ] ,
474+ sys_position_permission_set : [ { position_id : 'pos_owner' , permission_set_id : 'psO' } ] ,
475+ sys_permission_set : [ { id : 'psO' , name : 'organization_admin' } ] ,
476+ } ) ;
477+
478+ it ( 'escalation, BEFORE: an ACTIVE owner membership still resolves TENANT_ADMIN' , async ( ) => {
479+ const ctx = await resolveAuthzContext ( {
480+ ql : makeQl ( orgOwnerPositionTables ( { } ) ) , headers : H ( ) ,
481+ getSession : session ( 'esc' , { org : 'o1' } ) , nowMs : NOW ,
482+ } ) ;
483+ expect ( ctx . positions ) . toContain ( 'org_owner' ) ;
484+ expect ( ctx . permissions ) . toContain ( 'organization_admin' ) ;
485+ expect ( ctx . posture ) . toBe ( 'TENANT_ADMIN' ) ;
486+ } ) ;
487+
488+ it ( 'escalation, AFTER: a LAPSED owner membership resolves MEMBER, not TENANT_ADMIN' , async ( ) => {
489+ const ctx = await resolveAuthzContext ( {
490+ ql : makeQl ( orgOwnerPositionTables ( { valid_until : PAST } ) ) , headers : H ( ) ,
491+ getSession : session ( 'esc' , { org : 'o1' } ) , nowMs : NOW ,
492+ } ) ;
493+ expect ( ctx . positions ) . not . toContain ( 'org_owner' ) ;
494+ // The rung falls with the name: `org_owner` never resolves its
495+ // `sys_position` row, so the bound org-admin set is never collected.
496+ expect ( ctx . permissions ) . not . toContain ( 'organization_admin' ) ;
497+ expect ( ctx . posture ) . toBe ( 'MEMBER' ) ;
498+ expect ( ctx . accessible_org_ids ) . toEqual ( [ ] ) ;
499+ } ) ;
500+
501+ it ( 'the boundary: a DIRECT org-admin grant is standing authority and survives the lapse — by design' , async ( ) => {
502+ // Route (D). The role is only the PROVISIONING source (ADR-0095 D3); the
503+ // grant row it caused is separate authority with its own window. So the
504+ // role projection goes quiet while the capability keeps resolving. This
505+ // is the correct layering, not a residual hole — but it IS the reason the
506+ // rung can outlive the membership, so it is pinned rather than assumed.
507+ const ql = makeQl ( {
508+ sys_user : [ { id : 'esc2' } ] ,
509+ sys_member : [ { user_id : 'esc2' , role : 'owner' , organization_id : 'o1' , valid_until : PAST } ] ,
510+ sys_user_position : [ ] ,
511+ sys_user_permission_set : [ { user_id : 'esc2' , permission_set_id : 'psO' , organization_id : 'o1' } ] ,
512+ sys_permission_set : [ { id : 'psO' , name : 'organization_admin' } ] ,
513+ } ) ;
514+ const ctx = await resolveAuthzContext ( { ql, headers : H ( ) , getSession : session ( 'esc2' , { org : 'o1' } ) , nowMs : NOW } ) ;
515+ expect ( ctx . positions ) . not . toContain ( 'org_owner' ) ; // the role projection is closed
516+ expect ( ctx . permissions ) . toContain ( 'organization_admin' ) ; // the grant row is not
517+ expect ( ctx . posture ) . toBe ( 'TENANT_ADMIN' ) ;
518+ expect ( ctx . accessible_org_ids ) . toEqual ( [ ] ) ; // and access is still withheld
519+ } ) ;
520+ } ) ;
358521} ) ;
359522
360523describe ( 'audience anchors in the resolver (ADR-0090 D5)' , ( ) => {
0 commit comments