You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
> Revision 1 (2026-08-02): narrowed after verifying the code. Two facts shrink this issue: sys_member already has trackHistory: true (the transitions ARE recorded — the missing piece is only who), and granted_by already exists on the capability-grant table (auto-org-admin-grant.ts:244 deliberately writes null into it). And one half of the original "suggested shape" is reversed: adding ADR-0091 validity columns to sys_member turns out to be the wrong move, for a reason worth recording — see "Deliberately not doing". Original framing preserved in History.
Split out of #3723 (closed by #3802 / ADR-0108). Everything else in that investigation landed; this is the one substantive item left.
The actual gap: attribution, not machinery
The elevation chain is sys_member.role (admin grade) → auto-org-admin-grant.ts → sys_user_permission_set(organization_admin, org-scoped) → isTenantAdmin(). Both hops lose provenance:
Hop 1 — the grade change loses the true actor.sys_member is trackHistory: true, so role transitions are already captured as history rows. But every writer is a better-auth path running under isSystem, so history records "system" as the actor. The human who clicked make admin is known exactly once — in the better-auth hook layer, where the session actor exists (invitation-role-cap.ts already resolves the issuer's membership on that same seam). It is discarded before the write reaches ObjectQL.
Hop 2 — the capability grant discards provenance it has a column for.auto-org-admin-grant.ts:244 writes granted_by: null into a column that exists. So explain cannot answer "why is X a tenant admin" end to end, even though every table involved could carry the answer.
Suggested work
W1 — thread the true actor from the better-auth seam into the write context, so trackHistory captures it. Two shapes:
General (recommended): better-auth-originated ObjectQL writes carry the real actor for ATTRIBUTION. Fixes actor history for every better-auth-managed table in one move, not just sys_member.
Targeted: emit explicit grade-transition audit events from the hook layer only. Smaller blast radius; the next table with the same problem does it again.
⚠️The one hard constraint either way: the threaded actor must be attribution-only — it must NOT become the authorization subject of the write. better-auth writes are isSystemon purpose (the route already authorized the action under better-auth's ACL); re-authorizing them as the human actor would create a second adjudication track at exactly the boundary ADR-0095 D3 closed. Attribution and authorization stay separate fields of the context, and a test should pin that.
W2 — stamp granted_by on the auto-grant row: a machine-provenance marker plus the sys_member row that triggered it (and the W1 actor when present). One-liner class of change; makes explain answer the whole chain.
W3 — cover it at the call sites, not the declaration (the #3106 lesson: a case label is not enforcement). Tests drive the real better-auth routes — invite-accept, update-member-role, reconciler bind, demotion revoke — and assert the history row / grant row carries the actor.
Deliberately NOT doing: validity windows on sys_member
The original filing suggested adding the ADR-0091 valid_from / valid_until columns. Rejected on the merits, and the reasoning is the record here:
A platform-side window on the grade would be dual-track adjudication. better-auth's own route ACL reads the stored role string (update-member-role, invitation permissions). If the platform computes "admin until Friday, member after", there are two answers to is X an admin — the platform's and better-auth's — which is the ADR-0092 D4/D5: sys_user 编辑对"同时持有 organization_admin 的平台管理员"被权限合成挡住(explicit deny 胜 wildcard allow) #2836 class reopened at the boundary ADR-0095 D3 just cleaned. The stored string must remain the single truth.
The alternative (revert the role at expiry via a job) is banned — ADR-0049: a security property enforced by a cleanup job is not enforced.
Windowed temporary authority already exists, governed:delegated_admin grade (carries no authority by construction) + an adminScope permission-set grant with an ADR-0091 window, capped by the 30-day delegation ceiling implemented in DelegatedAdminGate. That is the incident-window tool. Full owner/admin is a standing appointment, not a temp elevation mechanism.
Temporary membership (a contractor whose belonging lapses) is a different thing — a product feature in ADR-0093 lifecycle territory, not an audit-parity fix. Note the read path is only PARTIALLY prepared for it: of the three membership-consuming loops in resolve-authz-context, only accessible_org_ids applies isGrantActive today — the grade-projection and fellow-org (org_user_ids) loops do not, so building the feature requires closing those two plus copying the window onto the auto-granted capability row and gating the better-auth actor seam. Windowing MEMBERSHIP does not hit the dual-track wall the way windowing the GRADE does — an expired plain member diverges only on presentation (better-auth still lists the org) while Layer 0 already denies all data — but it is real scoped work. Decided 2026-08-02 (founder): not now — demand-gated. Revisit when an enterprise/compliance need lands; it then gets its own design (ADR-0093 addendum), starting from the analysis above.
Not in scope
Narrowing the resolve-authz-context.ts projection union — with the vocabulary closed (ADR-0108) it can only see the four framework names; correct as it stands.
History
Original filing (2026-07-28): framed as "no audit trail and no expiry", suggesting ADR-0091 columns + a granted_by stamp. Half right: the audit machinery already existed (trackHistory), the expiry half is architecturally wrong on this table, and the real gap is actor attribution at the better-auth seam.
> Revision 1 (2026-08-02): narrowed after verifying the code. Two facts shrink this issue:
sys_memberalready hastrackHistory: true(the transitions ARE recorded — the missing piece is only who), andgranted_byalready exists on the capability-grant table (auto-org-admin-grant.ts:244deliberately writesnullinto it). And one half of the original "suggested shape" is reversed: adding ADR-0091 validity columns tosys_memberturns out to be the wrong move, for a reason worth recording — see "Deliberately not doing". Original framing preserved in History.Split out of #3723 (closed by #3802 / ADR-0108). Everything else in that investigation landed; this is the one substantive item left.
The actual gap: attribution, not machinery
The elevation chain is
sys_member.role(admin grade) →auto-org-admin-grant.ts→sys_user_permission_set(organization_admin, org-scoped)→isTenantAdmin(). Both hops lose provenance:Hop 1 — the grade change loses the true actor.
sys_memberistrackHistory: true, so role transitions are already captured as history rows. But every writer is a better-auth path running underisSystem, so history records "system" as the actor. The human who clicked make admin is known exactly once — in the better-auth hook layer, where the session actor exists (invitation-role-cap.tsalready resolves the issuer's membership on that same seam). It is discarded before the write reaches ObjectQL.Hop 2 — the capability grant discards provenance it has a column for.
auto-org-admin-grant.ts:244writesgranted_by: nullinto a column that exists. Soexplaincannot answer "why is X a tenant admin" end to end, even though every table involved could carry the answer.Suggested work
W1 — thread the true actor from the better-auth seam into the write context, so
trackHistorycaptures it. Two shapes:sys_member.isSystemon purpose (the route already authorized the action under better-auth's ACL); re-authorizing them as the human actor would create a second adjudication track at exactly the boundary ADR-0095 D3 closed. Attribution and authorization stay separate fields of the context, and a test should pin that.W2 — stamp
granted_byon the auto-grant row: a machine-provenance marker plus thesys_memberrow that triggered it (and the W1 actor when present). One-liner class of change; makesexplainanswer the whole chain.W3 — cover it at the call sites, not the declaration (the #3106 lesson: a
caselabel is not enforcement). Tests drive the real better-auth routes — invite-accept,update-member-role, reconciler bind, demotion revoke — and assert the history row / grant row carries the actor.Deliberately NOT doing: validity windows on
sys_memberThe original filing suggested adding the ADR-0091
valid_from/valid_untilcolumns. Rejected on the merits, and the reasoning is the record here:rolestring (update-member-role, invitation permissions). If the platform computes "admin until Friday, member after", there are two answers to is X an admin — the platform's and better-auth's — which is the ADR-0092 D4/D5: sys_user 编辑对"同时持有 organization_admin 的平台管理员"被权限合成挡住(explicit deny 胜 wildcard allow) #2836 class reopened at the boundary ADR-0095 D3 just cleaned. The stored string must remain the single truth.delegated_admingrade (carries no authority by construction) + anadminScopepermission-set grant with an ADR-0091 window, capped by the 30-day delegation ceiling implemented inDelegatedAdminGate. That is the incident-window tool. Fullowner/adminis a standing appointment, not a temp elevation mechanism.resolve-authz-context, onlyaccessible_org_idsappliesisGrantActivetoday — the grade-projection and fellow-org (org_user_ids) loops do not, so building the feature requires closing those two plus copying the window onto the auto-granted capability row and gating the better-auth actor seam. Windowing MEMBERSHIP does not hit the dual-track wall the way windowing the GRADE does — an expired plain member diverges only on presentation (better-auth still lists the org) while Layer 0 already denies all data — but it is real scoped work. Decided 2026-08-02 (founder): not now — demand-gated. Revisit when an enterprise/compliance need lands; it then gets its own design (ADR-0093 addendum), starting from the analysis above.Not in scope
Narrowing the
resolve-authz-context.tsprojection union — with the vocabulary closed (ADR-0108) it can only see the four framework names; correct as it stands.History
granted_bystamp. Half right: the audit machinery already existed (trackHistory), the expiry half is architecturally wrong on this table, and the real gap is actor attribution at the better-auth seam.Related: #3723 · #3697 · #3767 (
sys_memberwrite governance) · ADR-0049 · ADR-0091 D3 · ADR-0093 · ADR-0095 D3 · ADR-0108.