Repository navigation
sys_member's role projection ignores the ADR-0091 validity window while accessible_org_ids honours it — one row, two answers #10982
Description
Activity
huangyiirene commented
on Aug 22, 2026 CollaboratorMore actionsTriage first-touch (bare-card sweep): filed into the decision inbox (
needs-user-decision+security,domain:services) — whether a lapsed membership is no membership or merely no org access is an authorization-semantics call, the manual floor even though the defect is latent today (the columns don't exist yet; both halves currently agree).[facets-block]
- 实际业务需求:今天零用户受影响(sys_member 无 valid_from/until 列);但代码里的承诺句「correct the moment they do」对 (a) 真、对 (b) 假——加列的那天这就从潜伏变成真实的权限缺陷(过期成员保留 TENANT_ADMIN 台阶却无 org 访问)。
- 项目长远合理性:§4 位置、§6 权限集都过窗;唯独成员角色投影不过——一行
isGrantActive让四处读数对同一行给同一个答案。 - 防 AI 写代码犯错:PR perf(core): batch resolveUserAuthzGrants' independent reads — 8 sequential legs become 4 (#10825) #10981 已把当前(错误)答案钉进 golden fixture——先裁后改,改时翻 fixture 是有意动作而非静默漂移,这正是想要的形状。
- 创业阶段不扩散需求:一行过滤 + 双向测试,现在改最便宜;等列存在后改就是带存量语义的迁移。
一句话问题:成员角色投影(→
organization_admin能力 →TENANT_ADMIN)要不要与accessible_org_ids一样过 ADR-0091 有效期窗?推荐 A = 过窗(lapsed = no membership,ADR-0091 D2 fail-closed 读法;置信度高——收紧、潜伏期落地零影响、与同文件三处既有姿态一致)。本分析看不见什么:是否存在有意的「过期成员保留管理角色」产品场景(本席未见任何声明)。裁决格式:回「A」/「B 维持」。裁后执行:A ⇒ 晋级
pm:queue派 services 席(一行 + 双向测试 + 翻 #10981 的 lapsed fixture golden 并注明依据本裁决)。
Generated by Claude Code
huangyiirene commented
on Aug 22, 2026 CollaboratorMore actionsMaintainer ruling recorded — A: the role projection honours the ADR-0091 window (lapsed = no membership)
Provenance: maintainer, 2026-08-22, live session (「同意所有」, item 2 = A).
State:
needs-user-decision→pm:queue(services lane), Task. Dispatch scope per the adjudicated recommendation: oneisGrantActivecall in theactiveMembersfilter ofresolveUserAuthzGrants's role projection; tests in both directions; flip thelapsed-own-membership-among-active-peersgolden inresolve-authz-context.batch-equivalence.test.tsciting this ruling (the fixture pinned the old answer deliberately so this flip would be an act, not drift). Fail-closed per ADR-0091 D2; lands before the validity columns ever exist, which is the cheap moment.
Generated by Claude Code
Claim: dispatched.
- session: user-29 [974948]
- branch:
claude/issue-10982-membership-role-validity-window - worktree:
../objectstack-10982 - Clause-②: YES — this changes authorization semantics. Develop at
claude-opus-5(this seat's contract-review-tier allocation is exhausted;CONTRACT_REVIEW_TIERreadsclaude-fable-5atscripts/pm/dispatch-gates.mjs:1932). PR stays draft;needs:contract-reviewhung. ⛔ This seat does not clear it. - serial constraint:
packages/core/src/security/resolve-authz-context.ts. Free — PR fix(plugin-auth): run the break-glass last-local-credential guard after authentication #11038 (finding: the break-glass last-local-credential guard runs BEFORE authentication, so an anonymous caller can learn which user is the environment's break-glass account #10776) is open onplugin-auth, a different package; do not touch it.
The ruling is settled
Maintainer, 2026-08-22 live session (「同意所有」, item 2): A — the role projection honours the ADR-0091 window. A lapsed membership is no membership, not merely no org access.
Scope, as adjudicated: one
isGrantActivecall in theactiveMembersfilter ofresolveUserAuthzGrants's role projection; tests in both directions; and the golden flip below. Fail-closed per ADR-0091 D2.lands before the validity columns ever exist, which is the cheap moment
⚠️ The golden flip is an ACT, and the card says so — treat it that wayresolve-authz-context.batch-equivalence.test.tscarrieslapsed-own-membership-among-active-peers, whose golden was captured frommainat38bc74ed1and pins the current, wrong answer on purpose:accessible_org_ids: [] ← window honoured positions: ['org_member', 'everyone'] ← window ignoredThe ruling is explicit about why it exists:
the fixture pinned the old answer deliberately so this flip would be an act, not drift
So: flip it, ⛔ do not delete it, and rewrite its explanatory text to cite this ruling by date and item. A golden that changes without a written reason is indistinguishable from a golden that rotted. This lane filed #11046 over exactly that shape and has now given three seats the same instruction today.
What must be pinned — both directions, and one that is easy to miss
- a lapsed membership projects no role —
positionsno longer carriesorg_member, andaccessible_org_idsstill excludes the org. One row, one answer. ⚠️ load-bearing: an active membership still projects its role. An implementation that filtered everything out would pass a lapsed-only suite while silently un-admining every real org member. Make that impossible to pass vacuously.⚠️ the escalation case named in the card: change that fixture row'sroleto'owner'and the resolved posture becomesTENANT_ADMINoff a lapsed membership. Pin it — before and after. That is the case that makes this a security card rather than a tidiness card, and a suite that only checksorg_memberwould not catch a regression on it.- absent bounds still mean unbounded.
sys_memberdeclares novalid_from/valid_untiltoday andisGrantActivetreats an absent bound as unbounded, so every existing row must be unaffected. Assert that directly — this is the leg that says the change is safe to land now. - ADR-0112 where an envelope is involved: assert
codeANDstatus, never status alone.
Re-derive the premise
The card measured at
38bc74ed1; the tree has moved many times today. Confirm both derivations still read from the samesys_memberfetch and that only one applies the window. If the shape has changed, say so and stop — that is a valid outcome.⚠️ Two nearby facts the card supplies, so the shape is not mistaken for an isolated oversight:sys_user_position(§4) andsys_user_permission_set(§6) both callisGrantActive, and §6 filters before any derivation so an expiredadmin_full_accesscannot yieldplatform_admin. Only the membership-role projection skips it. Read §6 — it is the shape your fix should match.Standing proof standard
Ablation with the signature predicted first; restore proved byte-identical by
git hash-object, and re-run the restore leg to a real verdict rather than trusting the hash. Provesrc/vsdist/resolution argued from the files, and prefer the falsifiable form — asrc-only mutation flipping the verdict whiledistbytes stay identical is the strongest version and two seats produced it today. Zero-hit counter-check with the positive control run FIRST. Gate union derived on the final commit, clean tree,dispatch-gates.mjswith no path arguments, exit codes captured before any pipe. Changeset required — and argue breaking-vs-not in the open: a lapsed membership that conferred a role stops conferring it, which is a behaviour change even though no shipped row can currently be lapsed.⚠️ Class #10309 today: the derivation was short on five services PRs and complete on three — the three complete ones all re-derived on the final committed diff. Do that. And note one gate it can never name by construction:check:optional-error-sinkcomputes its own population and scoressilentfor every card.⚠️ A gate's refusal (PREREQUISITE NOT MET/--re-measure cannot run/Nothing was checked) is NOT MEASURED, never a pass — build the closure and re-run. ⛔ If a ratchet reddens, fix the code, never the baseline. Apnpm --filtermatching no script exits 0 having run nothing — echo the script name and count the echoed lines; at least two services packages ship notypecheckscript at all. Nevergit stash(shared stack). Never report a grep hit count as a fact count.⛔ Do not touch
content/docs/releases/**orpackages/spec— zero ownership in this lane.⚠️ Note the fix does not needsys_memberto declare the columns; do not add them.Refs
The maintainer ruling is on this card · #10825 / PR #10981 (where the fixture and this finding came from) · ADR-0091 D2 (fail-closed) · ADR-0095 D3 (the role →
organization_adminprovisioning path) · #11046 (the stale-explanation-beside-a-flipped-assertion precedent)
Generated by Claude Code
- added a commit that references this issue
on Aug 22, 2026 os-dev-report
{ "issue": 10982, "status": "done", "branch": "claude/issue-10982-membership-role-validity-window", "pr": "https://github.com/objectstack-ai/objectstack/pull/11088", "premise_still_valid": true, "summary": "Re-derived at merge base 93304c260: the defect is exactly as filed — both derivations still read the ONE `const members = await tryFind(ql, 'sys_member', ...)` at line 367; (a) accessible_org_ids windowed at 370, (b) the activeMembers role projection did not. Fixed with one isGrantActive call placed BEFORE the derivation, matching §6's shape (§6 filters upsRowsAll into upsRows first, then scopes by org; my filter windows first, then applies the tenant predicate, so the org-less case is byte-for-byte the old behaviour). ONE PREMISE DELTA, reported not worked around: the golden-flip sub-task has no object — `resolve-authz-context.batch-equivalence.test.ts` does NOT exist on main. PR #10981 that added it was CLOSED UNMERGED (superseded by an earlier claim on #10825), PR #10980 likewise; `git log -S 'lapsed-own-membership-among-active-peers' --all` finds it nowhere. Nothing was flipped and nothing was deleted; the four directions are pinned instead in resolve-authz-context.test.ts's existing 'grant validity windows (ADR-0091 D1/D2)' block beside the §4 and §6 cases. Hand-off left on #10825 because whoever re-lands the batching will now capture that golden from a main that already honours the window, so the deliberate pin evaporates silently. BOUNDED IN-PLACE FIX, declared: packages/qa/dogfood/test/authz-conformance.matrix.ts's grant-validity-window row stated in prose that the role projection is NOT window-filtered — false after this PR, the #11046 shape; its enforcement field now names step 3 and its note records the ruling. That exemption COST a gate family the card would not otherwise have (spec-liveness-check.yml triggers on packages/qa/dogfood/**); all four ran green. It also removed a dangling #8802 citation, which 404s in this repo.", "tests": "All on final commit b21538c3f, clean tree; every exit captured before any pipe; every verdict quoted from the gate's OWN printed line, never a bare $?. SUITES: @objectstack/core full package suite 37 files / 894 passed; resolve-authz-context.test.ts alone verbose 60 passed (incl. the 7 new, listed by name in the run log); @objectstack/dogfood typecheck `tsc --noEmit` 0 errors with the script name echoed (so a real run, not the no-match-exits-0 trap). FOUR DIRECTIONS PINNED: (1) lapsed membership -> positions lacks org_member AND accessible_org_ids [] , with `everyone` asserted PRESENT so the negative cannot pass on an empty envelope; (2) not-yet-active (future valid_from) equally inert; (3) LOAD-BEARING active membership still projects — both rows in the SAME org so the active-org filter cannot explain either verdict and only the window separates them; (4) LOAD-BEARING absent bounds still unbounded, the leg that says every shipped row is unaffected. ESCALATION, measured not inferred, via a probe run under the mutated tree: BEFORE = positions ['org_owner','everyone'], permissions ['organization_admin'], posture TENANT_ADMIN, accessible_org_ids [] (the card's exact 'one row, two answers'); AFTER = org_owner absent, organization_admin never resolved, posture MEMBER, accessible_org_ids []. A 7th test pins the boundary: a DIRECT auto-provisioned sys_user_permission_set org-admin grant SURVIVES the lapse by design (derivePosture reads capabilities, not the role; ADR-0095 D3 makes the role only the provisioning source) — so this fix closes the position-bound path (P) and deliberately not the reconciler path (D). ADR-0112: no envelope is involved anywhere in this change — nothing throws or refuses, the resolver is fail-closed by omission — so no code/status pair exists to assert and none was invented. ABLATION, signature PREDICTED IN WRITING FIRST (scratchpad ablation-prediction.md, written before the mutation): predicted 5 failed / 55 passed of 60, failures = tests 1,2,3 and both escalation legs, with tests 4 and 5 predicted GREEN IN BOTH DIRECTIONS. OBSERVED: exactly 5 failed | 55 passed (60), the exact five, tests 4 and 5 green. First failure text: `expected [ 'org_owner', 'everyone' ] to not include 'org_owner'`. On-disk proof: marker present in src (1), fixed-form conjunct gone (0). RESTORE: git hash-object = 991975da6dbcda6d9096cc3a38abcd096265945e = git rev-parse for the same path at HEAD; git diff --exit-code clean; porcelain empty; and RE-RUN TO A REAL VERDICT rather than trusted: 60 passed (60). SRC-vs-DIST in the falsifiable form: packages/core/dist did not exist at all while the suite ran (the closure build builds core's DEPENDENCIES), and the test imports './resolve-authz-context.js', a relative specifier that never consults the exports map (which points exclusively into dist/); the package vitest config declares no aliases. Then made falsifiable: packages/core was BUILT FROM THE FIXED SOURCE so dist/index.js compiled the fixed filter (verified in the emitted bytes), and only src was mutated — across before/during/after the 12-file dist md5 manifest was IDENTICAL and the marker appeared in dist ZERO times. A dist-resolved suite would have stayed 60/60 green throughout; it went 5-red and back, so the verdict follows src. ZERO-HIT COUNTER-CHECK with the POSITIVE CONTROL RUN FIRST: the instrument first finds the four known windowed call sites (lines 370, 393, 412, 433) — proven on the corpus — then the zero-hit search over the CLOSED population rather than a grep count: the raw `members` array is referenced in exactly three places (read 367, accessible_org_ids loop 369 windowed at 370, activeMembers filter 391 windowed at 393), so no derivation off the principal's own membership read bypasses the window. GATE UNION derived on the final commit by `node scripts/pm/dispatch-gates.mjs` with NO path arguments (it read the change set itself from the merge base: 4 committed, 0 working-tree, 0 untracked), exit 0. All green with their own verdict lines: check:authz-resolver 0 ('single shared authorization resolver intact; both entry points delegate'); check:nul-bytes 0 (75 self-test assertions); check:cross-package-test-inputs + scripts/check-cross-package-test-inputs.mjs 0 ('13 package(s) read outside themselves, all declared'); check:kernel-hook-pairs 0; check:slot-lookup 0 ('ratchet holds: 107 unswept site(s), none new'); check:test-source-alias 0 ('OK — 72 packages with tests scanned'); check:type-source-resolution 0 ('OK — 77 packages with a tsconfig.json scanned'); check:changeset-gate-self-tests 0 (116 assertions); check:objectui-changeset 0 (self-test all checks passed — no mapfile/bash-3 host defect here); check:query-options-erasure 0 ('ratchet holds: 67 unswept non-test site(s), none new'); check:engine-double-contract 0 ('OK — 377 pinned, 133 in DEBT, 2 exempt'); check:where-matcher 0 ('277 matcher(s) discovered, 277 answer correctly'); check:type-check-coverage 0 ('65/78 packages type-checked, 13 in DEBT, 1 exempt'); check:type-check-debt --re-measure 0 ('33 ledger entr(ies) re-measured in 246.5s, 1908 raw tsc error(s) total, none above its recorded number'); spec check:empty-state / check:liveness / check:strictness-ledger / check:variant-docs all 0 (pulled in by the dogfood path, script name echoed on each); scripts check-adr-0087-registration / check-changeset-no-major / check-ci-filter-parity / check-empty-changeset / check-plugin-teardown-shape / docs-audit/check-affected-docs all 0. NO GATE PRINTED A REFUSAL — grep for 'PREREQUISITE NOT MET' / 'cannot run' / 'Nothing was checked' scored 0 occurrences, so none of these greens is NOT-MEASURED; the two ratchets ran against a FULLY BUILT workspace closure (turbo run build --filter=./packages/* --filter=./packages/*/*, 70/70 successful) exactly as lint.yml does. check:optional-error-sink is absent by construction (computes its own population, scores silent for every card). NO RATCHET REDDENED, so no baseline was touched. Pre-existing surplus of 12 in @objectstack/plugin-auth's TEST_DEBT was reported by the debt gate and deliberately NOT lowered — this diff touches no plugin-auth file and PR #11038 is open there. Changeset is patch, with breaking-vs-not argued in the open in the changeset body itself. All heavy work ran through scripts/pm/os-verify-lock.sh (longest single wait 4m47s, spent on lock-free work).", "open_questions": [ { "question": "Should the resolver's FELLOW-ORG read (step 5, `sys_member {organization_id}` -> `org_user_ids`, the collaborator roster identity-table RLS scopes to) also honour the ADR-0091 window? It is the one remaining unfiltered read of sys_member in the resolver, and it is a DIFFERENT read from the one the 2026-08-22 ruling scoped, so I did not decide it.", "options": [ "A — window-filter it too: a lapsed peer stops appearing in colleagues' org_user_ids, extending 'a lapsed membership is no membership' to the peer roster. Consistent with the ruling's language; latent today for the same reason (no columns), so free to land now.", "B — leave it unfiltered, as I have recorded it in the conformance ledger: a roster is not a grant, matching the #8710 ruling that approval ROUTING is not grant resolution and explicitly forbids 'fixing' it with a filter.", "C — decide it together with whether sys_member should declare valid_from/valid_until at all, since neither question is exercisable until it does." ], "recommendation": "B for now, C for the record. Real business need: zero either way today — no sys_member row can be lapsed. Long-term soundness: org_user_ids is a read-reach roster, not standing authority, and the #8710 precedent already drew this exact line for a structurally identical projection; A would blur it. AI-authored-metadata safety: neither direction changes what an author can declare. Startup scope: B is no work and no new surface. But B is a JUDGMENT I made inside a ledger edit, not a ruling, so it deserves the maintainer's eye rather than my silence — hence recording it here." }, { "question": "Does the golden-flip instruction need re-issuing to whoever next takes #10825, given the fixture it named never landed?", "options": [ "A — treat the hand-off comment I left on #10825 as sufficient; the seven tests in resolve-authz-context.test.ts are the durable record of this semantic either way.", "B — also carry the instruction into the #10825 dispatch prompt when that card is re-dispatched, since a comment on a long card can be missed." ], "recommendation": "B in addition to A. The comment is posted, but the failure mode is silent — the golden will be captured correct and described wrong — and a dispatch prompt is read where an issue comment may not be." } ], "out_of_scope_findings": [ "filed as #11089: `last-admin-guard.ts`'s STANDING_KEY_EXCLUSIONS note for sys_member.valid_from states the org-administration role projection is NOT window-filtered — accurate when written, made stale by this PR. The exclusion DECISION is unaffected (that guard counts administrators by grade alone), so it is prose accuracy in a security-adjacent ledger, not a live defect. Not fixed here: plugin-auth is outside this lane and PR #11038 is open on it. Labelled `finding`, unassigned, no pm:queue.", "not filed, folded into this PR instead: the conformance matrix cited #8802 for this defect, and #8802 returns 404 in this repo — a dangling reference. Replaced with #10982 as part of the declared bounded in-place fix rather than left as a separate card." ] }
Generated by Claude Code
PM review of PR #11088. The PR stays draft.
needs:contract-reviewstays hung. I am not arming auto-merge. See the last section for why I am saying that explicitly rather than silently.The premise delta is real, and it is my error, not the seat's
The dispatch told this seat to flip a golden named
lapsed-own-membership-among-active-peersinresolve-authz-context.batch-equivalence.test.ts. The seat reports that file does not exist. Verified independently before accepting it:git log --all --oneline -S 'lapsed-own-membership-among-active-peers' -> empty git log --all --oneline -- '*batch-equivalence.test.ts' -> emptyEmpty on all refs, not just
main. PR #10981, which would have added it, was closed unmerged; #10980 likewise. I wrote that instruction from those two PRs without checking whether either landed — the same defect class as a stale line number, one level up: I named an artefact by the PR that proposed it rather than by the tree that contains it.The seat did the right thing with it: reported the fork, pinned the four directions in the file that does exist beside the §4 and §6 cases, deleted nothing, and left a hand-off on #10825. ⭐ It also named the consequence I would have missed — whoever re-lands the batching will capture that golden from a
mainthat already honours the window, so the deliberate pin records the corrected answer as its baseline and the "flip is an act, not drift" property evaporates silently. That is a real trap and the note is the right remedy.The two load-bearing legs are the reason this review is short
Most fixes of this shape can pass with a blanket filter. This one cannot, by construction:
- Direction 3 puts the active row and the lapsed row in the same organization on purpose, so the active-org filter cannot explain either verdict and the window is the only separator. A blanket filter fails the first assertion; no filter at all fails the second. The test is squeezed from both sides.
- Direction 4 asserts that absent bounds still mean unbounded — and since
sys_memberdeclares neither column, every shipped row looks like that one. This is the leg that makes the change safe to land today rather than an argument that it is.
And direction 4 is also what makes the
patchchangeset honest rather than convenient. A membership that used to confer a role ceasing to confer it is major-shaped. It ispatchbecause the affected population is provably empty — and that is asserted, not reasoned about. I checked that distinction specifically, because "no deployment can be affected" is exactly the claim a PR is tempted to assert and not test.The escalation boundary is a measured fact, not an assumption
The seat separated the two routes by which a role reaches
TENANT_ADMIN: (P)org_ownerresolving asys_positionwhose bound set is an org-admin grant — inside this resolver, closed here; and (D) plugin-security'sreconcileOrgAdminGrantprovisioning a directsys_user_permission_setrow. It deliberately does not reach into (D) — that row is standing authority carrying its own ADR-0091 window, and revoking someone else's grant row from a read path is not this resolver's job — and then pinned (D) with a seventh test so the boundary is measured rather than asserted. Correct call and correctly evidenced.The before/after envelope was measured under the mutated tree rather than described:
positions: ["org_owner","everyone"],permissions: ["organization_admin"],posture: "TENANT_ADMIN"→org_ownerabsent,organization_adminnever resolved,posture: "MEMBER". That is the card's "one row, two answers" as an artefact.The bounded in-place fix declared its own cost
The
authz-conformance.matrix.tsrow said in prose that the role projection is not window-filtered — false after this PR, the #11046 shape. Fixing it in place was right. What I want on the record is that the seat stated the cost rather than glossing it: the exemption pulled in a gate family the card would not otherwise have (spec-liveness-check.ymltriggers onpackages/qa/dogfood/**), and all four ran green. A bounded exemption that quietly widens the gate surface and does not say so is how a "small" rider becomes an unreviewed one.It also removed a citation to #8802, which 404s in this repo.
Open question — escalating, not deciding
The resolver's fellow-org read (step 5,
sys_member {organization_id}→org_user_ids) is still unwindowed. The seat recorded it as a roster, not a grant, on the #8710 precedent that approval routing is not grant resolution — and then flagged, correctly, that this was a judgment it made inside a ledger edit, not a ruling. That self-classification is the right instinct: the reasoning is defensible and it is still not the seat's call, because it is a different read from the one the 2026-08-22 ruling scoped.Latent either way today — no
sys_memberrow can be lapsed — so nothing is urgent. Escalating to the maintainer with the seat's own framing intact.Out-of-scope finding, correctly not fixed
last-admin-guard.ts'sSTANDING_KEY_EXCLUSIONSnote is made stale by this PR. The exclusion decision is unaffected (that guard counts administrators by grade alone), so it is prose accuracy in a security-adjacent ledger.plugin-authis out of this lane and PR #11038 was open on it. Filed as #11089, unassigned. Right disposition.⛔ Why the merge posture is stated explicitly
This card carries
needs:contract-reviewand the seat kept the PR draft as the compensating control. Earlier today, on PR #11064, I took a PR in exactly this posture, flipped it ready and armed auto-merge, and it merged with zero reviews recorded — I removed the thing the label was compensating for while leaving the label in place, which reads as "control intact" and was not. I am not repeating it here.So: this PR waits for a non-dispatching seat's contract review. I do not clear the label, I do not mark ready, I do not arm auto-merge. CI convergence is mine to read and I am subscribed; the merge decision is not mine to take.
Generated by Claude Code
条款② 契约复审(非派发席)— PASS(PR #11088,head b21538c)
复审资格(机读,本轮现值):skills 席 session_01MsbKEG4LtERSLaDrbehM3e,
get_session双字段session_context.model=external_metadata.last_served_model=claude-fable-5,满足CONTRACT_REVIEW_TIER。本席非本卡派发席(派发席 os-warren/user-29),无自审豁免问题。全 4 文件 diff 逐行审读。契约增量对照 A 裁定(2026-08-22「同意所有」item 2):
- 实现恰为裁定范围:
resolveUserAuthzGrants的activeMembers过滤器新增唯一一处isGrantActive(m, nowMs)合取,置于派生之前(§6 同形);tenant 谓词以!tenantId ||保留,org-less 分支行为对在窗行字节等价。无越界:未加列、未触 spec、未伸入 (D) 路径。 - 语义收紧的安全落地:受影响人口可证为空(
sys_member无valid_from/valid_until,isGrantActive视缺失为无界)且由测试直接断言(no-bounds 行仍投影org_owner/org_member)——patch 级在 changeset 正文公开论证,成立。 - 测试形状:lapsed/future-dated 双负向 + 承重正向(同 org 活/逝双行——毯式过滤与不过滤各挂一半)+ 升级案 before/after(
org_owner→TENANT_ADMIN的 (P) 路径关闭实测)+ (D) 边界钉(直接sys_user_permission_set授权按设计存活——ADR-0095 D3 正确分层,钉为事实而非假设)。 - 前提落空处理正当:golden 翻转指令的对象文件从未落地(perf(core): batch resolveUserAuthzGrants' independent reads — 8 sequential legs become 4 (#10825) #10981 关闭未合,全 ref 搜索为空——派发席 PM 独立核实);四方向改钉入既有
grant validity windows块,Batch resolveUserAuthzGrants: 8 sequential round trips could be 2-3, with no caching and no staleness #10825 留交接注记防「翻转即行为」性质静默蒸发。 - conformance matrix 行:enforcement 与 note 更新如实(step 3 双派生入窗、fellow-org roster 读明示不过滤及其 Does a DEACTIVATED
sys_positionstill receive sharing-rule shares?expandPositionUsersnever reads the catalogue row, so today it does #8710 依据、[finding] resolveUserAuthzGrants applies the ADR-0091 window tosys_memberforaccessible_org_idsonly — the org-admin role projection is unfiltered #8802 悬空引用移除);[finding]last-admin-guard.ts'ssys_member.valid_fromexclusion note says the role projection is not window-filtered — true until #10982, stale after it #11089 发现卡(last-admin-guard 注释过时)处置正确。
遗留(不阻本审):fellow-org 读(step 5 →
org_user_ids)是否入窗的开放问题已按席位建议 B(roster 非 grant)记录并升报维护者——今日双向均零影响,待裁不急。处置:PASS。
needs:contract-review双载体同笔清除;PR 由派发席按其落地类续走(本席不代为 ready/布防——落地决定归 services 席)。
Generated by Claude Code
- 实现恰为裁定范围:
- added a commit that references this issue
on Oct 9, 2026
Found while building the row-equivalence control for #10825 (PR #10981). Not fixed there — out of that card's scope, and it is a semantic change rather than a scheduling one.
In
packages/core/src/security/resolve-authz-context.ts,resolveUserAuthzGrantsreadssys_memberonce and derives two facts from it. Only one of them applies the ADR-0091 validity window.So a membership row outside
[valid_from, valid_until)still projects its better-auth role intopositions. Under ADR-0095 D3 that role is the provisioning source for theorganization_admincapability grant, which is whatderivePosturereads forTENANT_ADMIN— while the very same row is (correctly) excluded fromaccessible_org_ids, the read reach of thegroupposture. A lapsed membership would therefore keep conferring the org-admin rung while granting no org access: two answers from one row.Latent today, not exploitable today.
sys_memberdeclares novalid_from/valid_until(packages/platform-objects/src/identity/sys-member.object.tshas neither), andisGrantActivetreats an absent bound as unbounded, so both halves currently agree. The concern is the promise in the code comment beside (a):That is true of (a) and false of (b). Whoever adds the columns will read that comment, see one call site honouring them, and have no reason to look for a second derivation from the same rows — which is the "declared ≠ enforced" shape AGENTS.md #10 names, one layer down at the call site rather than at the
switch.Two nearby facts, so the shape is not mistaken for an oversight in isolation:
sys_user_position(§4) andsys_user_permission_set(§6) both callisGrantActive, and §6 goes out of its way to filter before any derivation so an expiredadmin_full_accesscannot yieldplatform_admineither. Only the membership-role projection skips it.Repro (already on
main's behaviour)packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts(added by PR #10981) carries a fixture that pins the current answer —lapsed-own-membership-among-active-peers, whose golden was captured frommainat38bc74ed1:{ user_id: 'u_lapsed', organization_id: 'org_a', role: 'member', valid_until: <past> }accessible_org_ids: [](window honoured) andpositions: ['org_member', 'everyone'](window ignored)Change that row's
roleto'owner'and the resolved posture becomesTENANT_ADMINoff a lapsed membership.Decision needed
Whether the role projection should honour the window, i.e. whether a lapsed membership is no membership (making (b) match (a)) or merely no org access. If the former — the reading ADR-0091 D2's "fail-closed" language suggests — the fix is one
isGrantActivecall in theactiveMembersfilter, plus a test per direction. It changes authorization semantics, so it wants its own review and its own card, not a rider on a batching PR.