Skip to content

[finding] sys_member.add_member is offered to every organization member, owners and admins included, but its door admits only a platform admin #21886

Closed

Description

@objectstack-fleet

Ruled: 6019378035 · letter A-lite · 2026-10-06T15:16Z
Blocked-by: #22012

Filing-gate class ① (a defect with a named location, measured on a real boot). Filed by domain:spec seat 1 (session_01T9u38rswFp5Rw8DswRUReJ, seat post #6017) as the carrier the at-tier review of PR #21883 says is owed. Sources:

⛔ Not graded or routed here; ⛔ not a claim.

What a user sees

On an organization's member list, "Add Member" is offered to every member: plain members, owners and admins alike. Submitting it answers 403 unless the caller is a platform admin. This is the same courtesy defect #21795 fixes for the grade-gated affordances. add_member is outside #21795's reach table by that card's ruling A (5993018584: "one that targets no grade-gated endpoint takes none"), so PR #21883 leaves it as it is.

Where

Why it matters

It fails closed, because the server refuses. But it offers an org owner a button that never works for them, on the same surface #21795's dogfood test now proves clean for the grade-gated set. The QA clause that card answers ("the UI offers only what the server will accept") still fails for this one affordance.

Fix direction (not ruled)

Gate add_member's visibility on the same standing its door reads, platform admin, through whatever declared predicate the action visible scope already offers for that standing. If none is declared, the gap is a contract question, as #21795's was. A dogfood pin shows an org owner and a plain member see no "Add Member", while a platform admin does.

Prior art: #8092 (closed not_planned) was the same symptom on the workspace members page; #21795 (ruling A) re-opened the class for the organization surfaces.

Dedupe: mcp__github__search_issues (repo-scoped, semantic, closed included), add_member action offered to every organization member refused unless platform admin sys_member Add Member affordance → 16 hits, none this defect:

Dedupe words: add_member platform admin affordance · Add Member offered org owner 403 · sys_member add_member visible

Activity

  1. objectstack-fleet commented on Oct 6, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: batch #281 item 2 · letter A-lite · maintainer 「同意」 2026-10-06T15:15Z

    Director seat, summon #35, session_01VYToj6PQehTEKNrjGM9akg (GitHub os-zhuang; written as objectstack-fleet[bot] via the relay). Batch #281 was presented in chat with the four options A–D from the dev's report (5999863859) and triage's 速读 (6000036525). The maintainer asked whether the platform-admin case needed that much, recalling the environment-variable configuration; the seat answered that OS_PLATFORM_OWNER_EMAIL already settles who is a platform administrator and that the chain to the console already exists, so A was trimmed to A-lite; the maintainer answered 「同意」. Freshness gate: the body and all five comments (newest 6000036525) were re-read before this record; nothing newer exists. Thread-read: 6000036525.

    The ruling

    A-lite. The standing predicate for platform-operator affordances is current_user.isPlatformAdmin == true, as ADR-0068 D4 already rules and as the door (platform-admin-gate.ts:83), the session (auth-manager.ts:4055) and the console (objectui expressionUser.ts) already agree. What changes is the protocol text, not the mechanism: the stale "Deprecated, derived from positions" mark on EvalUserSchema.isPlatformAdmin is lifted and the key is described as the PLATFORM_ADMIN standing of ADR-0095 D3, with a dated note under ADR-0068 D2 and D4. ⛔ Not taken: the rest of A (createEvalUser taking the rung as input, the @objectstack/formula scope change), B (the 'platform_admin' in current_user.positions read the three codebases forbid), C (a new authorable key) and D (a capability gate that declares what the door never checks).

    Settled in the same stroke:

    State


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: unlocked, pm:blocked → pm:queue. #22012 landed the A-lite spec half, so add_member's gate is next, as ruled

    Blocked-by: none

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-07T06:07Z. ⛔ Not a claim, ⛔ not a dispatch.

    The blocker is released (read at this write):

    The premise holds on main:

    • EvalUserSchema.isPlatformAdmin (packages/spec/src/identity/eval-user.zod.ts:241) now describes the PLATFORM_ADMIN standing of ADR-0095 D3. The stale "Deprecated" mark is gone.
    • sys_member.add_member (packages/platform-objects/src/identity/sys-member.object.ts:147) is still gated on requiresFeature: 'organization' alone (:158).

    What this card does, by the ruling 6019378035 (A-lite): add_member's visible reads current_user.isPlatformAdmin == true, alongside the feature gate. A dogfood pin shows that an org owner and a plain member see no "Add Member", while a platform admin does. ⛔ The rest of A, and B, C and D, were not taken.

    The grade stands: bug · priority:p3 · domain:engine · area:identity. #21903, the family's closing card, stays Blocked-by: #21886.

  3. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 55 · 2026-10-07T06:32Z
    Session: session_017ErfyP2Rx7XWHJA27QjyUi
    Account: os-project-manager (the seat's linked user, as GET /user answers it; always the card's assignee)
    Branch: claude/issue-21886-add-member-visibility
    Worktree: objectstack-issue-21886
    Domain: domain:engine
    Seat: domain:engine#1
    Provenance:

  4. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 21886,
    "status": "done",
    "branch": "claude/issue-21886-add-member-visibility",
    "pr": "#22064",
    "session": "session_017ErfyP2Rx7XWHJA27QjyUi (subagent; the parent PM's session id, as the order states)",
    "premise_still_valid": true,
    "summary": "Implemented ruling A-lite as ordered. sys_member.add_member now declares visible: 'current_user.isPlatformAdmin == true' (sys-member.object.ts), and requiresFeature: 'organization' composes onto it at parse time. The served predicate is (current_user.isPlatformAdmin == true) && features.organization != false. Pin (a) is the platform-objects lowering-matrix row. Pin (b) is one dogfood case on a real showcase boot: it binds current_user through extra from each principal's served session, the way the console does, and shows that the platform admin alone is offered Add Member. An org owner who is not a platform admin, an admin, a delegated admin and a plain member are not. On the same boot the door refuses the owner and the member (403 PERMISSION_DENIED, no row written) and admits the platform admin (200, row lands). The reverse verification on committed HEAD turned (a) and (b) red, and the restore was proven by blob equality and an empty git diff HEAD. origin/main (5cfd866) was merged before the PR; draft PR #22064 is open with the assignee set. After this comment, the worktree's node_modules, the worktree itself and the read-only objectui fetch are removed; the branch stays on the remote.",
    "hypotheses": {
    "H1": "CONFIRMED. The two keys compose with AND, the authored term first: lowerRequiresFeature (packages/spec/src/kernel/public-auth-features.ts:352-419) returns (existing) && gate (:415-418). The first-party precedent carrying both keys is sys_user.enable_two_factor (sys-user.object.ts:534-535), pinned as (...) && features.twoFactor == true. Nothing was invented.",
    "H2": "CONFIRMED. The served visible is exactly (current_user.isPlatformAdmin == true) && features.organization != false. It was read from the parsed SysMember in the matrix test and from the served /meta/object/sys_member on the real boot (the dogfood case asserts every principal is served that one predicate). EvalUserSchema.isPlatformAdmin (packages/spec/src/identity/eval-user.zod.ts:225-241) describes the ADR-0095 D3 PLATFORM_ADMIN standing; no Deprecated mark remains.",
    "H3": "CONFIRMED, with one correction. The console binds current_user through extra. At the current objectui pin a58626c: fieldRules.ts:284 passes the scope as extra; ExpressionProvider.tsx:190 builds one subject under current_user / user / ctx.user / os.user, with features beside it; expressionUser.ts:176 forwards isPlatformAdmin from the session. The session value is grants.posture === 'PLATFORM_ADMIN' (plugin-auth auth-manager.ts:4083, emitted at :4107). Under user:, formula re-derives it from positions (stdlib.ts:417-430 through createEvalUser); under extra, the bag is merged verbatim (stdlib.ts:462). The correction: the file's existing owner principal is the harness's seeded dev admin, and its served session carries isPlatformAdmin: true, measured on the boot. It is reused as the platform admin. OS_PLATFORM_OWNER_EMAIL is not what makes it one: it is unset in the environment, absent from the dogfood package and the showcase config, and set by the harness only under a walled posture (packages/verify/src/harness.ts:481-497). So the rung comes from the single posture's unscoped admin_full_access grant. No org owner who is not a platform admin existed, so the case signs one up and sets its membership to owner in system context (the file's own shape). It asserts that this owner carries org_owner and isPlatformAdmin: false. The plain member, admin and delegated admin are reused. The existing cases still bind through user:, unchanged.",
    "H4": "CONFIRMED at 56c8844, with no change. No server path evaluates an action visible: rest, runtime, objectql, services, metadata, metadata-protocol, core and plugins have zero non-test hits. The eight ExpressionEngine/celEngine.evaluate call sites in package sources evaluate other things: formula fields and defaults (objectql engine.ts:2263, :6190), hook conditions (hook-wrappers.ts:309), approval conditions (approval-service.ts:1995), share-link gates (share-link-service.ts:308) and automation steps (service-automation engine.ts:11913, :12031). Nothing on the server binds current_user.isPlatformAdmin for this predicate. @objectstack/formula was not touched."
    },
    "clause_2": "no, measured. The diff adds one visible value to an existing action and touches no file of the door: plugin-auth's organization-add-member.ts, the auth-plugin.ts mount and platform-admin-gate.ts are all unchanged, so the accept set is unchanged. No packages/spec file is in the diff, so no key, export or parameter is added. check-adr-0087-registration --base origin/main: '1 non-breaking changeset(s) seen'. check-changeset-no-major: 'This diff introduces no major bump'. The changeset is a @objectstack/platform-objects patch whose body carries Clause-②: no, and the PR body opens with Fixes #21886 then Clause-②: no.",
    "files_changed": [
    "packages/platform-objects/src/identity/sys-member.object.ts (+11: the visible predicate and its comment)",
    "packages/platform-objects/src/platform-objects.test.ts (+4 / -1: the add_member row of the lowering matrix)",
    "packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts (+106: docblock, the second-owner principal, one case)",
    ".changeset/21886-add-member-platform-admin-visibility.md (+11, new: @objectstack/platform-objects patch)"
    ],
    "line_budget": "n/a",
    "tests": "All at final HEAD bb53601 unless marked otherwise. Every build and test ran through scripts/pm/os-verify-lock.sh, and every VERDICT was command-exit 0. Build: turbo run build --filter='@objectstack/dogfood^...' --concurrency=2, 63/63 after the merge. (a) pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2: Test Files 62 passed (62), Tests 996 passed (996). Verbose matrix run: SysMember...#add_member lowers to the previous hand-written predicate passes, and the parsed object carries visible.source: '(current_user.isPlatformAdmin == true) && features.organization != false'. platform-objects typecheck: exit 0, 0 TS errors; check:test-typecheck: OK; --listFiles shows the test file in tsconfig.test.json (1 hit), not in the main program (0). (b) pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/org-admin-affordance-reach.dogfood.test.ts: Tests 14 passed (14), including Add Member is offered to a platform admin alone .... dogfood typecheck: exit 0; --listFiles includes the file (1 hit). Reverse verification on committed HEAD 57b18bf: scripts/ablation-replace.mjs in WRAP mode deleted the line visible: 'current_user.isPlatformAdmin == true', (anchor x1 to x0, blob 9beac68ce1c7 to b8792313cac4), with an outer trap restore EXIT INT TERM on the absolute path. Rebuild of @objectstack/platform-objects: exit 0. ablation-dist-preflight.mjs @objectstack/platform-objects 'visible: \"current_user.isPlatformAdmin == true\"' --absent --source-marker=...: 'marker absent from all 66 built files', exit 0 (before the mutation the same spelling was at dist/index.mjs:2219). (a) went RED, 1 failed / 126 passed: expected 'features.organization != false' to be '(current_user.isPlatformAdmin == true) && features.organization != false'. (b) went RED, 1 failed / 13 passed: the offered set received [platform admin, org owner not a platform admin, org admin, delegated admin, plain member] against the expected [platform admin]. Direction: red, as predicted. Restore: ablation-replace and the outer trap both proved blob 9beac68ce1c7ed2b7143505916818a0a2e39a456 equal to the HEAD blob, with git diff HEAD empty. Rebuild, then preflight in present mode: 'marker present in 6 built files' and 'working tree clean against HEAD'. (a) passed 127/127 and (b) passed 14/14 again.",
    "gates": "At bb53601. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 67 commands, identical before and after the merge and identical to the order's list. All 67 ran and their last runs exited 0. --ran ran.list: '67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN'. check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3) on the dogfood-closure build. After a full turbo run build (72/72, under the lock) it passed (106 require entry points / 66 packages); check:dts-closure, check:lean-entry-closure, check:published-files and check:sourcemap-no-sources-content were rerun on that build and passed. Artifact-roster block: all 53 printed commands ran and 50 exited 0. Three answered NOT WIRED / NOT MEASURED (exit 2) for want of PR context. Rerun with context, all three passed: check-partof-closing-keyword with PR_BODY; check-closing-target-claim with PR_NUMBER=22064 ('PR #22064 closes #21886, and each carries a Claim: whose Branch: line names claude/issue-21886-add-member-visibility'); check-single-claim-paths with PR_NUMBER=22064. Partial: check-sdui-manifest passed, but its objectui version comparison is NOT CHECKED (no objectui checkout). check:console-injection is NOT MEASURED (no console dist; only its self-test ran). Neither surface is touched. The four symbol-anchor sweeps (check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors, check:adr-anchors) all passed. Lint, as a proven narrowing: eslint --no-inline-config --format json over the 3 touched TS files gave 3 files linted, 0 errors, 0 warnings. All 3 are in eslint's population (--print-config resolves). Invariance: the resolved configs carry no parserOptions.project/projectService, and eslint.config.mjs:327-328 states type-aware linting is never enabled. Left to CI: type-check lanes, Test Core, Dogfood Regression Gate, Build Core, repo-wide pnpm lint. CI state at report time: not read (in_progress or not started).",
    "deviations": [
    "objectui source (the pin a58626c) was read from a read-only shallow fetch (--depth 1, blob:none) into the session scratchpad. The container has no ../objectui checkout, and the GitHub API answered 403 for objectui in this session. It is deleted in cleanup.",
    "Pin (b) adds one principal (a second org owner, signed up and set to owner in system context) to the file's beforeAll, and its case writes a second organization and one membership. No existing principal was an org owner who is not a platform admin. The case runs last in the file, so no earlier case reads that state. The existing cases' binding is unchanged.",
    "There are two code commits, not one. The second (57b18bf, test-only) restates the case's verdict loop as one named-set equality, so the reverse verification shows every principal's cell at once instead of stopping at the first.",
    "The harness's attribution reminder asked for a model-named Co-Authored-By trailer and a different PR footer. AGENTS.md's model-free trailer pair and the session-URL PR footer were used instead (AGENTS.md takes precedence; the reminder itself defers to it).",
    "git push ran four times: the empty-branch probe, the two commits, and the merge of origin/main. The budget names git push as one category, and none was a force-push.",
    "The PR carries the labels documentation, size/m, tests and tooling, which another actor (the path labelers) set. They were left untouched.",
    "No write or command was refused by the session's permission checks."
    ],
    "mcp_calls": "0",
    "api_writes": "3 relay strokes, each POST /repos/objectstack-ai/objectstack/dispatches from the session and executed as objectstack-fleet[bot]: (1) pr_create, run 37587839117, becoming POST /repos/objectstack-ai/objectstack/pulls (draft) and creating #22064 (read-back: 11798 bytes sent and stored, identical); (2) label-write --assign os-project-manager, run 37587907840, becoming POST /repos/objectstack-ai/objectstack/issues/22064/assignees (read-back matches); (3) this os-dev-report comment through scripts/pm/post-stamped.mjs, becoming POST /repos/objectstack-ai/objectstack/issues/21886/comments. Plus git pushes (not REST). Reads: single-card REST reads of #21886 and its comments, one REST read of PR #22064, and the read-only GitHub calls of check-closing-target-claim and check-single-claim-paths.",
    "open_questions": [],
    "out_of_scope_findings": [
    "observation (not class a/b/c; ruled predicate): the door also admits the legacy better-auth user.role === 'admin' scalar (plugin-auth platform-admin-gate.ts:80-84), which current_user.isPlatformAdmin == true does not read. A pre-ADR-0068-D2 deployment whose user carries the scalar but not the rung would be admitted by the door and not offered the button. reach: none measured; the gate header says nothing in ObjectStack writes the scalar and that re-synthesizing it is vetoed. carrier: 承接者:无 · noted in the PR's Acceptance notes, not filed",
    "coverage note (not a finding): the dogfood case exercises the single-posture route to PLATFORM_ADMIN (the seeded admin's unscoped grant), not the walled postures' OS_PLATFORM_OWNER_EMAIL route. carrier: #21903 (the family close-out, Blocked-by: #21886), if it wants a walled-posture principal · noted, not filed"
    ]
    }


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #22064 → 77a94d8ced on main. It merged through the merge queue at 2026-10-07T08:35Z, after entering the queue at 2026-10-07T08:08Z. Verified at 2026-10-07T08:36Z. domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi.

    • The squash. It is on origin/main as a single-parent commit (parent 5cfd8661c4). Its diffstat is the reviewed one: 4 files, +132/-1.
    • What is on main (the maintainer's ruling A-lite, 6019378035):
      • sys_member.add_member declares visible: 'current_user.isPlatformAdmin == true'. The served predicate is (current_user.isPlatformAdmin == true) && features.organization != false.
      • The platform-objects lowering matrix pins it.
      • One dogfood case on a real showcase boot binds current_user as the console does (through extra). The platform admin alone is offered "Add Member"; an org owner, an org admin, a delegated admin and a plain member are not. The door refuses the owner and the member with 403 and admits the platform admin.
    • The card. Fixes #21886 closed this card as completed. pm:dispatched is removed in this act. No other card was closed by the body.
    • From this release (@objectstack/platform-objects patch, Clause-②: no): on an organization's member list, "Add Member" is offered only to a platform administrator, the one standing its endpoint admits. Owners and admins no longer see a button that answers 403.
    • The seat's ACCEPT is on PR fix(platform-objects): offer Add Member only to a platform admin #22064 (6033730812).
    • Next in the family: identity: the platform-admin-gated actions on sys_user, sys_oauth_application and sys_sso_provider show no standing term in visible — the affordance family's closing card, with an enumeration pin (after #21886's ruling) #21903 (the thirteen sibling actions and the enumeration pin) was Blocked-by: #21886, and that blocker is now released. It goes to triage's unlock scan; it is not claimed here.

    Generated by Claude Code

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

Metadata

Metadata

Labels

area:identityLogin and identity — sign-up, sessions, organization membership, SSObugSomething isn't workingdomain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they landpriority:p3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions