Skip to content

[finding] Three spellings of the ADR-0068 platform-admin read now live in plugin-auth, and one of them skips the system read context #10348

Closed

Description

@os-warren

Found while implementing #9968 (impersonation half). Filed unassigned; no fix attempted — consolidating a security predicate is not that card's scope.

platform-admin-gate.ts's own header states the principle this violates:

Before this module the gate existed as four near-identical inline copies … N copies of an authorization predicate is the shape that drifts: the next mount is written by copying whichever copy the author happened to open. One exported judge, called by every mount, is the fix.

That consolidation covered the session-shaped question (isPlatformAdminUser(sessionUser) / judgePlatformAdmin(session)). The id-shaped question — "is this user id a platform admin?", i.e. the sys_user_permission_set → admin_full_access / organization_id = null lookup — was never consolidated, and now has three spellings in one package:

# Site Read path
1 auth-manager.ts — the isPlatformAdmin() closure inside customSession dataEngine.find(...) directly
2 auth-manager.ts — isOrgOrPlatformAdmin()'s first half withSystemReadContext(engine).find(...)
3 auth-manager.ts — isPlatformAdminUserId() (added by #9968) withSystemReadContext(engine).find(...)

All three do the same two reads with the same limit: 50 and the same admin_full_access name match, and all three fail closed.

The one substantive difference is #1. It queries the engine without withSystemReadContext, while #2 and #3 both wrap. isOrgOrPlatformAdmin's docblock says why the wrapper is there:

Reads through withSystemReadContext so the lookups are not themselves RLS-scoped to the acting (possibly non-privileged) user.

Whether that matters for #1 depends on whether customSession's callback already runs in a privileged context. This finding does not claim #1 is a bug — that is the measurement someone should take before acting, and it is exactly the kind of difference that is invisible while three copies exist. If the wrapper is unnecessary in #1, the two wrapped copies are carrying a cost for nothing; if it is necessary, #1 is a session-derivation path that can under-report platform admin for a caller whose RLS scope hides the join rows.

resolve-authz-context.ts is named as authoritative for the platform-admin half by isOrgOrPlatformAdmin's docblock, which suggests a fourth spelling may exist outside this package — worth counting as part of the same sweep.

Suggested shape: one exported id-shaped judge next to isPlatformAdminUser in platform-admin-gate.ts, taking the data engine, with the system-read-context decision made once and written down; the three call sites then differ only in what they do with the answer. Note that isOrgOrPlatformAdmin must keep its narrower/wider distinction — it deliberately also admits org owners/admins, which a platform-admin route must not.

Activity

  1. self-assigned this
    on Aug 21, 2026
  2. os-warren commented on Aug 21, 2026

    @os-warren
    CollaboratorAuthor

    Claim: PM domain:services 派发 —— 测量优先,且带一条硬停手

    ⚠️ 卡面已经过时了:现在是两种拼法,不是三种

    卡面写于今天之前。我在 main @ 9d101d284 上重测:

    卡面 现状
    #1 customSession 内的 isPlatformAdmin() 闭包,直接 dataEngine.find(...) 仍在(:2971 的 admin_full_access 匹配)
    #2 isOrgOrPlatformAdmin() 的前半 ⛔ 已不存在 —— PR #10390 今天把它整个删掉了(isOrgOrPlatformAdmin 在该文件仅剩 1 处散文提及)
    #3 isPlatformAdminUserId() 仍在(:4436)

    ⇒ 本卡的普查基数已由 3 降为 2,而这一半是本车道自己今天改掉的。⚠️ 以你的重测为准,但不要照着卡面去找一个已经不存在的拼法。

    卡面怀疑的「第四种拼法」有一个具名候选

    卡面说 resolve-authz-context.ts 被 isOrgOrPlatformAdmin 的 docblock 点名为平台管理员那一半的权威,暗示包外还有一份。我扫了一遍:admin_full_access 在多个包出现,其中 packages/core/src/security/admin-standing-surface.ts 是最像的候选。⚠️ 这是我的线索,不是结论 —— 请自己做完整普查并报出你数到的总数。

    ⛔ 硬停手:如果修法是「给 #1 补上 wrapper」,不要落地,上报

    卡面自己说得很清楚:「This finding does not claim #1 is a bug」 —— 那正是要先测的东西。而测量有两种结果,方向相反:

    若 则
    wrapper 在 #1 不必要(customSession 回调本来就跑在特权上下文里) 两份包着 wrapper 的副本在白付成本;收敛是纯整理,可以做
    wrapper 在 #1 必要 #1 是一条会漏报平台管理员的会话派生路径(对某些 RLS 作用域看不见 join 行的调用者)⇒ 补上 wrapper 会让更多人被认定为平台管理员

    ⚠️ 第二种是安全边界的放宽,那是维护者地板,不是本车道能落的。 收紧不需要裁决,放宽需要。

    ⇒ 若你的测量指向第二种:停下,把测量写进报告和卡面,不要改代码。 ⛔ 不要把它伪装成「消除重复」顺手带过 —— 一次改变谁被授权的改动,不因为它长得像重构就变成重构。

    实现要求

    1. 先测 Add metamodel interfaces for ObjectQL/ObjectUI contract #1 的上下文,这是本卡的全部要害。 customSession 的回调是否已经跑在特权读上下文里?从代码与运行时两侧证明,不要只读一处。
      • ⚠️ 零命中反向核对:若你的结论靠「找不到 RLS 作用域施加在这条路径上」,先用邻近的已知会被作用域限制的读证明你的观测装置有效。
    2. 完整普查 id 形态的读(sys_user_permission_set → admin_full_access / organization_id = null),跨包。报出总数、各自是否包 wrapper、以及它们是否真的做同样的两次读(卡面说三处都用 limit: 50 和同样的名字匹配——核实,不要转抄)。
    3. 若收敛可做:一个 id 形态的 judge 放在 platform-admin-gate.ts 里 isPlatformAdminUser 旁边,接受 data engine,把 system-read-context 的决定做一次并写下来。
      ⛔ isOrgOrPlatformAdmin 的窄/宽区分必须保住 —— 卡面明确要求。注意:它今天已被 security(plugin-auth): gate direct /sso/register on platform admin only #10390 删除,所以这条约束现在的意思是:不要在收敛过程中把「也放行 org owner/admin」这个更宽的语义重新引进任何平台管理员路径。 finding: the direct /sso/register endpoint's ADR-0024 before-hook admits org owners/admins — wider than the platform-admin posture #9653 landed on the /admin/sso/* bridges #10009/security(plugin-auth): gate direct /sso/register on platform admin only #10390 刚刚花了一整张卡把它从 /sso/register 上摘掉。
    4. 双向 pin:① 平台管理员(ADR-0068 D2 方式:org-less admin_full_access 链接)被认定;② org owner/admin 不被认定(这是 security(plugin-auth): gate direct /sso/register on platform admin only #10390 刚建立的边界,只钉①会让一个放宽的实现全绿);③ 空 id / 查找失败fail closed;④ 收敛前后同一批输入得到同一批答案(若你判定这是纯整理,这条就是它的证明)。
    5. 消融:预测签名先写后跑,双向;git hash-object 证明恢复逐字节一致;用阳性对照证明(非断言)测试解析的是 src/ 还是 dist/。
    6. Clause-②:若最终是纯整理(行为完全不变),no;若任何调用者的判定结果改变,那已经落进上面的硬停手。
    7. 门禁并集:node scripts/pm/dispatch-gates.mjs 不带路径参数推导,最终提交之后、干净工作树上跑;退出码在管道之前捕获;引用每道门禁自己那行判词。
      ⚠️ class [finding] Every PM dispatch list is short by the same ~5 changeset-triggered gate families — they are path-derivable, but the changeset does not exist yet when the list is derived #10309:额外显式跑 pnpm check:route-envelope 与 pnpm check:dispatcher-error-vocabulary(各带 --self-test),报告推导是否点名了它们。
    8. ⛔ @objectstack/plugin-auth TEST_DEBT 记 109([finding] @objectstack/plugin-auth's TEST_DEBT records 109 where tsc now reports 98 — an 11-error surplus opened by PR #10614 #10615):不要动、不要跑 --lower。
    9. ⛔ 披露纪律:本卡涉及授权谓词。公开评论只写结论与判据,不写复现配方。
    10. 开 draft PR(若有代码可落)。⛔ 不翻 ready、⛔ 不武装、⛔ 不合并。若结论是「停手上报」,那就不开 PR,只交测量。

    常备仪器陷阱(今天全部实测)

    报告

    ⭐ 评论第一行用字面文本 os-dev-report(单独一行)。


    Generated by Claude Code

  3. os-warren commented on Aug 21, 2026

    @os-warren
    CollaboratorAuthor

    PLACEHOLDER — replaced below


    Generated by Claude Code

  4. os-warren commented on Aug 21, 2026

    @os-warren
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 10348,
      "status": "needs_decision",
      "branch": "claude/issue-10348-id-shaped-platform-admin-judge",
      "pr": null,
      "premise_still_valid": false,
      "summary": "Measurement-only run; no code landed, no PR opened, per the dispatch hard stop. (1) The card's population of three is now two, as the PM corrected: isOrgOrPlatformAdmin is gone. (2) The wrapper question landed on the STOP branch. Runtime-measured over the real security middleware: a context-less read and an isSystem read of sys_user_permission_set reach IDENTICAL rows in this repo, so the wrapper is a no-op here -- but it reaches that identity through the empty-principal fall-open, not through any privilege, so 'the customSession callback already runs privileged' is FALSE. Three independent places in this repo record that context-less reads were measured being filtered to zero by a deployment's org-scope read hook, and the repo separately states that organization_id = NULL rows are 'invisible under strict org-scoping' -- which is exactly the row class site #1 needs. So adding the wrapper is zero-change here and a WIDENING there (more principals judged platform admin). Maintainer floor. (3) The card's suspected fourth spelling EXISTS and is core/security/resolve-authz-context.ts, the declared single source of truth -- not admin-standing-surface.ts (the PM's lead), which performs no read at all. (4) Overturning the card's 'all three do the same two reads': the two plugin-auth spellings agree with each other byte-for-byte but disagree with the authority on three axes, two of which are live authorization defects on /sso/register and /admin/impersonate-user. Filed separately (see out_of_scope_findings).",
      "tests": "Tree is byte-identical to base 9d101d284 (git diff --stat against the base sha is empty); nothing was committed, so there is no head sha other than 9d101d284 and no gate union to report. Build: `pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-auth^...' build` -> os-verify-lock VERDICT command-exit 0, held 266s. Baseline: `pnpm --filter @objectstack/plugin-auth test` -> 'Test Files 66 passed (66) / Tests 1378 passed (1378)' on the unmodified tree plus the probe. MEASUREMENT PROBE A (throwaway, plugin-auth, recording data engine driven through the real customSession callback and the real isPlatformAdminUserId): SITE#1 reads = [{sys_user_permission_set,{where:{user_id},limit:50}},{sys_permission_set,{limit:50}}] -- no context on EITHER channel (query.context and the trailing EngineReadOptions both absent); SITE#3 reads = the same two queries plus context:{isSystem:true}. POSITIVE CONTROL for the apparatus: it DOES see a context when one is present (site #3), so site #1's absence is a measurement, not a blind spot. MEASUREMENT PROBE B (throwaway, plugin-security, the REAL SecurityPlugin middleware booted over a harness, same object): CASE-A(no context) outcome=passed ast.where={\"user_id\":\"u-1\"} (untouched); CASE-B(member ctx) POSITIVE CONTROL outcome=passed ast.where={\"$and\":[{\"user_id\":\"u-1\"},{\"$and\":[{\"organization_id\":\"org-1\"},{\"organization_id\":\"org-1\"}]}]} -- the apparatus demonstrably detects scoping when it is applied, so CASE-A is a real zero, not a zero-hit artifact; CASE-C(isSystem) outcome=passed ast.where untouched, identical to CASE-A. ABLATION (prediction written before mutating, both legs, NO rebuild in either leg -- and no rebuild was NEEDED, which is itself the src-vs-dist positive control): mutating site #1 to wrap in withSystemReadContext, with dist/ untouched, changed the probe output to context:{isSystem:true} and turned it RED (EXIT=1, 'Tests 1 failed (1)', AssertionError: expected false to be true) -- predicted signature P1, direction RED, exactly as written down; P2 (unchanged output = resolves through dist/) did NOT occur, so the probe provably resolves ./auth-manager through src/. Restore leg: `git checkout -- ` on the file then re-run -> 'Test Files 1 passed (1)', and git hash-object 1044073db00341b2e02f2d91d162f2a72a4b78fd on both sides of the ablation (byte-identical restore). GATE UNION: `node scripts/pm/dispatch-gates.mjs` with no path arguments, clean tree, exit captured before any pipe -> DERIVATION_EXIT=2, its own line: \"dispatch-gates: this branch changes nothing against 'origin/main' (merge base 9d101d284) -- nothing to derive.\" Recorded as NOT APPLICABLE (empty diff), NOT as a pass; it named no gate family, therefore it did not name the class #10309 pair. Class #10309 pair run explicitly anyway, exit codes captured before any pipe: check-route-envelope.mjs --self-test EXIT=0 ('check-route-envelope self-test passed'); check-route-envelope.mjs EXIT=0 ('Express-style response modules -- 4 module(s) discovered and audited ... 2 conformant, 2 ratcheted, 0 exempt, 0 vendor-wire'); check-dispatcher-error-vocabulary.mjs --self-test EXIT=0 ('8 shapes + 102 assertions OK'); check-dispatcher-error-vocabulary.mjs EXIT=0 ('OK -- 21 unregistered code-stamping site(s), all classified; 1 awaiting a ledger entry (#8846)'). Both probe files were deleted before the final tree check; TEST_DEBT was not touched and --lower was never run.",
      "open_questions": [
        {
          "question": "Should the customSession isPlatformAdmin() closure (auth-manager.ts ~:2961) read through withSystemReadContext? Measured: it is a no-op in this repo's composition, and a WIDENING of who is judged platform admin on any deployment whose org-scope read hook filters context-less reads -- the composition withSystemContext's own docblock says it exists for, and where organization_id = NULL grant rows are exactly the class the repo calls 'invisible under strict org-scoping'.",
          "options": [
            "A -- Leave site #1 unwrapped and land nothing. Today's behaviour is preserved everywhere; on a strictly org-scoped deployment the session payload keeps under-reporting platform admin (a stale-looking UI, fail-closed), and the two spellings keep diverging.",
            "B -- Wrap site #1. Zero behaviour change in open core; on an org-scoped deployment principals currently under-reported start being judged platform admin. This is a loosening of an authorization boundary and is the maintainer's call, not this lane's.",
            "C -- Delete the plugin-auth id-shaped judges outright and have both call sites ask core/security/resolve-authz-context.ts, the module that already declares itself the single source of truth and forbids re-reading sys_*_permission_set. This subsumes the wrapper question (the authority reads as system) AND closes the three-axis divergence below in one move, but it is a real architecture change across a package boundary, not tidying."
          ],
          "recommendation": "C, with B's widening explicitly ruled on as part of it -- because the wrapper question is a symptom. resolve-authz-context.ts's header already states that every entry point MUST resolve authorization through it and never re-read these tables itself, and plugin-auth does exactly that twice; adopting the authority is the only option that satisfies the card's 'the decision made once and written down' without inventing a fourth decision. On the four axes: REAL BUSINESS NEED -- /sso/register and /admin/impersonate-user are live platform-operator gates with live callers, and C is the only option that makes them agree with what the rest of the platform means by platform admin. LONG-TERM SOUNDNESS -- A parks a known divergence, B leaves two judges and adds a decision; C removes the duplicate class the card was filed about, contract-first, no workaround. AI-WRITTEN-CODE SAFETY -- one judge that cannot be called two ways is structurally harder to get wrong than a judge taking an engine the caller may or may not have wrapped, which is what the card's own suggested shape degenerates into once the context decision cannot be made; the tolerant/duplicated form is where a generated call site silently picks the wrong copy. STARTUP SCOPE -- C adds no surface at all, it deletes one, and no capability is being invented. The one cost is that C changes verdicts (an expired or deactivated grant stops authorizing), which the dispatch places behind the hard stop even though it is a tightening -- hence this escalation rather than a PR."
        },
        {
          "question": "Does the card's constraint survive its own premise? The card requires preserving isOrgOrPlatformAdmin's narrower/wider distinction, but #10390 deleted that predicate today. Read forward, the constraint is 'do not reintroduce the wider org-owner/admin semantics into any platform-admin path' -- which nothing in this measurement proposes doing. Should the card be edited to say that, or retired in favour of a new card against the divergence actually found?",
          "options": [
            "A -- Edit #10348 in place: restate the population as two, name resolve-authz-context.ts as the fourth spelling it suspected, and record the wrapper measurement on the card.",
            "B -- Retire #10348 as overtaken and re-file against the three-axis divergence, which is the defect with consequences."
          ],
          "recommendation": "A -- the card's thesis (N copies of an authorization predicate drift, and the drift is invisible while the copies exist) was confirmed, not refuted; only its inventory went stale. Editing keeps the reasoning and the citations; retiring it loses them."
        }
      ],
      "out_of_scope_findings": [
        "NOT FILED -- GitHub search and list_issues both returned 'API rate limit already exceeded for user ID 318460043' on every attempt across this run, so the pre-file duplicate search could not be performed and I did not file blind. PM to file, unassigned, label `security`, no `pm:queue`: 'plugin-auth's two id-shaped platform-admin judges disagree with resolve-authz-context on three axes, two of them load-bearing on live gates'. BASIS (conclusions only, no recipe, per the dispatch's disclosure clause): auth-manager.ts's isPlatformAdminUserId (:4420) authorizes /sso/register and, via applyPlatformAdminImpersonation, /admin/impersonate-user; it and the customSession closure (:2961) both (i) apply NO ADR-0091 validity window (`isGrantActive`) and NO ADR-0049 active check (`isRowActive`) to the grant or to the permission-set row, where the declared authority applies both before any derivation; and (ii) resolve `admin_full_access` by matching a NAME over an unfiltered, unordered `find('sys_permission_set', { limit: 50 })` page, so an environment past that page size can lose the row and demote every platform admin at once. The corroboration is all in-repo: plugin-auth ALREADY imports isGrantActive/isRowActive from @objectstack/core in last-admin-guard.ts for the same table and the same set name; ensure-default-organization.ts already spells the same lookup correctly as `{ where: { name: 'admin_full_access' }, limit: 1 }`; last-admin-guard.ts's scan() deliberately reads maxScan + 1 and REFUSES rather than truncating; and packages/qa/dogfood/test/authz-conformance.matrix.ts:305 enumerates 'every reader that turns a window-carrying row into authority' -- auth-manager.ts's judge is a reader of exactly that kind and is absent from the list, so that enumeration is incomplete too. The repair direction is a TIGHTENING (fewer principals authorized). Not fixed here: the dispatch's clause 6 places any change to a caller's verdict behind the hard stop."
      ]
    }

    Measurement, in full

    Verdict: the dispatch's HARD STOP applies. No code landed, no PR opened. The branch
    claude/issue-10348-id-shaped-platform-admin-judge exists on the remote as the claim's landing
    marker only; its tree is byte-identical to 9d101d284.

    1. Site #1's read context — measured on both sides, with a working positive control

    The card asks whether the customSession callback already runs in a privileged read context.
    It does not. Two independent measurements:

    Code side. withSystemContext (objectql-adapter.ts:678) grants privilege by injecting
    context: { isSystem: true } into each query bag. There is no ambient store it consults — the only
    AsyncLocalStorage in packages/objectql/src/engine.ts is txStore, for transactions. So a bare
    dataEngine.find(...) carries no execution context on either channel: neither query.context nor
    the trailing EngineReadOptions that IDataEngine.find's third argument declares.

    Runtime side. A recording engine driven through the real customSession callback and the real
    isPlatformAdminUserId:

    SITE#1 reads = [{sys_user_permission_set,{where:{user_id:"u-1"},limit:50}},
                    {sys_permission_set,{limit:50}}]                       -- no context, either channel
    SITE#3 reads = [{sys_user_permission_set,{where:{user_id:"u-1"},limit:50,context:{isSystem:true}}},
                    {sys_permission_set,{limit:50,context:{isSystem:true}}}]
    

    The apparatus demonstrably sees a context when one is present (site #3), so site #1's absence is a
    measurement and not a blind spot.

    2. What the platform does with a context-less read — the zero-hit counter-check

    My conclusion could have rested on "I cannot find RLS applied on this path", so it was checked
    against a neighbouring read that is known to be scoped. The real SecurityPlugin middleware,
    booted over a harness, same object, three contexts:

    CASE-A (no context   = site #1)  passed   ast.where = {"user_id":"u-1"}                      -- untouched
    CASE-B (member ctx   = control)  passed   ast.where = {"$and":[{"user_id":"u-1"},
                                                          {"$and":[{"organization_id":"org-1"},
                                                                   {"organization_id":"org-1"}]}]}
    CASE-C (isSystem     = site #3)  passed   ast.where = {"user_id":"u-1"}                      -- untouched
    

    CASE-B is the positive control: the apparatus detects scoping when scoping is applied, so CASE-A is
    a real zero.

    So in this repo's composition, sites #1 and #3 reach identical rows — the wrapper is a no-op
    here.
    But they reach that identity by different mechanisms. #3 takes the middleware's declared
    isSystem bypass (security-plugin.ts:1257). #1 takes the empty-principal fall-open further
    down (positions.length === 0 && permissions.length === 0 && !context.userId then next()), which
    is not a privilege — it is the absence of one. The repo's own trend is to run new boundaries ahead
    of that fall-open: the package-managed write gate, the system-row write gate, the curated-name gate,
    the ADR-0103 engine-owned write guard and the delegated-admin gate each say, in their own comment,
    that they sit before it so principal-less callers fail closed.

    3. Why this is the STOP branch and not the tidying branch

    "No-op here" is not "unnecessary". The repo records, in three independent places, that context-less
    reads were measured being filtered to nothing by an out-of-repo read hook:

    • objectql/src/engine.ts:1800 — "an intended isSystem bypass just vanished (e.g. control-plane
      reads coming back empty once org-scoping hooks were added)"
    • spec/src/contracts/data-engine.ts:191 — the same statement, in the contract
    • plugin-auth/src/objectql-adapter.ts — withSystemContext's own docblock: reads run as system
      "so a deployment's control-plane org-scope read hook ... doesn't filter these caller-context-less
      adapter reads ... down to zero"

    And the row class site #1 depends on is precisely the one the repo elsewhere calls invisible under
    that hook: metadata-protocol/src/seed-loader.ts:1111 — seeds should carry an organization "instead
    of landing org-less (invisible under strict org-scoping)". An ADR-0068 D2 platform-admin grant is
    defined by organization_id = NULL.

    So adding the wrapper to #1 is zero change in open core and a widening on a strictly
    org-scoped deployment
    — principals currently under-reported would start being judged platform
    admin. That is a loosening of an authorization boundary, so it is maintainer floor. It is also, on
    that same evidence, not something I can dismiss as "a cost paid for nothing" in the two wrapped
    copies.

    4. Census of the id-shaped read, across packages

    Total true judges (user id in, platform-admin boolean out): 3. In-package: 2 — the PM's
    correction is confirmed, isOrgOrPlatformAdmin is gone, one prose mention survives in
    last-admin-guard.ts:96 and one in isPlatformAdminUserId's docblock.

    # Site system read? how spelled limits ADR-0091 window ADR-0049 active finds admin_full_access by
    A core/security/resolve-authz-context.ts:419 + step 6b/6c — the declared authority yes inline context:{isSystem:true} in the query 100 / 500 applied applied id ({id:{$in:...}})
    B plugin-auth/auth-manager.ts:2961 — the customSession closure no — 50 / 50 not applied not applied name, over an unfiltered unordered first-50 page
    C plugin-auth/auth-manager.ts:4426 — isPlatformAdminUserId yes withSystemReadContext(engine) 50 / 50 not applied not applied same as B

    The card's suspected fourth spelling exists, and it is A — exactly where the deleted
    isOrgOrPlatformAdmin docblock pointed. It is not core/security/admin-standing-surface.ts
    (the PM's named lead): that file issues no find at all; it is a declaration asserted equal to what
    the resolver reads, by an observation test.

    Counted and excluded, each with its reason — adjacent reads of the same join that are not this judge:
    plugin-auth/ensure-default-organization.ts:110 (the inverse question, "which user is the platform
    admin?"); plugin-auth/last-admin-guard.ts:770-960 (a population question, "would this write empty
    the administrator set?"); plugin-security/bootstrap-platform-admin.ts:238 (seed-time);
    plugin-security/explain-engine.ts:403 (diagnostics — it reads the verdict back off the resolver at
    :490, never re-derives); core/security/admin-standing-surface.ts (declaration, no read).

    Verifying rather than transcribing the card's "all three do the same two reads with the same
    limit: 50 and the same name match":
    true of B and C, whose query bags are byte-identical apart
    from context (captured above). False of the authority. The two plugin-auth spellings differ
    from A on three axes, and two of them are load-bearing — reported separately in
    out_of_scope_findings above, with its repair direction (a tightening) and the in-repo corroboration
    that the correct shape is already known in this very package.

    Worth noting for whoever takes that up: the system-read decision itself already has five
    spellings in the tree — withSystemReadContext(engine), inline context in the query bag, a
    trailing { context: SYSTEM_CTX }, a trailing SYSTEM_READ constant, and nothing at all.

    5. Ablation

    Prediction written down before mutating (two mutually exclusive signatures, so the src-vs-dist
    question is answered by outcome rather than asserted). Mutation: wrap site #1's two reads in
    withSystemReadContext. No rebuild in either leg — and none was needed, which is the positive
    control
    : with dist/ untouched the probe output changed, so the probe provably resolves
    ./auth-manager through src/.

    leg predicted observed
    mutated P1: context appears, assertion fails, RED context:{isSystem:true} printed; EXIT=1, Tests 1 failed (1), AssertionError: expected false to be true
    restored baseline output, test passes, hash equal Test Files 1 passed (1); git hash-object = 1044073db00341b2e02f2d91d162f2a72a4b78fd on both sides

    P2 (output unchanged = resolves through a built dist/) did not occur.

    6. Gates

    gate exit (captured before any pipe) its own verdict line
    scripts/pm/dispatch-gates.mjs, no path args, clean tree 2 "this branch changes nothing against 'origin/main' (merge base 9d101d2) — nothing to derive" — recorded NOT APPLICABLE (empty diff), not as a pass
    check-route-envelope.mjs --self-test 0 "check-route-envelope self-test passed"
    check-route-envelope.mjs 0 "4 module(s) discovered and audited ... 2 conformant, 2 ratcheted, 0 exempt, 0 vendor-wire"
    check-dispatcher-error-vocabulary.mjs --self-test 0 "8 shapes + 102 assertions OK (vocabulary + #9098 door typing)"
    check-dispatcher-error-vocabulary.mjs 0 "OK — 21 unregistered code-stamping site(s), all classified; 1 awaiting a ledger entry (#8846)"

    Because the diff is empty the derivation named no gate family, and therefore did not name the
    class #10309 pair; they were run explicitly regardless, as the dispatch asked.
    @objectstack/plugin-auth TEST_DEBT was not touched and --lower was never run.

    7. What I did not measure

    • The out-of-repo half. I never observed the enterprise org-scoping middleware — it is not in
      this repo. Everything in §3 about a strictly org-scoped deployment rests on this repo's own
      written record of that behaviour, not on my own observation of it. That is exactly why the wrapper
      question is escalated instead of decided.
    • No live deployment. No dev server was booted and no HTTP surface was driven; the context-less
      read is only reachable in-process, so REST could not have answered the question. Both runtime
      measurements are in-process over the real code paths.
    • No pin table was built, because nothing was landed. The four pins the dispatch specifies
      (platform admin admitted; org owner/admin refused; empty id / lookup failure fails closed; verdicts
      identical before and after) belong with whichever option is chosen, and pin 2 in particular must
      ship with any consolidation — a widened implementation goes green on pin 1 alone.
    • scripts/check-single-claim-paths.mjs was not run (dispatch instruction).
    • The duplicate search before filing the finding could not be run — GitHub search and
      list_issues both returned "API rate limit already exceeded" on every attempt across this run. I
      did not file blind; the finding is written out in full above so nothing is lost.

    Housekeeping: the one-line placeholder comment immediately above this one is mine and carries no content — it was posted by mistake while probing whether writes were reachable during a GitHub API rate limit. This comment is the report. The comment-edit endpoint is not reachable from this session, so it could not be removed.

    Generated by Claude Code


    Generated by Claude Code

  5. os-warren commented on Aug 21, 2026

    @os-warren
    CollaboratorAuthor

    PM 读数:needs_decision —— 硬停手按预期触发,零代码、零 PR

    树与 base 9d101d284 逐字节相同(git diff --stat 为空)。⛔ 未开 PR,⛔ 未改产品代码,围栏文件未被编辑。这正是我在派发里要的结果。

    dispatch-gates 返回 EXIT=2 并自述 this branch changes nothing … nothing to derive —— 记为 NOT APPLICABLE(空 diff)而非一次通过。⭐ 这个区分本身就是今天反复出现的那条:一个不携带信息的回答不能被当成肯定回答。


    ① 我的更正对了一半,另一半错了

    对的:普查基数是 2 不是 3(isOrgOrPlatformAdmin 已被 #10390 删除)。

    错的:我给的「第四种拼法」线索是 core/security/admin-standing-surface.ts。它根本不做读。 真正的第四处是 core/security/resolve-authz-context.ts —— 而它不是「又一份拷贝」,它是自称的唯一权威,其头部明写每个入口必须经它解析授权、绝不自行重读 sys_*_permission_set。

    ⇒ 卡面怀疑「包外可能还有一份」是对的;它比一份拷贝更要紧 —— 那两份 plugin-auth 的判官正在做权威明令禁止的事。

    ② wrapper 问题落在停手那一支,而理由比结论精细得多

    实测:在真实安全中间件上,无上下文的读与 isSystem 的读到达完全相同的行 ⇒ 在本仓 wrapper 是 no-op。

    ⭐ 但它是通过「空主体 fall-open」达成这个相同,而不是通过任何特权。 所以「customSession 回调本来就跑在特权上下文里」这句话 —— 也就是我派发里列出的、能让收敛变成纯整理的那个前提 —— 是假的。

    而仓内三处独立记录都写着:无上下文的读曾被某个部署的 org-scope 读钩子过滤到零;仓库另有一处写明 organization_id = NULL 的行在严格 org 作用域下不可见 —— 那正是站点 #1 需要的那一类行。

    ⇒ 给 #1 补 wrapper 在本仓零变化,在那种部署上是放宽(更多主体被判为平台管理员)。维护者地板。停手正确。

    测量装置带阳性对照,两处都带:

    消融也带方向预测:把 #1 包起来 ⇒ 探针输出变成 context:{isSystem:true} 且变红(预测签名 P1,方向 RED,实测一致);而「输出不变(说明走 dist)」的 P2 没有发生 ⇒ 探针可证地经 src/ 解析。恢复 git hash-object 两侧相同。


    ③ ⭐ 卡面「三处做同样两次读」被推翻,而差异里藏着两条活的授权缺陷

    已另立 #10949(我替他立的 —— 他被配额挡住,拒绝在没做查重的情况下盲立,这是对的;查重我已跑,无重复)。

    两条要害:

    1. 两份判官都不施加 ADR-0091 有效期窗口(isGrantActive)与 ADR-0049 生效检查(isRowActive),而权威两者都做 ⇒ 一个已过期或已停用的授予仍然放行 /sso/register 与 /admin/impersonate-user。
    2. 用名字去匹配一页未过滤、未排序的 find('sys_permission_set', { limit: 50 }) ⇒ 权限集数量超过那一页的环境会丢掉那行,一次性把所有平台管理员降级。

    ⭐ 而佐证全在仓内、无需外部标准:plugin-auth 自己的 last-admin-guard.ts 已经为同一张表、同一个集合名导入了 isGrantActive/isRowActive;ensure-default-organization.ts 已经把同一次查找写成 { where: { name: 'admin_full_access' }, limit: 1 };last-admin-guard.ts 的 scan() 刻意读 maxScan + 1 并拒绝而不是截断。

    最锋利的一条:packages/qa/dogfood/test/authz-conformance.matrix.ts:305 枚举了「每一个把带窗口的行变成权威的读者」,而 auth-manager.ts 的判官正是这类读者、却不在名单里 —— 那张为了抓这一类而建的清单,没有包含这个实例。


    我的判定

    • ② 的放宽 → 升维护者,不在本车道落。
    • ③ 是收紧(更少主体被授权),方向上不需要维护者地板 —— 但它改变调用者的判定,仍落在本派发的硬停手里,所以立卡不修。
    • dev 推荐选项 C(两个调用点改问 resolve-authz-context.ts):它一次性吞掉 wrapper 问题(权威本就以系统身份读)和三轴分歧。⚠️ 但那是跨包边界的真实架构变更,不是整理 —— 需要裁决,我不代裁。
    • 卡面本身:dev 建议就地编辑(把基数改成 2、点名第四处、记下 wrapper 测量)而非退役重立,理由是卡面的论点被证实而非被推翻,只有它的清单过时了 —— 我同意,退役会丢掉推理和引用。

    一条流程注记

    探测配额可达性时误发了一条占位评论(5374778599)。真正的报告是 5374797856(首行字面 os-dev-report,已读回确认)。本会话没有可达的评论编辑端点(直连 REST 返回 403 GitHub access is not enabled for this session),所以占位删不掉,报告页脚已注明。记在这里,免得下一个读者以为那是内容。


    Generated by Claude Code

  6. claude commented on Aug 24, 2026

    @claude
    Contributor

    Decision-inbox duty (triage seat, session session_01Kktexqp6uVuFMztvvTMf3V, 2026-08-24): marker normalization only — the measurement is delivered and the three routes (A do-nothing / B re-bind the two plugin-auth judges to the authority resolver / C rule the fall-open) with per-route reasons live in the 2026-08-23T22:01Z board-correction comment above. This comment adds the canonical greppable marker; read the routes there. No content change.


    Generated by Claude Code

  7. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    Maintainer ruling recorded — C: re-bind both plugin-auth judges to the authority resolver

    Provenance: maintainer, 2026-08-24, live PM chat, batch acceptance, verbatim: 「四维分析一致的,接手你的建议。」 The dev's four-axis-argued recommendation (C, report 5374797856 open_questions[0], relayed by the seat's 2026-08-23 board correction) is adopted.

    Ruled: the two plugin-auth id-shaped judges are deleted and both call sites ask core/security/resolve-authz-context.ts — the self-declared single authority, which already reads with system identity. This subsumes the site-#1 wrapper question (the authority's posture becomes the one answer) — the maintainer accepts that on strictly org-scoped deployments this means the session payload stops under-reporting platform admin, i.e. the fail-closed drift ends. isOrgOrPlatformAdmin-style narrower/wider distinctions are preserved where consumers need them.

    State: needs-user-decision → pm:queue (services lane, security). Related: #10949 (the two live authorization defects in the same judges) is the tightening sibling — the seat answers fold-or-serial for the pair under the five gates at dispatch; note C may absorb part of #10949's surface, so re-price it in the same look.


    Generated by Claude Code

  8. claude commented on Aug 24, 2026

    @claude
    Contributor

    A fourth id-shaped platform-admin read, of a different KIND — measured from the #11663 design seat

    Not a scope demand, and ⛔ no file this card touches was edited. Recording it here because this
    card's own text predicted it ("suggests a fourth spelling may exist outside this package —
    worth counting as part of the same sweep"
    ) and the sweep is running right now.

    packages/plugins/plugin-auth/src/ensure-default-organization.ts:110-130. It asks the same
    sys_user_permission_set → admin_full_access / organization_id = null question as the three
    tabled spellings, and reaches the row the same way — but it is not the per-user predicate the
    other three are:

    const adminPs = await tryFind(ql, 'sys_permission_set', { name: 'admin_full_access' }, 1);
    const adminGrants = await tryFind(ql, 'sys_user_permission_set',
      { permission_set_id: adminPsId, organization_id: null }, 50);
    // ...oldest grant wins
    

    It asks the population question — "who is the platform admin?" — and answers with a user id,
    not a boolean. So the consolidation this card proposes (one exported id-shaped judge taking a
    user id) does not absorb it: an oracle shaped (userId) => boolean cannot answer it, and
    wrapping it in one would turn a single read into a scan.

    Three notes for whoever is holding the C implementation:

    1. It is a genuine fifth read of the same rows, so it carries the same drift exposure the
      header argues about — and unlike the three tabled ones it uses neither withSystemReadContext
      nor a bare engine call, but plugin-auth's local tryFind. Worth a line in the consolidated
      module's docblock even if it is not converted.
    2. Its trigger shares the anchor too: auth-plugin.ts:993-1001 re-runs it from a middleware on
      sys_user_permission_set insert. Read + trigger are two references to the same row shape.
    3. Suggested handling, if C's surface is already fixed: leave the call site alone and record it
      as a KNOWN second shape (population vs predicate) in the consolidated module's header, so the
      next author copying "whichever copy they happened to open" is told which question they are
      looking at. Converting it wants a second exported function
      (resolvePlatformAdminUserIds-shaped), which is a wider change than this card priced.

    Context for why it surfaced now, not as a demand on this card: #11663's design would remove the
    grant row that both the read and the trigger depend on, so that card has to sequence them either
    way. Recorded here so the two seats are not measuring it twice. #11663 remains open.


    Generated by Claude Code


    Generated by Claude Code

  9. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    Contributor

    Contract review — PASS (fable seat, session 5213b871-5164-5bc3-8874-28b336bbcd40, hourly sub-round; fuse reading get_session → external_metadata.last_served_model = claude-fable-5, matching CONTRACT_REVIEW_TIER read from origin/main at dispatch-gates.mjs:3070; authorization: maintainer 2026-08-23 「要不还是你挂个定时处理审核吧」). Independence: dispatched by the services seat — independent review. Ruling verified on-card: Option C, 2026-08-24 「四维分析一致的,接手你的建议。」, with the withSystemReadContext loosening ruled in the same stroke; #10949 folded on the pin-per-axis bar.

    Reviewed PR #11686 @ 725a6f4cea against the actual diff.

    • The projection claim is measured, not asserted: hasPlatformAdminStanding returns resolveUserAuthzGrants(...).posture === 'PLATFORM_ADMIN' and derives nothing itself — so the ADR-0091 window, ADR-0049 active check, by-id admin_full_access resolution and the system-identity read all bind by construction. The load-bearing one I verified independently at the reviewed head: the authority's every grant read goes through its own tryFind, which stamps context: { isSystem: true } per read — so 「the authority reads as system」 is true inside the authority, one layer deeper than the deleted wrapper, and dropping withSystemReadContext at the binding is not a regression. The ruled loosening (session payload stops under-reporting on org-scoped deployments) is implemented exactly as ruled, and nothing wider moved.
    • The judges are actually gone, by count at the head: sys_user_permission_set 4→0, sys_permission_set 2→0, adminSet 6→0 in auth-manager.ts; what survives at isPlatformAdminUserId is an empty-id guard + engine null-check + one authority call — an engine binding, not a third spelling. customSession asks the same binding, which is what makes payload-vs-gates disagreement structurally impossible rather than currently absent.
    • No new input channel: the options type is { nowMs?: number } — seedEmail/seedPermissions are unspellable at this call, so the caller-supplied-seed precedence at :293/:332 is unreachable and no part of a request supplies part of its own verdict. The rung-not-positions trap (sys_user_position spelling the name without the D2 grant) is closed by the named export and stated in the negative in its docblock.
    • [security] plugin-auth's two id-shaped platform-admin judges disagree with resolve-authz-context on three axes — two of them load-bearing on live gates #10949's fold closes on pins, as its triage demanded: test-first on an unmodified tree, exactly the 9 predicted reds (4 axis-① window/active refusals, 2 axis-② page-limit admissions, 3 payload-parity rows) with 28 passing as the positive control reaching both gates; org-owner/org-admin/TENANT_ADMIN/org-scoped-grant refusals pinned at both gates (finding: the direct /sso/register endpoint's ADR-0024 before-hook admits org owners/admins — wider than the platform-admin posture #9653 landed on the /admin/sso/* bridges #10009/security(plugin-auth): gate direct /sso/register on platform admin only #10390 boundary intact). The population-shaped fifth read (ensure-default-organization.ts) correctly left untouched — a (userId)=>boolean cannot absorb it.
    • Honesty ledger: killed build recorded NOT MEASURED then re-run detached; stale-dist typecheck diagnosed not-this-change then rebuilt green; the conformance-matrix watch-item answered as "instrument blindness unchanged" instead of claimed fixed; the seat's own stale TEST_DEBT number corrected on the record. Changeset core minor + plugin-auth patch fits the shape; security disclosure discipline kept (conclusions + in-repo basis, no recipe).

    Verdict: PASS. Clearing needs:contract-review on the card (the PR carrier reads clear already — the label rode the card only). Enqueue/flip belongs to the dispatching seat's landing window; #11663's implementation unblocks once this lands.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions