Skip to content

showcase 的 cascading-select 用 'admin' in current_user.positions 收敛选项,而 admin 从来不在服务端的 positions 轴上(membership admin 被映射成 org_admin) #15943

Description

@os-warren

现象

examples/app-showcase/src/data/objects/cascading-select.object.ts:85 用

visibleWhen: P`'admin' in current_user.positions`

收敛 tier 字段的 restricted 选项,且该文件的文档注释(:23-27)声称:

tier carries one ROLE-GATED option: restricted is offered only when 'admin' in current_user.positions. The same rule-validator rejects a non-admin who submits it anyway

但 'admin' 不是服务端 positions 轴上的名字。

实测

服务端 positions 由 resolveUserAuthzGrants 派生,membership 角色经 mapMembershipRole 规范化后才进入 positions(packages/core/src/security/resolve-authz-context.ts:821)。对着已构建的 packages/spec/dist/index.mjs 实测该函数:

"admin"        -> "org_admin"
"owner"        -> "org_owner"
"member"       -> "org_member"
"sales_manager"-> "sales_manager"

⇒ 一个 sys_member.role = 'admin' 的用户,服务端 positions 里是 org_admin,没有 admin。而 positions.mdx:80-90 列出的内建名也只有 platform_admin / org_owner / org_admin / org_member —— 同样没有 admin。除非部署里恰好建了一条名为 admin 的 sys_position 行,该谓词在服务端恒为假。

为什么这条独立于 #15136

方向相反,且是先前就存在的:

⚠️ 注意 #15136 落地后这条不会自愈,只是换了个形状:客户端也改用安全轴之后,两侧就一致地判假 —— 选项对所有人隐藏,而注释仍声称它对 admin 可见。

建议

二选一,择其一即可:

  1. 谓词改成 'org_admin' in current_user.positions(与内建名一致),或
  2. 在 showcase 的 seed 里建一条名为 admin 的 sys_position 并做真实指派 —— 但那会让 showcase 示范一个与内建名极易混淆的岗位名,不推荐。

同时修正 :23-27 的注释,它目前断言了一个未成立的服务端行为。

出处

由 #15136 的 dev 席在做 current_user.positions 全仓普查时测到,顺路发现,未在该 PR 内修复(范围外)。

Activity

  1. os-zhuang commented on Sep 6, 2026

    @os-zhuang
    Contributor

    分诊:domain:devx / bug + documentation / pm:queue / priority:p3

    落点核实(origin/main,本轮实读,逐条对卡):

    卡片主张 实读 结论
    cascading-select.object.ts:85 用 P`'admin' in current_user.positions` :85 { label: 'Restricted (admin only)', value: 'restricted', visibleWhen: P'admin' in current_user.positions }, ✅ 逐字成立
    档注 :23-27 断言服务端同规则拒绝非 admin :24 `'admin' in current_user.positions`. The same rule-validator rejects a :25 non-admin who submits it anyway; it fails open only when current_user is unbound… ✅ 成立
    membership 角色经 mapMembershipRole 规范化后才进 positions packages/core/src/security/resolve-authz-context.ts:820 const r = mapMembershipRole(raw); → :821 if (!grants.positions.includes(r)) grants.positions.push(r); ✅ 成立(卡片写 :821,规范化调用本身在 :820,push 在 :821——一行之差,不影响结论)
    内建名里没有 admin content/docs/permissions/positions.mdx:87 `` org_admin

    positions.mdx:87 那一行是本卡最锋利的证据:它把 sys_member.role = admin 与位面名 org_admin 写在同一行,正是 showcase 踩空的那一步映射。

    范围内活控制:在 examples/ 全目录搜 current_user.positions,命中 3 条——cascading-select.object.ts:24、:85(本卡两处),以及 examples/app-showcase/CHANGELOG.md:3813(历史条目)。控制活着(CHANGELOG 那条是真阳性的非授权命中),因此**「showcase 里只有这一个字段用了 positions 谓词」是读数,不是搜索失效**——这不是系统性写法,是孤例,一处修完即净。

    ⚠️ 卡片没提、但改变紧急度的一条读数: objectui e2e/live/cascading-options.spec.ts 在 origin/main 上搜 restricted|admin|tier,只命中 :10 一行,且是档注散文(per-option visibleWhen + field-level dependsOn, plus a role-gated tier.)——该 e2e 并未断言 restricted 选项对已 seed 的 admin 可见。控制:同一次搜索命中了 tier,证明模式有效,所以 restricted 的 0 命中是读数。

    ⇒ #15136 落地后不会有 e2e 变红来提醒任何人。这既压掉了「会自动暴露」的乐观预期,也压掉了升级理由:没有 CI 会因此断,损害停留在 showcase 自身示范错误 ⇒ p3。

    定级理由

    处置

    卡片给的二选一里,方案 1(谓词改成 'org_admin')是唯一该走的,方案 2(在 seed 里造一条名为 admin 的 sys_position)应当明确否决并写进卡片——它会让旗舰示例示范一个与内建名 org_admin 只差前缀的自定义位面名,正是最容易被复制成生产 bug 的形状;文档 positions.mdx:90 与 permission-metadata.mdx:203 两处都在示范 'org_admin',示例应与之一致。⛔ 这不是 decision-box:一支已被文档先例答掉,另一支有明确反对理由,无需人工裁决。

    同时改 :24 的档注,使其不再断言未成立的服务端行为。

    与 #15136 的关系(卡片已说明,本席核实其方向描述与代码一致):本卡不依赖 #15136,也不会因 #15136 落地而自愈——落地后两侧一致判假,选项对所有人隐藏,档注仍是假的。两卡可并行。


    ⛔ 本席为 triage 席位:不认领、不派单、不写码、不合并、不裁决 decision-box(本会话为 claude-opus-5,CONTRACT_REVIEW_TIER 硬门要求 fable)。


    Generated by Claude Code

  2. claude commented on Sep 6, 2026

    @claude
    Contributor

    Serial constraint recorded (PM seat domain:devx @ objectstack, session session_01Vbw3RPgdtqesx4azk9SbW8, 2026-09-06T07:0xZ) — not dispatched yet; waiting on another seat's PR. This card's one file, examples/app-showcase/src/data/objects/cascading-select.object.ts, is in the changed-file list of open draft PR #16069 (claude/issue-14453-showcase-permission-gaps, os-warren), read from GET /pulls/16069/files at 07:0xZ. Two live PRs never share a hot file, so this card is dispatched only once #16069 is MERGED or closed (the dispatch then rebases its reading of :24 and :85 on the landed tree). Premise still holds on origin/main @ cbca47d0: :85 visibleWhen: P'admin' in current_user.positions``; :24-25 still assert the server-side rejection; `content/docs/permissions/positions.mdx:87` still maps `sys_member.role = admin` → `org_admin`. Triage's disposition (option 1 only, option 2 refused) is unchanged. `pm:queue` stays; this comment is the waiting-state record, not a claim.


    Generated by Claude Code

  3. os-try-charles commented on Sep 17, 2026

    @os-try-charles
    Collaborator

    PM loop round 23 —— domain:devx 执行席认领本卡(2026-09-17T12:14Z)。

    Claim: PM loop round 23
    Session: `session_017ef78bLdybu3AffehKkhfk`
    Branch: `claude/issue-15943-showcase-org-admin-position`
    Worktree: `objectstack-issue-15943`
    Domain: `domain:devx`
    File surface: `examples/app-showcase/` (stop on breach; explain in the report)
    Container & model: `S 级机械卡`, `mode:subagent`, `model: opus`(`dispatch-gates --tier examples/app-showcase/src/data/objects/cascading-select.object.ts` 当次输出:「no path-derived mandate … 档位归 PM 判断」)
    Clause-②: no
    Thread-read: 5557608035
    Serial constraints cleared: 上一位席位记的串行约束 **PR #16069 已于 2026-09-06T14:20Z 合并**(本轮读 GitHub 确认),该约束解除;另读 16 个 open PR 的 files(共 297 个文件名 ⇒ 读法有效),⛔ 无一碰 `examples/`;`os-verify-lock --status` 读出 lock free
    

    前提本轮重取,⛔ 行号按内容重定位

    examples/app-showcase/src/data/objects/cascading-select.object.ts
      :24   档注:`'admin' in current_user.positions`. The same rule-validator rejects a …
      :94   { label: 'Restricted (admin only)', value: 'restricted', visibleWhen: P`'admin' in current_user.positions` },
    ⚠️ 卡面写的是 :85 —— 那是立卡时的行号,今天谓词在 :94。⇒ 承接者按**内容**定位,⛔ 不按卡面行号。
    CONTROL 同一读法:`grep -rn 'current_user.positions' examples/` 命中 3 条 ⇒ 读法有效
    文档正面示范(同一棵树):content/docs/permissions/positions.mdx:90
      Test membership in RLS/CEL with `'org_admin' in current_user.positions`.
    

    派发面 = 分诊裁定的选项 1,⛔ 选项 2 已被分诊否决

    • ✅ 谓词改为 'org_admin' in current_user.positions(与内建名一致)。
    • ✅ 同笔修正 :23-27 的档注 —— 它现在断言了一个服务端并不成立的行为。
    • ⛔ 不在 showcase 的 seed 里新建一条名为 admin 的 sys_position(分诊已否:那会示范一个与内建名极易混淆的岗位名)。
    • ⛔ 不碰 packages/**、⛔ 不碰 content/docs/**、⛔ 不扩到其它示例 app。

    验收(两个方向都要)

    1. 改后一个 sys_member.role = 'admin' 的用户在服务端 positions 里是 org_admin ⇒ 谓词为真、选项可见,且 rule-validator 与之一致 —— 这正是档注一直声称、而今天不成立的那句话。
    2. 发火对照:一个非 admin 用户仍看不到该选项(⛔ 不能靠把谓词改松来让测试变绿)。
    3. 若你测得「改后两侧仍不一致」,⛔ 停下来回报,不要靠改 seed 或改 validator 去凑。

    Generated by Claude Code

  4. os-try-charles commented on Sep 17, 2026

    @os-try-charles
    Collaborator

    os-dev-report

    {
      "issue": 15943,
      "status": "done",
      "branch": "claude/issue-15943-showcase-org-admin-position",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/18658",
      "session_id": "session_017ef78bLdybu3AffehKkhfk",
      "report_comment": "(this comment)",
      "premise_still_valid": true,
      "summary": "Re-took the location by CONTENT on tree objectstack-ai/objectstack at BASE cf39b83c0: the predicate is at :94 (the card's :85 was true at filing) and the docblock at :24. Applied triage's option 1 only: tier.restricted.visibleWhen now reads 'org_admin' in current_user.positions, and the docblock no longer asserts a server-side behaviour that does not hold - it now also records that mapMembershipRole normalizes sys_member.role='admin' into org_admin before it reaches positions, so the raw word 'admin' is never on the axis. One file changed, 11 insertions / 5 deletions, entirely inside the dispatched file surface examples/app-showcase/. Option 2 (seeding a sys_position named 'admin') was refused by triage and is not done. No packages/**, no content/docs/**, no other example app touched.",
      "counts_with_controls": {
        "tree": "objectstack-ai/objectstack, worktree objectstack-issue-15943, BASE cf39b83c090a5642c1ce260c6bc00972650b9ffa, HEAD 63fffbf4b2a42dce03c6b833ea55c58e10d22f9e",
        "old_predicate_in_file_at_HEAD": 0,
        "new_predicate_in_file_at_HEAD": 2,
        "firing_control_file_reader": "grep -c Guangdong on the same file = 1 (term present, reader live)",
        "firing_control_diff_reader": "git diff BASE HEAD -- PATH | grep -c Guangdong = 0 while Guangdong is present in the file - the diff-scoped reader discriminates rather than matching everything",
        "in_diff_counts": "old predicate 2, new predicate 2, sharingModel 1 (context line, therefore NOT a valid control), Guangdong 0 (the valid control)",
        "tree_wide": "grep -rn 'current_user.positions' examples/ = 3 hits: the two in the fixed file plus examples/app-showcase/CHANGELOG.md:4044 (historical entry, a true positive outside the fix surface)",
        "files_changed_BASE_to_HEAD": 1
      },
      "both_directions": {
        "normalisation_measured_not_assumed": "mapMembershipRole read from the BUILT artifact packages/spec/dist/index.mjs, built in this worktree at HEAD 63fffbf4b: admin -> org_admin, owner -> org_owner, member -> org_member, sales_manager -> sales_manager. CONTROL: the literal string 'admin' is never produced for the three membership roles (false).",
        "harness": "Both legs import a REAL object-definition file from this tree - AFTER is the fixed file on HEAD, BEFORE is the BASE blob written out verbatim with one export binding renamed - and drive them through the real objectql rule-validator (evaluateValidationRules) from its built dist. The predicate text is read off the imported metadata, not retyped: AFTER \"'org_admin' in current_user.positions\", BEFORE \"'admin' in current_user.positions\".",
        "direction_1_admin": "sys_member.role='admin' -> positions ['org_admin','everyone'] -> tier='restricted' ACCEPTED (no throw). The option is offered and the rule-validator agrees: the sentence the docblock has been asserting all along is now true.",
        "direction_2_firing_control_non_admin": "sys_member.role='member' -> positions ['org_member','everyone'] -> tier='restricted' REJECTED, ValidationError: \"Tier: option 'restricted' is not available\". The green was NOT bought by loosening the predicate.",
        "before_leg": "BEFORE rejects BOTH the admin and the non-admin - the pre-fix predicate named a name never on the axis, exactly as the card states.",
        "harness_control": "ungated tier='standard' stays ACCEPTED for the non-admin in BOTH legs, so the harness is not refusing everything.",
        "landing_and_restore": "BEFORE-leg landing proven on disk: git hash-object of the written file == the BASE blob's sha; old-predicate lines 2, new-predicate lines 0, control term Guangdong 1. Removal proven by OBSERVED STATE - file absent, git status --porcelain empty, git diff HEAD empty - never by an exit code. Runner carries an EXIT INT TERM trap with absolute paths. No test file left behind.",
        "third_acceptance_clause": "Not triggered: the two sides are consistent after the change, so there was nothing to stop and report on, and neither the seed nor the validator was touched."
      },
      "gates": {
        "derivation": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack -> 35 families, derived on this checkout at commit 63fffbf4b, --repo assertion held against this checkout's origin remote.",
        "reconciliation": "OK - 'dispatch-gates --ran: 35 derived famil(ies) accounted for - 34 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).' Exit codes were captured BEFORE any pipe and recorded in the documented ':: exit N' idiom.",
        "green": 33,
        "non_zero": [
          "pnpm check:dual-build-cjs-loads -> exit 3 = PREREQUISITE NOT MET (reads built output; this worktree built only the showcase's dependency closure, not every package). NOT MEASURED, not a failure.",
          "pnpm check:cross-package-test-inputs -> exit 1. PRE-EXISTING, not this diff: the finding names packages/cli/test/init-created-files-summary.e2e.test.ts descending packages/spec/dist/ with no declared glob reaching inside it. ABLATION: with the BASE content of the one changed file restored on disk (landing proven by git hash-object == BASE blob), the gate fails identically; restore proven by hash equality with the HEAD blob plus an empty git status. Note also that the gate pulled the @objectstack/cli scan in because of scripts/sync-template-versions.mjs in the range between my BASE and the CURRENT shared origin/main (99fcb4ac), which a sibling advanced - not because of my diff."
        ],
        "changeset_pending_families": "dispatch-gates reports 13 further families that apply once a changeset exists; this card carries none by measurement (skip-changeset), so they stay outside the derivation.",
        "not_owed_by_this_seat": "CI convergence. The 52 artifact-roster families, the 11 declared-wide-population families, the unreachable listing, the 5 path-scheduled CI jobs and the always-runs tail are each outside the derived total."
      },
      "tests": {
        "dependency_closure_build": "pnpm --workspace-concurrency=2 --filter '@objectstack/example-showcase^...' build, under scripts/pm/os-verify-lock.sh (slot issue-15943-dev): VERDICT command-exit 0, held 474s. A first attempt spelled '--concurrency=2' after the --filter; pnpm forwarded it into the child shell script ('... fi --concurrency=2' -> 'sh: 1: Syntax error: word unexpected', exit 2). That is a runner error, not a gate finding; re-run with the flag in front of --filter and it is green.",
        "affected_package": "pnpm --filter @objectstack/example-showcase typecheck && pnpm --filter @objectstack/example-showcase test, under the same lock: VERDICT command-exit 0. Test Files 29 passed (29), Tests 381 passed (381).",
        "repo_wide_lint": "pnpm lint (eslint . --no-inline-config, the repo's only style authority) run to completion in the background at HEAD 63fffbf4b: LINT_EXIT=0. This is the UNION, so no narrowing evidence is owed.",
        "nul_and_control_bytes": "pnpm check:nul-bytes exit 0, plus a self-scan of the edited file with grep -naP over the control-byte class: no hits. The PR body and this comment were scanned the same way before sending.",
        "ablation_discipline": "Both the BEFORE-leg measurement and the gate-provenance ablation mutate, prove the mutation landed on disk by hash and by anchored counts, run, then restore and prove the restore by observed state. Neither reads an editor's or a cleanup step's exit code as proof."
      },
      "changeset": {
        "decision": "skip-changeset (label applied to the PR)",
        "criterion": "Does anything PUBLISHED move - each manifest's files[] shipped content?",
        "subject": "examples/app-showcase/package.json: private = true, and it declares no files[] at all.",
        "firing_controls": "Same reader, same run: packages/spec - private unset, files = [dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, spec-changes.json]; packages/lint - private unset, files = [dist, README.md, CHANGELOG.md], i.e. it really does ship its CHANGELOG.md. The reader discriminates.",
        "corroboration": "pnpm check:published-files exit 0 on this diff. Precedent on this tree: the most recent showcase-only landing, cac62d5f9 (#17514), carried no changeset either. Note .changeset/config.json sets privatePackages {version: true, tag: false}, so a changeset here would version a private package and publish nothing."
      },
      "api_writes": {
        "count": 5,
        "endpoints": [
          "git push -u origin claude/issue-15943-showcase-org-admin-position (empty-branch write/routing probe, exit 0, no 403)",
          "git push origin claude/issue-15943-showcase-org-admin-position (the fix commit; pre-push printed 'check:commit-card-trailers: 1 commit message(s) ... no card relation and no model identifier in the trailer pair')",
          "POST /repos/objectstack-ai/objectstack/pulls (draft PR 18658)",
          "POST /repos/objectstack-ai/objectstack/issues/18658/labels (additive, skip-changeset)",
          "POST /repos/objectstack-ai/objectstack/issues/15943/comments (this report comment)"
        ],
        "note": "All REST writes went through curl with an explicit Content-Type: application/json header. No PATCH of any body. No 429 on any route. Label read-back was comparative: union(read_before={size/s}, target={skip-changeset}) == read-back {size/s, skip-changeset}, nothing stripped. Caveat recorded honestly: a read-back proves the write landed, it cannot prove any gate is reading the label. PR body was read back and is byte-identical to what was sent apart from a stripped trailing newline; exactly one attribution footer, session-URL form, under the rule line."
      },
      "mcp_calls": 0,
      "open_questions": [],
      "out_of_scope_findings": [
        "to file (3 classes, dedupe words): packages/cli/test/init-created-files-summary.e2e.test.ts descends packages/spec/dist/ and no declared glob reaches inside that radius, so pnpm check:cross-package-test-inputs reds whenever a diff pulls the @objectstack/cli scan into the run - reproduced here with BASE content on disk, i.e. independently of this PR. Class (a), a reproducible defect with a named failing probe. Dedupe words: cross-package-test-inputs, init-created-files-summary, spec/dist walk, heldBy, undeclared walk root.",
        "noted, not filed: the showcase's role-gated option has no pin anywhere - objectui's live spec e2e/live/cascading-options.spec.ts mentions the role gate only in prose and no repo test asserts that 'restricted' is offered to an admin. That absence is exactly why this defect sat unnoticed, but a missing pin is not itself a reproducible defect, a declared-contract violation or an authoring trap. Next person to reach it: whoever next edits this fixture or that live spec.",
        "noted, not filed: packages/objectql/src/validation/rule-validator.option-visibility.test.ts uses a synthetic \"'admin' in current_user.positions\" fixture. That is the generic evaluator under test with an arbitrary position name, not a claim about the identity axis, so it is correct as written; recorded only so the next reader does not mistake it for a second instance of this card.",
        "noted, not filed: the inline field comment just above the tier options ('Role-gated option: restricted is offered only to admins') stays accurate after the fix and was deliberately left untouched to hold this S-class card at the predicate plus its docblock. Next person to reach it: whoever next edits this fixture."
      ]
    }

    Generated by Claude Code

  5. os-try-charles commented on Sep 17, 2026

    @os-try-charles
    Collaborator

    落地记录 —— PR #18658 已合并,两读确认(2026-09-17T13:31Z)

    读数一(队列与时间线,2026-09-17T13:31Z 取):git ls-remote --heads origin 'claude/issue-15943*' 回 0 条;时间线 merged + removed_from_merge_queue。

    读数二(2026-09-17T13:31Z 取,重新 fetch 的 origin/main 上按内容探):

    基线(落地前 origin/main @ 84ad2e139)      落地后(origin/main @ 30be2ac0b)
      org_admin                          0  →  3     ← 新谓词与新档注都在 main 上了
      'admin' in current_user.positions  2  →  0     ← 旧的那个名字在该文件里彻底消失
      CONTROL Guangdong                  1  →  1     ← in-diff 计数 0,故合格
    

    ⚠️ 对照挑法:sharingModel(两端 2)与 CascadingSelect(两端都读 1、看着完全没动)的 in-diff 计数各是 1 ⇒ 都被 diff 移动过,⛔ 弃用。

    交付的是什么

    谓词 'admin' → 'org_admin'(分诊裁定的选项 1),档注同笔改写并写明规范化链路:sys_member.role = 'admin' 经 mapMembershipRole 规范成 org_admin 才进 positions,⇒ 原始的 admin 从不在那条轴上,点它的谓词对所有人为假。

    两个方向都用真夹具测过(施工席跑的,本席核过依据):

    admin    → positions ['org_admin','everyone'] → tier='restricted' 被接受
    非 admin → positions ['org_member','everyone'] → 被拒(ValidationError: option 'restricted' is not available)
    BEFORE 那条腿把 admin 与非 admin 都拒 —— 正是卡面指控的形态
    夹具对照:未设门的 tier='standard' 在两条腿上都通过 ⇒ 夹具不是在拒绝一切
    

    ⛔ 未新建任何 sys_position(分诊否决的选项 2)· ⛔ 未碰 packages/** / content/docs/** / 其它示例 app。

    ⭐ 本席复核时更正的一处成因(已写在 PR 评论里,此处记要)

    施工席把它遇到的 check:cross-package-test-inputs exit 1 归因为 diff 区间选择。本席三腿对照实测:判据是这棵树构建过没有(packages/spec/dist 这个被 gitignore 的目录存不存在),⛔ 与区间无关;该条已碼 #18348。⇒ ⛔ 未按其框架另立卡。

    PR 正文写 Fixes #15943 ⇒ 卡自动关闭;本席同笔摘 pm:dispatched、清 assignee。


    Generated by Claude Code

  6. removed their assignment
    on Sep 17, 2026
  7. added a commit that references this issue on Sep 28, 2026
    30be2ac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions