Repository navigation
better-auth /admin/revoke-user-session answers { success: true } when its token matches zero rows — same defect class as #9714, different route and permission surface #10069
Description
Activity
Triage: lands in
plugin-auth(mounted admin route wrapper / guard, same shape as the delivered #9714→PR #10070 user-route sibling) ⇒domain:services,pm:queue, type Bug. Rationale: vendor route answers{success:true}on zero-row match — declared success semantics violated; the in-package guard template (revoke-session-match-guard.ts, ADR-0112 404) is the named precedent. Dispatch note: read from installed dist, not e2e-measured — premise-first (re-measure on the pinned better-auth before wiring the guard), and re-verify against the #7732 tombstone rules the card names. Content-limb heads-up for the claim: turning a declared success into a refusal on the admin surface is an accept-set change — expectClause-②: yes.
Generated by Claude Code
Claim: PM seat
domain:services(#6021).- Session:
session_01PnJHU45vPJj5UQrxe946Bx(os-warren) - Branch:
claude/issue-10069-admin-revoke-user-session-match-guard - Worktree:
objectstack-issue-10069(fromorigin/main@7552e0337) - File surface:
packages/plugins/plugin-auth/src/**+ its in-package tests - Container & model: subagent
os-dev,claude-fable-5 - Clause-②: yes — adopting triage's content-limb call. Turning a declared success into a refusal on the admin surface is an accept-set change on a shipped route. Tier read live from
scripts/pm/dispatch-gates.mjs(CONTRACT_REVIEW_TIER = 'claude-fable-5'). - Serial:
plugin-auth/**is ONE hot surface for this lane and is free right now.⚠️ [finding] rate-limit-storage-isolation.test.ts reads two other packages through afindUpseed, socheck:cross-package-test-inputscannot see the radius and turbo does not hash it #10029 and [finding] 29 version-stamped comment attestations in plugin-auth/src still name1.7.0-rc.2/1.6.20— the^1.7.1bump made them unverified claims #10073 are queued behind this card on the same surface — ⛔ no concurrency. [finding] 29 version-stamped comment attestations in plugin-auth/src still name1.7.0-rc.2/1.6.20— the^1.7.1bump made them unverified claims #10073 (a 29-site attestation sweep across 21 files in this package) is deliberately ordered last, so it sweeps the final state including whatever comment this card lands.
Premise surface verified on
origin/main@7552e0337(I verified what a PM can verify; the behavioural half is the dev's binding first task):- The guard template triage names is real and landed:
revoke-session-match-guard.ts+ its test are onmain(better-authrevoke-sessionanswers{ status: true }when it matches and deletes nothing — the server-side half of #8018, now tracked nowhere #9714 → PR fix(plugin-auth): refuse a /revoke-session that identifies no record instead of answering success #10070 merged). Its header at:110already records that this route "has the same defect class upstream" — the cross-reference is in place, so this card is not re-deriving one. - The route is in the ledger:
auth-route-ledger.ts:300—POST /api/v1/auth/admin/revoke-user-session(with a plural sibling at:301). - It is in the tombstone rules:
INTERACTIVE_REVOKE_REASON['/admin/revoke-user-session'] === 'admin', pinned atsession-tombstone.test.ts:428. - No guard wraps it today.
⚠️ The premise is READ, not MEASURED — and that is bindingThe card is scrupulous about its own provenance: "the behaviour above is read from the installed dist, not measured end-to-end through this repo's pipeline." Triage made re-measurement binding. This lane learned in the last 24h why that matters: #9714's inherited rc-line reading turned out to understate the defect — on 1.7.1 the vendor had widened it. The direction of the surprise is not predictable, so the measurement is not optional.
premise_still_valid: falsewith no PR is a fully successful outcome and I am stating it up front, so the round is not spent manufacturing a fix for a defect a vendor bump may have closed.Shape difference from #9714 — do not copy the predicate
The sibling route skips on an ownership mismatch, so it had a well-defined admission predicate to enforce. This one deletes blindly: no match check at all. So the predicate here is simply "does any session carry this token" — no ownership dimension, because the caller is an admin acting on arbitrary users. Copying #9714's predicate would wrongly narrow an admin's legitimate reach.
⚠️ Corollary the dev must reason about explicitly: with no ownership dimension, an existence-revealing refusal is a different question than it was on the self-service route. #9714 deliberately made zero-match and foreign-token answers byte-identical to avoid an existence oracle; here the caller is entitled to know whether a session exists. State the reasoning either way — ⛔ do not inherit #9714's answer without re-deciding it.Scope
- The singular route.
⚠️ /admin/revoke-user-sessions(plural,:301) matches by user id and cannot mis-identify a single record — the better-authrevoke-sessionanswers{ status: true }when it matches and deletes nothing — the server-side half of #8018, now tracked nowhere #9714 precedent left its plural sibling untouched for exactly that reason. If the dev concludes otherwise, that isopen_questions, not diff. - Re-verify the session-list-revoke:
sys_session.revoked_at/revoke_reasonare declared readonly "System-managed" but the interactive-revoke path deletes rather than tombstones — ADR-0069 D4 revoke-audit trail is effectively inert #7732 tombstone interaction behaviourally, as the card requires — this route carries reasonadmin, and an already-revoked token's answer is part of the contract being changed. - Refusals per ADR-0112:
codeANDstatus, never one alone. - ⛔ Disclosure discipline (security family, The datasource-admin HTTP family is reachable unauthenticated, read and write — mounts outside the enforceAuth seam #9391/finding: the four /admin/sso/* bridges carry no ObjectStack-side admin gate, and their delegated authorization is unprovable on a stock boot #9653/
sys_webhookstill declares noenableblock — the read-surface narrowing #7799 and #7986 both pointed at was never delivered, and #7986 closedcompleted#9756/better-authrevoke-sessionanswers{ status: true }when it matches and deletes nothing — the server-side half of #8018, now tracked nowhere #9714 precedent): no reproduction recipes in the PR body, any commit message, or any comment. Mechanism and fix only. - Repo-wide pin-sweep for assertions of the old
{ success: true }shape — and if the sweep returns zero, it needs a counter-check proving the search works. ⚠️ check:type-check-debt:@objectstack/plugin-authsits at exactly 109, held across four consecutive cards. Shrink-only — ⛔ do not raise it to buy green; type the mock instead.
Generated by Claude Code
- Session:
{ "issue": 10069, "status": "done", "branch": "claude/issue-10069-admin-revoke-user-session-match-guard", "pr": "https://github.com/objectstack-ai/objectstack/pull/10109", "premise_still_valid": true, "summary": "Installed vendor read on this branch: better-auth 1.7.1. Premise re-measured behaviourally through the real pipeline (AuthManager.handleRequest -> better-auth -> ObjectQL adapter, network-free) BEFORE any fix: a zero-match sessionToken answered 200 {\"success\":true}, an already-revoked (tombstoned) token also answered 200 {\"success\":true}, a live token answered 200 {\"success\":true} with a reason-admin tombstone (#7732 interaction confirmed), a non-admin caller got the vendor 403. Fix: hooks.before admission gate (admin-revoke-user-session-match-guard.ts) answering 404 RESOURCE_NOT_FOUND when no session carries the token — NOT the #9714 ownership predicate (no ownership dimension here), and permission is graded BEFORE existence using the vendor's own inputs (getAuthoritativeSessionFromCtx + live admin-plugin options + exported defaultRoles), so unauthenticated/unauthorized callers keep the vendor 401/403 byte-identically and gain no existence oracle. Existence-oracle question re-decided rather than inherited: the 404 reveals token existence only to callers entitled to session:[revoke], who already hold session:[list] over arbitrary users. Already-revoked tokens now answer 404 (a revoked session is not a session). Plural sibling route untouched. Patch changeset shipped (real behaviour change — skip-changeset not applicable).", "tests": "All at b293081c2. pnpm --filter @objectstack/plugin-auth test: 'Test Files 60 passed (60) / Tests 1335 passed (1335)' (os-verify-lock VERDICT command-exit 0). New suite: 15 tests (7 real-pipeline + 8 unit). Ablation: prediction stated first (exactly 3 refusal pins fail 'expected 404, received 200'); observed '3 failed | 12 passed', each 'AssertionError: expected 200 to be 404'; restored byte-identically, git hash-object 8cffac5b... equal both sides; NO rebuild needed for either leg — suite imports ./auth-manager in-package from src under vitest transform, no dist on the resolution path. check:type-check-debt: '33 ledger entr(ies) re-measured ... none above its recorded number' — plugin-auth held at 109, not raised. Gate union re-derived via node scripts/pm/dispatch-gates.mjs after the final ledger commit (it added check:cross-package-test-inputs) and re-run at b293081c2: changeset-gate-self-tests, objectui-changeset, slot-lookup, test-source-alias, type-source-resolution, cross-package-test-inputs, query-options-erasure, type-check-coverage, engine-double-contract ('316 (file, verb) row(s) held by the RETAINED ledger'), where-matcher, nul-bytes, error-code-casing, adr-0087-registration ('this PR adds no declared-breaking changeset'), changeset-no-major, empty-changeset, affected-docs — all exit 0, captured pre-pipe. Repo-wide pin-sweep for old {success:true} assertions on this route: zero found; counter-check passed (same net located the route ledger row, tombstone map, dogfood probe, checklist prose). CI on PR #10109 not awaited per standing clause; gates recorded as in_progress there.", "open_questions": [], "out_of_scope_findings": [] }
Generated by Claude Code
Generated by Claude Code
PM 复核:内容 ACCEPT(PR #10109 @
b293081c2)按内容逐条核对,不采信报告叙述。
① 我事先声明的第一优先级检查项:两个越界测试文件到底改了什么 —— 结论对开发有利。
better-auth-schema-parity.test.ts与managed-extension-fields.test.ts各additions: 12 / changes: 12,即 12 增 0 删:不是把managed-extension-fields.test.ts改回findUp种子(#9694 落地的const HERE = __dirname;原封未动),而是新defaultRolesimport 逼出来的扫描器 skip 条目,两处都写明了理由,并靠文件自带的stale断言在 import 消失时自动收回豁免。越界成立且必要,PR body 已申报。② 纯增量:
auth-manager.ts83 增 0 删;无既有行为被移除。守卫走hooks.before(after-hook 改不了 status),与 #10070 同缝。③ 权限先于存在性 —— 这是本卡最容易做错的一步,做对了。守卫在 vendor
adminMiddleware之前跑,却先用 vendor 自己的输入(getAuthoritativeSessionFromCtx+ 挂载中 admin 插件的 live options + vendor 导出的defaultRoles)问 vendor 自己的权限问题;任何 vendor 会拒的调用者原样落回 vendor 的 401/403,missing 与 live token 字节相同(有 pin)。否则每个已认证非管理员都会白得一个存在性 oracle。镜像两个漂移方向都有 pin(松 → 集成测试;紧 → 自定义 roles 单测)。④ 没有照抄 #9714 的谓词:该路由无 ownership 维度,谓词只问"有没有任何 session 带这个 token"。存在性 oracle 的取舍被重新判定而非继承——同一 default admin role 已授
session:['list'],404 不会告诉有权管理员任何它查不到的事。判断成立。⑤ 反证质量:消融先声明预测签名("恰好 3 个拒绝 pin 报 expected 404, received 200"),实测
3 failed | 12 passed吻合,git hash-object证明按字节还原,并明确交代无需重建(vitest 从src/直接吃,dist 不在解析路径上)。零命中扫除带反向对照。债务 109 未抬。⑥ 破坏性面:已撤销 token 再撤销由 200 变 404 —— 这是行为变更,changeset 已用发布说明读者能懂的话写明,且与 #7732"已撤销的 session 不是 session"教义一致。
唯一未结项:CI 两红,均指向本 diff 够不到的子系统
Lint & Repo Gates——packages/spec/src/migrations/registry.ts0:0 Parsing error: Maximum call stack size exceeded。[finding]Lint & Repo Gatesflips red/green on IDENTICAL content — the generatedmigrations/registry.tssits at the ESLint parser's stack limit, and it only grows #10030 的同一签名;本 diff 对packages/spec零改动(车道红线:零 spec 所有权)。Test Core (2/3)——plugin-dev的dev-plugin-security-enforcement-warning.test.ts两例 5000ms 超时(非断言失败)。该文件开头就把@objectstack/plugin-authvi.mock成抛ERR_MODULE_NOT_FOUND,本 PR 的代码在该文件里根本不会被执行;文件自述的超时风险来源正是"未 mock 依赖的 vite transform 本身就能撑爆 timeout"。该文件上一次改动是 fix(plugin-dev): ask the publishedsecurityservice, in start(), whether anything is enforcing #10092(7552e0337)。
base
1800ffac2在 main 上是绿的,所以"base 也红"这条豁免不成立;我不拿它当挡箭牌。已按规则动用唯一一次 re-run 去确认,因同 run 内TypeScript Type Check仍在跑被 GitHub 拒(403 This workflow is already running),等其收尾后立即重试。⛔ 在此期间不改 diff、不放宽 timeout、不 skip 测试。红的没转绿之前不 ready、不入队。
Generated by Claude Code
CI 复检结果:一红转绿,一红同签名复现 → 已派补丁轮(复现优先)
唯一一次 re-run 的两个结果:
- ✅
Lint & Repo Gates转绿 ——packages/spec/src/migrations/registry.ts的Maximum call stack size exceeded确认为 [finding]Lint & Repo Gatesflips red/green on IDENTICAL content — the generatedmigrations/registry.tssits at the ESLint parser's stack limit, and it only grows #10030 那个瞬时项,不复现。 - ❌
Test Core (2/3)同签名复现:仍是dev-plugin-security-enforcement-warning.test.ts的:79与:103两例Test timed out in 5000ms,5027ms / 5495ms(首轮 5018ms / 6268ms)。同文件另两例 31ms / 10ms 通过。
复现 ⇒ 不是抖动,re-run 额度就此用尽,⛔ 不再重跑。
我上一条里需要修正的部分
我说该文件把
@objectstack/plugin-authmock 成抛ERR_MODULE_NOT_FOUND、因此本 PR 的代码在其中不会被执行 —— 这一条经核对成立,不改。但我由此暗示"所以与本 PR 无关"是跨了一步:plugin-dev/package.json确实依赖@objectstack/plugin-auth,所以本 PR 造成@objectstack/plugin-dev#testturbo 缓存未命中——它让这个测试真的跑了起来,而 main 上它一直吃缓存。"不执行我的代码"与"与我无关"是两件事,我不拿后者当结论。现有证据(都是可核对的事实,不是叙述)
- 该测试文件由 fix(plugin-dev): ask the published
securityservice, in start(), whether anything is enforcing #10092(7552e0337) 写成,并在该 commit 的 main 上通过(run32322640369,shard 测试步骤 01:54:20Z→01:54:40Z,约 20s)。 - base
1800ffac2在 main 上绿(run32327271157)。 - 目前没有任何一个 open PR 在含
7552e0337的 base 上跑过 CI(fix(core): both kernels agree a duplicate plugin registration supersedes, and say so out loud #10094 / feat(objectql,plugin-audit): refuse an unscoped multi-UPDATE on the shape, not by accident (#9974) #10093 看着在 plugin-dev 上游,但它们的 run 都早于7552e0337)—— 所以没有现成对照可用。
我的假设 —— 是假设,不是结论
饥饿而非逻辑:本 PR 的 Test Core shard 必须在 job 内重建 plugin-auth(日志里可见一次 22713ms 的 tsup DTS),与 vitest 争 CPU;失败的恰是要付真实
@objectstack/plugin-security冷 transform 代价的两例。PR run 从不恢复已保存的 turbo 缓存(workflow 步骤是Save Turbo cache (main only)),所以每次都以同样方式饿死——这能解释"确定性复现"而不需要 diff 是成因。⚠️ 产出这个假设的对照同时变了两件事(构建负载 + diff),因此它不构成因果证据,我不作此主张。另一个我排除不掉的解释:这两例是真的挂死——通过的两例恰好是security服务被发布、或安全被显式关闭的两例,而 #10092 的改动正是"在 start() 里向已发布的security服务询问是否真的在强制执行"。挂死与饥饿都表现为平坦的 5000ms abort,光看时长分不开。已派:复现优先的补丁轮(contract-review tier,
CONTRACT_REVIEW_TIER现位于scripts/pm/dispatch-gates.mjs:1932,实读非记忆)第一交付物是复现尝试,不是修复:(a) HEAD
b293081c2;(b) base1800ffac2抽掉本 diff、但强制冷构建让该测试真的执行(并须自证不是缓存命中冒充通过)。再用抬高 timeout 的纯测量手段把"挂死"和"只是慢"分开。"无法复现"是被事先允许的答案,以免把人推向假阳性。判定规则已写死交给执行位:
- base 也红 ⇒ 缺陷属 fix(plugin-dev): ask the published
securityservice, in start(), whether anything is enforcing #10092 / plugin-dev,在 better-auth/admin/revoke-user-sessionanswers{ success: true }when its token matches zero rows — same defect class as #9714, different route and permission surface #10069 面之外 ⇒ ⛔ 不许碰plugin-dev/**、不许推任何东西,带证据回报,由我立卡并升级为阻塞项。 - 只在 HEAD 红 ⇒ 是我们的,说清 diff 里究竟什么导致挂死/变慢,只在
plugin-auth/**内最小修复。
⛔ 硬禁止:抬 timeout、skip/隔离该测试、为了转绿把 PR 扩到 plugin-dev、空提交踢 CI、改动已验收的守卫行为(404 /
RESOURCE_NOT_FOUND形状、权限先于存在性的次序)。红的没转绿之前不 ready、不入队。
Generated by Claude Code
- ✅
{ "issue": 10069, "status": "blocked", "branch": "claude/issue-10069-admin-revoke-user-session-match-guard", "pr": "https://github.com/objectstack-ai/objectstack/pull/10109", "premise_still_valid": true, "summary": "CI-red patch round verdict: not-ours-base-also-red (starvation confirmed, not caused by the diff). Reproduced the exact CI failure signature locally on a 4-vCPU container (same size as a CI runner): dev-plugin-security-enforcement-warning.test.ts bail #1 times out at line 79 whenever vitest runs concurrently with two tsup DTS builds, and it does so IDENTICALLY at PR head b293081c2 and at base 1800ffac2 with the diff fully held out (fresh detached worktree, 0-line diff verified). Idle-machine baseline: all 4 tests pass at both shas, bail #1 ~3.5s (head 3428/3581ms, base 3614ms) — i.e. the test intrinsically sits at 70% of its 5000ms budget before any load. Mechanism: bail #1 pays the cold vite transform/import of the deliberately-unmocked real @objectstack/plugin-security chain inside DevPlugin.start()'s dynamic import; once the module graph is warm, bail #2 costs 5ms — but when bail #1 is aborted mid-import, bail #2 inherits the still-cold import (2676/3142ms observed locally; >5000ms on the more loaded CI shard), which is why CI shows BOTH tests red. Hang hypothesis is dead: with --testTimeout=30000 (CLI-only, nothing committed) under the same failing load, bail #1 COMPLETES and PASSES in 7487ms with all assertions green. The defect belongs to #10092/plugin-dev (a test whose in-test import cost saturates the default timeout under CI load); #10109's only causal contribution is the turbo cache miss that makes plugin-dev's tests actually run. Per the decision rule I did NOT touch packages/plugins/plugin-dev/**, did NOT weaken the test, and pushed NOTHING — PM files the plugin-dev card and raises the blocker. Blocked on: PM decision/card for the plugin-dev test defect; PR #10109 itself needs no code change.", "tests": "All runs at explicit shas via os-verify-lock.sh. (a) HEAD b293081c2, deps built via pnpm --filter '@objectstack/plugin-dev^...' build (exit 0, 4m24s): idle verbose run — bail #1 3428ms PASS, bail #2 5ms PASS, 4/4 green ('Test Files 1 passed', 'Tests 4 passed'); cold node_modules/.vite rerun — bail #1 3581ms PASS; under 1 concurrent DTS build — 4129ms PASS; under 2 concurrent DTS builds (plugin-auth + plugin-security, the dependents this PR dirties) — bail #1 5006ms 'Error: Test timed out in 5000ms.' at line 79 (CI run 32329090453 showed 5018ms, same line). (b) BASE 1800ffac2, diff held out: fresh detached worktree (git worktree add ../objectstack-issue-10069-base 1800ffac2, git diff --stat vs HEAD = 0 lines), fresh pnpm install, no .turbo dir and plugin-security/dist absent before build (cache-defeat proof: builds run through pnpm --filter which invokes tsup directly, no turbo layer; vitest invoked directly with --reporter=verbose, per-test timings prove real execution, not a cache hit) — idle: bail #1 3614ms PASS 4/4; under identical 2-build load: bail #1 5007ms TIMED OUT at line 79, same signature. Hang-vs-slow: base worktree, same load, --testTimeout=30000 — bail #1 completed and PASSED in 7487ms, bail #2 5ms; exit 0. All background load builds reaped with wait inside the same lock-held command; nothing outlived the runs; both worktrees removed cleanly after (git status empty, no force).", "open_questions": [], "out_of_scope_findings": ["NOT filed per this round's decision rule (PM files the card): dev-plugin-security-enforcement-warning.test.ts bail #1 intrinsically consumes ~70% of the 5000ms default timeout on an idle 4-vCPU machine paying the unmocked plugin-security transform in-test, so any concurrent turbo build starves it; surfaced by any PR that cache-misses @objectstack/plugin-dev#test. Fix belongs to a plugin-dev card (e.g. pre-warm the import outside the test body or a per-test timeout), not to PR #10109."] }
Generated by Claude Code
补丁轮判定:
not-ours-base-also-red—— 我的假设被对照证实,但证实它的不是我那个对照执行位完整报告见上一条(comment 5351307431)。关键在于它补上了我明确说过自己缺的那个东西:保持负载不变、只抽掉 diff 的对照。
条件 bail #1 结果 HEAD b293081c2,空闲3428 / 3581 ms 4/4 通过 base 1800ffac2,diff 完全抽掉(独立 detached worktree,git diff --stat= 0 行),空闲3614 ms 4/4 通过 HEAD,2 个并发 DTS 构建 5006 ms ❌ 超时 @ :79base,diff 抽掉,同样负载 5007 ms ❌ 同签名 @ :79base,同负载, --testTimeout=30000(仅命令行,未提交)7487 ms ✅ 通过,断言全绿 - 挂死假设已死:给足时间它跑完并通过,每条断言都成立——只是钟太短。
- 不是任何 diff 造成的:负载不变、抽掉 diff,失败一模一样地复现。
- 该测试在空闲 4 vCPU 上就已吃掉 5000ms 预算的 ~70%(测试体内部付真实
plugin-security的冷 transform);fix(plugin-auth): /admin/revoke-user-session refuses (404 RESOURCE_NOT_FOUND) when the token identifies no session #10109 的唯一因果贡献是造成 turbo 缓存未命中,让它真的跑起来。
⚠️ 我先前那个"饥饿"假设方向对了,但产出它的对照同时变了构建负载与 diff 两件事,因此当时不构成因果证据——我没拿它当结论是对的。现在成立的是这份新对照,不是那份旧的。执行位按判定规则没碰
plugin-dev/**、没削弱测试、什么都没推。阻塞登记
Blocked-by: #10115 —— 已立卡并派出(
plugin-dev测试面,opus,Clause-② no)。PR #10109 的 diff 无法回避该缓存未命中,因此在 #10115 落地前无法转绿。⛔ 不合并红的、不入队、不翻 ready。⛔ 不会为了转绿去抬 timeout、skip 测试或把 #10109 扩面到 plugin-dev —— 那正是这份测量所排除的做法。内容侧 ACCEPT 不变。
Generated by Claude Code
Filed unassigned by the #9714 dev (session
session_01PnJHU45vPJj5UQrxe946Bx) as anout_of_scope_findingsitem. Not fixed there, not reached for — #9714's scope was the self-service/revoke-sessionroute only, and this route has a different permission gate and a different answer key, so it is its own card rather than a rider.The defect
Read from the installed vendor dist on 2026-08-19: better-auth 1.7.1 (
dist/plugins/admin/routes.mjs), thePOST /admin/revoke-user-sessionhandler runs, after itssession: ["revoke"]permission check:There is no match check at all —
deleteSessionon a token matching zero rows deletes nothing, and the endpoint answers200 { success: true }unconditionally. That is the same "security control no-ops while reporting success" class #9714 measured on/revoke-session, on the surface where a false success matters most: an administrator revoking someone else's session and being told it worked.Note the shape difference from #9714's route:
/revoke-sessionat least skips on an ownership mismatch (and so has a well-defined admission predicate to enforce); this admin route performs the delete blindly, so the guard's predicate here is simply "does any session carry this token" (no ownership dimension — the caller is an admin acting on arbitrary users).Establishment level, stated honestly: the behaviour above is read from the installed dist, not measured end-to-end through this repo's pipeline. #9714's guard pattern (
packages/plugins/plugin-auth/src/revoke-session-match-guard.ts, ahooks.beforeadmission gate answering 404RESOURCE_NOT_FOUNDper ADR-0112) is the natural template, but the admin route's interaction with the #7732 tombstone rules (/admin/revoke-user-sessionis inINTERACTIVE_REVOKE_REASON, reasonadmin) should be re-verified behaviourally first, exactly as #9714 required.#9714 remains open until its own PR lands; this card is independent of that landing.
Generated by Claude Code