Repository navigation
[finding] sys_member.add_member is offered to every organization member, owners and admins included, but its door admits only a platform admin #21886
Description
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:
- the review record
5996636663on platform gap: a plain member is offered "Invite User" (and other org-admin affordances) that the server then refuses with 403 — an action's visibility cannot be gated on the membership grade #21795, ③ (b); - the platform gap: a plain member is offered "Invite User" (and other org-admin affordances) that the server then refuses with 403 — an action's visibility cannot be gated on the membership grade #21795 dev's report
5996276927, out-of-scope finding 2.
⛔ 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
packages/platform-objects/src/identity/sys-member.object.ts:144, theadd_memberaction. Its servedvisibleis theorganizationfeature gate alone. The platform gap: a plain member is offered "Invite User" (and other org-admin affordances) that the server then refuses with 403 — an action's visibility cannot be gated on the membership grade #21795 dev measured this on a real showcase boot, as served metadata for a plain member.packages/plugins/plugin-auth/src/organization-add-member.ts:35and:131: the door is platform-admin only ("including org owners/admins — they are NOT platform admins, ADR-0068"), with a 403 otherwise. This is cited from the source and its test; it was not re-measured.
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:
- platform gap: a plain member is offered "Invite User" (and other org-admin affordances) that the server then refuses with 403 — an action's visibility cannot be gated on the membership grade #21795: the grade-gated set;
add_memberis excluded there by its own ruling; - [finding]
sys_member"Add Member" action targetsPOST /organization/add-member, which better-auth 1.7.0-rc.2 never mounts (server-only) — on multi-org there is NO remaining UI path to attach an existing user to an org #9941 / docs: the newly mountedPOST /organization/add-memberis undocumented — and it is the ONLY multi-org path to attach an existing user #10050 / finding: three source comments claimteamIdonorganization/add-memberdefaults to the caller's active team — better-auth 1.7.1 has no such fallback #10532: the add-member endpoint's mount and docs; - console: workspace members page offers Invite / Remove member to the
memberrole; only the server 403 stops it #8092: the workspace page, closednot_planned; - console: org record page's default Members tab hides the email-invite entry — invite_user lives only on the Invitations tab #11544 / feat(auth,platform-objects): org join link V1 — sys_join_link + create/rotate/revoke/info/join, membership via governed addMember #11587: other members-tab and join-link cards.
Dedupe words: add_member platform admin affordance · Add Member offered org owner 403 · sys_member add_member visible
Activity
objectstack-fleet commentedon Oct 5, 2026
Path: sign-in and identity — organizations, teams and admin user operations | identity-auth.org-membership-team-management | P2
Triage: first grade —
bug·priority:p3·domain:engine·area:identity·pm:queue.add_member's visibility reads the standing its door reads; measure the declared predicate firstTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-05T14:55Z. ⛔ Not a claim, ⛔ not a dispatch.Triage: lands in
packages/platform-objects/src/identity/sys-member.object.ts(add_member, about:144) ⇒domain:engine(the lane that ownsplatform-objects); rationale: the action is gated only on the feature, while its door admits only a platform admin (ADR-0068).visiblescope already declare a predicate for platform-admin standing? That includes what PR feat(spec,platform-objects): org-admin actions follow the membership grade through one declared reach table #21883 lands for platform gap: a plain member is offered "Invite User" (and other org-admin affordances) that the server then refuses with 403 — an action's visibility cannot be gated on the membership grade #21795.add_memberuses it. A dogfood pin shows an org owner and a plain member see no "Add Member", and a platform admin does.Generated by Claude Code
objectstack-fleet commentedon Oct 5, 2026
Claim: PM loop round 36 · 2026-10-05T17:29Z
Session:
session_017ErfyP2Rx7XWHJA27QjyUiAccount:
os-project-manager(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-21886-add-member-visibilityWorktree:
objectstack-issue-21886Domain:
domain:engineSeat:
domain:engine#1File surface (at
origin/main1e18a0735c), per triage's grade and direction 5997030687:visiblescope (or another declared action key the console honours, such asrequiredPermissions) already declare a predicate for platform-admin standing (ADR-0068,judgePlatformAdmin)? That includes whatever platform gap: a plain member is offered "Invite User" (and other org-admin affordances) that the server then refuses with 403 — an action's visibility cannot be gated on the membership grade #21795 landed in607463d736.packages/platform-objects/src/identity/sys-member.object.ts'sadd_memberuses it, so an org owner, an admin and a plain member see no "Add Member", and a platform admin does;platform-objectspin on the served predicate;packages/qa/dogfood/test/(declared on [PM seat] domain:cli — 🟢 marchtian · session_01PFB9ZGRWYvjhTXsd8i6KBp · finishing 3, then serial (maintainer) #6024);.changeset/21886-*.md.needs_decision. The gap is a contract question and returns to triage. ⛔ No new predicate is declared on this card. ⛔ Nopackages/specorplugin-authedit.Container & model:
S,mode:subagent,model: default(dispatch-gates --tier: no path-derived mandate).Clause-②: no
patch, as platform gap: a plain member is offered "Invite User" (and other org-admin affordances) that the server then refuses with 403 — an action's visibility cannot be gated on the membership grade #21795'splatform-objectshalf shipped. No key, export or parameter is added.Thread-read: 5997030687
Serial constraints cleared: at 2026-10-05T17:29Z:
607463d736. This lane's PR fix(platform-objects)!: retire the sys_account link_social action, dead on every boot; unlink_account stays #21894 (identity: the "Link Social Account" action is dead on every boot — it navigates to a GET of the POST-only social sign-in route (404) and offers a fixed provider list regardless of configuration #21849), which reworded a comment inside the sameadd_memberblock, landed as7665c541a6.filename, paged. None touchessys-member.object.ts,org-admin-affordance-reach.dogfood.test.ts,organization-add-member.tsorplatform-objects.test.ts.sys-member.object.tsmoved in607463d736and7665c541a6; the dev reads it at the currentmain.objectOverride), so a delegated_admin is served noinvite_userthough the invite door admits them #21884 (metadata-core's FLS mask), touches noplatform-objectssource.objectstack-fleet commentedon Oct 5, 2026
os-dev-report
{
"issue": 21886,
"status": "needs_decision",
"branch": "claude/issue-21886-add-member-visibility",
"pr": null,
"session": "session_017ErfyP2Rx7XWHJA27QjyUi (subagent; the parent PM's session id, as the order states)",
"premise_still_valid": true,
"summary": "H1 holds.
sys_member.add_member(sys-member.object.ts:147) is gated onrequiresFeature: 'organization'alone (:158), so its servedvisibleisfeatures.organization != false(pinned at platform-objects.test.ts:593). Its door,/organization/add-member, sits behindgateAdmin, which callsjudgePlatformAdmin(auth-plugin.ts:2925);organization-add-member.test.tspins a plain member and an org owner at 403 PERMISSION_DENIED (:299, :309) and admits a platform admin (:320). H2 found no declared predicate for platform-admin standing whose meaning the contract, the producer and the console agree on. The one key that carries the door's own signal in the console,current_user.isPlatformAdmin, is one the spec marks Deprecated, and the spec defines it as'platform_admin' in positions. Core, plugin-auth and objectui all forbid that positions form for standing (#15136). Picking either form for the first first-party standing gate is a public metadata-semantics call, so the card stops here: nothing committed, nothing pushed, no PR.","h2_scope_table": [
"root current_user / user / ctx.user / os.user: ONE subject, objectui 0abd4f9f ExpressionProvider.tsx:190 (buildExpressionScope) over buildExpressionUser(session). It is handed to @objectstack/formula as
extra(objectui core/src/evaluator/fieldRules.ts:284), so the console runs no createEvalUser re-derivation","key isPlatformAdmin: console binds
user.isPlatformAdmin ?? false(expressionUser.ts:176). The session value isgrants.posture === 'PLATFORM_ADMIN'(plugin-auth auth-manager.ts:4008), the field judgePlatformAdmin reads (platform-admin-gate.ts:83), i.e. the door's own signal. Spec: declared optional and described "DERIVED alias of 'platform_admin' in positions. Deprecated." (spec identity/eval-user.zod.ts:226). ADR-0068 D4 [ruled] says platform-operator actions gate on it, with the clause (≡ 'platform_admin' in roles)","key positions containing 'platform_admin': console binds
user.positions ?? []. The server projects the name from the same hasPlatformAdminGrant as the rung (core resolve-authz-context.ts:1125, :1140), so the two agree for every genuine admin. Declared ADR-0068 D2 projection (BUILTIN_IDENTITY_PLATFORM_ADMIN). Forbidden as a standing read by core resolve-authz-context.ts:1193, plugin-auth platform-admin-gate.ts:65 and auth-manager.ts:3981, and objectui useWorkspaceAdminStatus.ts (objectui#8291). Tenant-written reserved names have been refused on write since #15972 (plugin-security sys-user-position.object.ts validations)","key can(object, verb): object permissions from /auth/me/permissions; does not carry standing",
"root features: /auth/config flags; requiresFeature lowers here; does not carry standing",
"root record (and previous): row binding; does not carry standing",
"action key requiresMembershipReach: lowers to 'org_owner' / 'org_admin' / 'delegated_admin' in current_user.positions (spec identity/membership-reach.ts); add-member is deliberately no row (membership-reach.ts:42)",
"action key requiredPermissions: a capability subset (mayInvoke, objectui DeclaredActionsBar.tsx:484), not standing. No capability is exclusive to the rung: organization_admin carries manage_org_users / setup.access / setup.write (plugin-security default-permission-sets.ts:431)",
"server CEL, @objectstack/formula buildScope: toEvalUser calls createEvalUser, which re-derives isPlatformAdmin = positions.includes('platform_admin') (formula stdlib.ts:419, spec eval-user.zod.ts:256). The same predicate text therefore reads the name on the server and the rung in the console. No server path evaluates an action
visibletoday: grep of rest / runtime / objectql / services / metadata found zero hits","verdict: no declared, non-deprecated predicate with an agreed meaning exists. The only predicate carrying the door's value in the console is a Deprecated key whose declared definition is the form the door side forbids. Hence needs_decision"
],
"tests": "No code changed, so no test was run. H1 and H2 were read from source at origin/main 1e18a07 (the dispatch commit) and objectui 0abd4f9f (the .objectui-sha pin, shallow-fetched read-only into the scratchpad). H1 was confirmed from source plus the door's own test, not on a real boot; the served-metadata half is cited from the #21795 dev's real-boot measurement, not re-measured.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat 1e18a07 answered: this branch changes nothing against 'origin/main' (merge base 1e18a07), nothing to derive (exit 2).","gates": "none owed. The diff is empty: dispatch-gates derived nothing at 1e18a07 (exit 2). The order's gate list, the artifact-roster block and the four symbol-anchor sweeps were not run, because there is no change for them to judge.",
"line_budget": "n/a",
"files_changed": [],
"deviations": [
"The empty-branch push probe (os-dev rule 1) was deferred until H2 decided, because the order allows only the report comment under needs_decision. H2 returned needs_decision, so the branch was never pushed. The local branch (at 1e18a07, no commits) and the clean worktree were removed, so a redispatch can reuse the name.",
"objectui source was read from a read-only shallow fetch of the pinned sha 0abd4f9f into the session scratchpad, because the container has no ../objectui checkout. It was deleted after reading.",
"Coordinator correction applied: the prompt's
Clause-²was a typo forClause-②(the order file is authoritative). It is moot under needs_decision.","No write or command was refused by the session's permission checks."
],
"mcp_calls": "0",
"api_writes": "1 — POST /repos//issues/21886/comments (this os-dev-report, via scripts/pm/post-stamped.mjs). Reads: 2 single-card REST reads (issue 21886 and its comments).",
"open_questions": [
{
"question": "Which declared predicate is canonical for platform-admin standing in metadata predicates, so that
sys_member.add_member(and the same-family actions listed in out_of_scope_findings) can gatevisibleon it? Today the spec declarescurrent_user.isPlatformAdminas Deprecated and defines it as'platform_admin' in positions. plugin-auth and the console bind it as the posture rung, which is the door's own signal. The server CEL scope re-derives it from positions. Core, plugin-auth and objectui forbid the positions form for standing. Decision frame: the one carried in the order (pm-dispatch SKILL.md 9dae475, block md5 abff5f852c8fd09b79ec0623439aa4f0).","options": [
"A — Make
current_user.isPlatformAdminthe canonical predicate. Un-deprecate it in EvalUserSchema and re-describe it as the ADR-0095 D3 posture rung, which is what auth-manager.ts:4008 already emits and what judgePlatformAdmin reads. Make createEvalUser / @objectstack/formula buildScope carry the producer's rung instead of re-deriving it from positions. Amend ADR-0068 D2/D4's (≡ 'platform_admin' in roles) clause (Tier H). Then gate add_member withvisible: 'current_user.isPlatformAdmin == true'composed under requiresFeature, and fold the same family in. Business need: measured; the door reads exactly this session field, ADR-0068 D4 already rules platform-operator actions gate on it, and cloud's sys_environment 'Change Plan (admin)' is a named external producer. Long term: one name, one meaning, equal to the door on every evaluator; no new key; it closes the declared-vs-delivered split on the key. AI safety: the first-party example an AI copies is the authority itself, and the server and console scopes stop disagreeing. Startup focus: it removes a stale deprecation and adds no surface and no gate. Cost: spec text, one factory input, a formula scope change, generated docs, and an ADR amendment by the maintainer.","B — Bless
'platform_admin' in current_user.positionsfor UI gates with no spec edit, since #15972 now refuses tenant-written reserved names, and gate add_member on it. Business need: the same pull as A. Long term: two predicates for one standing remain and the deprecated boolean lingers; the core / plugin-auth / objectui rule 'read the RUNG, never positions.includes' would have to be split into 'authorization only'. AI safety: worst. It teaches the name form for platform standing, and an AI pasting it into a server-side gate reopens the escalation path for any pre-#15972 row. Startup focus: no new surface, but both spellings are kept, the opposite of immediate retirement.","C — A declarative action key (e.g. requiresPlatformAdmin: true), the twin of requiresMembershipReach, lowered at parse time to whichever text A or B fixes. Business need: 14 first-party sites plus cloud. Long term: the predicate text lives in one lowering. AI safety: the strongest structural guard, because the AI writes a closed key instead of CEL. Startup focus: it is a NEW authorable key (spec, authorable-surface, liveness ledger, docs) and still needs the A/B answer underneath; default-tight expansion argues for A first, with C only if the CEL form proves error-prone.",
"D — A capability gate (requiredPermissions, e.g. manage_users). Business need: no capability equals the standing; any permission set can grant one, and organization_admin carries manage_org_users / setup.access / setup.write. Long term: ADR-0068 D4 defers capability gating to cloud#474. AI safety: it declares on a type: api action a capability the door never checks (the door checks standing), which is declared but not enforced. Startup focus: no new surface, but the semantics are wrong."
],
"recommendation": "A. It is the only option whose predicate equals the door's own signal on every evaluator. Business need: the door reads session.user.isPlatformAdmin, ADR-0068 D4 already rules it, and a named external producer uses it. Long term: one name, one meaning, no new key. AI safety: the copied first-party example is the authority, and the server and console scopes agree. Startup focus: it retires a stale deprecation instead of adding surface. Because the choice un-deprecates a spec key and amends ADR-0068, it belongs to the maintainer. Until then add_member keeps failing closed (403 at the door), which is why triage graded it p3."
}
],
"out_of_scope_findings": [
"class: same family as #21886, not a single-point card · reach: NOT MEASURED (no boot; which grades reach these lists below platform admin is unmeasured, and organization_admin carries setup.access) · evidence: 13 first-party actions target /api/v1/auth/admin/* mounts behind gateAdmin / judgePlatformAdmin / hasPlatformAdminStanding with no standing term in
visible: sys_user ban_user :125, unban_user :151, unlock_user :167, create_user :187, set_user_password :251, impersonate_user :300, set_user_manager :348; sys_oauth_application :90, :115; sys_sso_provider :92, :132, :162, :190 (mounts: auth-plugin.ts :2371 :2461 :2493 :2529 :2594 :2663 :2684 :2844 :2863; sso :2953 :2979 :2996 not individually mapped) · carrier: the #21886 decision's redispatch, to fold into that family close-out · noted, not filed · dedupe words: platform admin action visible standing · admin action offered 403 setup · isPlatformAdmin visible gate","class: b candidate · reach: none measured. Divergence needs a principal holding the platform_admin name without the grant, which #15972 refuses on write, so only a pre-#15972 sys_user_position row could reach it. Not filed for that reason · evidence: EvalUserSchema.isPlatformAdmin is described as derived from positions (eval-user.zod.ts:226); the session binds the rung (auth-manager.ts:4008); @objectstack/formula re-derives it from positions (stdlib.ts:419 to eval-user.zod.ts:256); docs repeat the spec text (content/docs/permissions/permission-metadata.mdx:226, content/docs/references/identity/eval-user.mdx:73). Seam: spec:EvalUserSchema.isPlatformAdmin → runtime:packages/formula/src/stdlib.ts toEvalUser | renderer:objectui buildExpressionUser · carrier: the #21886 decision (option A resolves it) · noted, not filed · dedupe words: isPlatformAdmin derivation rung positions · EvalUser isPlatformAdmin deprecated · createEvalUser platform_admin",
"class: observation · reach: n/a · evidence: org-admin-affordance-reach.dogfood.test.ts:208 evaluates with
user:, so @objectstack/formula re-derives isPlatformAdmin from positions; the console passes its scope asextra(objectui fieldRules.ts:284). A dogfood pin of a standing predicate written that way measures the name form, not the console's binding. Harmless for its current position-only predicates · carrier: the redispatch's dogfood case (bind current_user via extra, as the console does) · noted, not filed","class: observation (sibling-repo prose) · reach: n/a · evidence: objectui 0abd4f9f useWorkspaceAdminStatus.ts docblock says sys_user_position position values 'are unconstrained', which has been stale since #15972's write refusal; the rule it states (read the rung) still stands · carrier: 承接者:无 · noted, not filed"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 5, 2026
Retriage requested:
pm:retriage· no declared predicate for platform-admin standing (triage's step 1 answered "no")domain:engine#1·session_017ErfyP2Rx7XWHJA27QjyUi· 2026-10-05T17:44Z. The dev's report is 5999863859 (needs_decision, no PR, no code, nothing pushed). ⛔ The claim (5999650144) stands. The card keepspm:dispatched. Triage's direction (5997030687) said: if no predicate exists, stop, and the card returns to triage for the decision box. This is that return.What the dev measured (source at
origin/main1e18a0735c, plus objectui at the pin0abd4f9f). The premise holds.sys_member.add_memberis gated onrequiresFeature: 'organization'alone (sys-member.object.ts, about:158). Its door sits behindgateAdmin→judgePlatformAdmin. The door's own test refuses a plain member and an org owner with 403 and admits a platform admin.current_user.isPlatformAdmin. The console binds it from the session, and plugin-auth emits it as the posture rung, the fieldjudgePlatformAdminreads. So in the console it is the door's own signal. But the spec declares it Deprecated, and defines it as "a DERIVED alias of'platform_admin' in positions". The seat read this atpackages/spec/src/identity/eval-user.zod.ts: the schema's.describe()andcreateEvalUser'spositions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN).@objectstack/formulare-derives it that way on the server.'platform_admin' in current_user.positions. Core, plugin-auth and objectui all forbid this form as a standing read (action.visible 的 current_user.positions 装的是 auth 角色而非安全层岗位,按岗位收敛的按钮对所有人静默消失(17.2.0) #15136).requiresMembershipReach. It lowers to org grades. Add-member is deliberately no row of the reach table.requiredPermissions. It is a capability subset. No capability is exclusive to the standing (organization_admincarriesmanage_org_users,setup.accessandsetup.write).featuresand the record. Neither carries standing.The fork (the dev's options; its four-axis analysis is in 5999863859):
current_user.isPlatformAdmincanonical:EvalUserSchemaand describe it as the posture rung that plugin-auth already emits;createEvalUser/@objectstack/formulacarry the producer's rung instead of re-deriving it;'platform_admin'in roles" clause;add_memberwithcurrent_user.isPlatformAdmin == true.'platform_admin' in current_user.positionsfor UI gates, with no spec edit. A tenant can mint asys_user_positionrow spelling any built-in identity name — PR #15948 closed every reader, nothing stops the row #15972 refuses tenant-written reserved names. But this keeps two spellings and splits the rule "read the rung, neverpositions".requiresPlatformAdmin: true), the twin ofrequiresMembershipReach, lowered to A's or B's text. This is new authorable surface and still needs A or B underneath.requiredPermissions. No capability equals the standing, so it would declare what the door never checks.Seat's read:
origin/main.createEvalUser(the spec lane's), and amends ADR-0068, a governed surface. That is a maintainer call routed through the spec lane, not this lane's.add_memberfails closed (403 at the door), which is why it is p3.Asked of triage:
EvalUserSchema,createEvalUser, the formula scope and the ADR-0068 amendment) isdomain:spec's and the maintainer's, andadd_member's gate is this lane's, serial after it.Out of scope, carried by the decision's redispatch, not filed (the dev's findings):
/api/v1/auth/admin/*and SSO mounts with no standing term invisible:sys_user:ban_user,unban_user,unlock_user,create_user,set_user_password,impersonate_userandset_user_manager;sys_oauth_application×2;sys_sso_provider×4.isPlatformAdminderivation split. The session binds the rung, while the spec and@objectstack/formuladerive it frompositions. They diverge only for a principal holding the name without the grant, which A tenant can mint asys_user_positionrow spelling any built-in identity name — PR #15948 closed every reader, nothing stops the row #15972 refuses on write. Option A resolves it.org-admin-affordance-reach.dogfood.test.tsevaluates withuser:, so formula re-derivesisPlatformAdmin. A standing-predicate dogfood case should bindcurrent_userthe way the console does (extra).Generated by Claude Code
objectstack-fleet commentedon Oct 5, 2026
Triage answers
pm:retriage(5999894453): into the decision box. Under A, the spec half isdomain:spec's andadd_member's gate stays here, serial; the family close-out is its own closing cardTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-05T17:53Z. ⛔ Not a claim, ⛔ not a dispatch. The claim (5999650144) and the assignee are untouched.createEvalUserand amends ADR-0068, a governed surface. That is the maintainer's to rule, and it goes into triage's next batch with the 速读 below.EvalUserSchema,createEvalUser, the formula scope and the ADR-0068 amendment) isdomain:spec's, filed as its own card on the ruling.add_member's gate stays on this card,domain:engine, serial after it.Blocked-by: #21886. It holds the 13 members and an enumeration pin. It is the family's third occurrence (console: workspace members page offers Invite / Remove member to thememberrole; only the server 403 stops it #8092, platform gap: a plain member is offered "Invite User" (and other org-admin affordances) that the server then refuses with 403 — an action's visibility cannot be gated on the membership grade #21795, this card).维护者速读
"添加成员"这类按钮只有平台管理员点了才有效,但现在所有人都看得到。要按"是不是平台管理员"来控制按钮显示,协议里没有一个现成的、含义和服务端判断完全一致的写法。
推荐 A(只看①选 A;②③④ 是否翻转:否)。置信缺口:A 要改一个已发布的 spec 字段的含义,第三方作者可能已经按"废弃"处理了它。
四棱(os-decision-facets)
Prior rulings read: ADR-0068 D2/D4 → 1; #15136 (positions are not a standing read) → 1; #21795 ruling A (
5993018584) → 1; thread: the dev's5999863859Generated by Claude Code
2 remaining items
objectstack-fleet commentedon Oct 7, 2026
Triage: unlocked,
pm:blocked→pm:queue. #22012 landed the A-lite spec half, soadd_member's gate is next, as ruledBlocked-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):
d5a14dd5c0, an ancestor oforigin/main.EvalUser.isPlatformAdminis the live PLATFORM_ADMIN standing predicate but is still marked Deprecated and positions-derived — lift the mark, describe it as ADR-0095 D3 standing, date-note ADR-0068 D2/D4 (#21886 spec half) #22012 closedcompletedat 2026-10-07T05:06Z.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 onrequiresFeature: 'organization'alone (:158).What this card does, by the ruling
6019378035(A-lite):add_member'svisiblereadscurrent_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, staysBlocked-by: #21886.objectstack-fleet commentedon Oct 7, 2026
Claim: PM loop round 55 · 2026-10-07T06:32Z
Session:
session_017ErfyP2Rx7XWHJA27QjyUiAccount:
os-project-manager(the seat's linked user, asGET /useranswers it; always the card's assignee)Branch:
claude/issue-21886-add-member-visibilityWorktree:
objectstack-issue-21886Domain:
domain:engineSeat:
domain:engine#1Provenance:
EvalUser.isPlatformAdminis the live PLATFORM_ADMIN standing predicate but is still marked Deprecated and positions-derived — lift the mark, describe it as ADR-0095 D3 standing, date-note ADR-0068 D2/D4 (#21886 spec half) #22012 closed.pm:queue(6032081861): spec(identity):EvalUser.isPlatformAdminis the live PLATFORM_ADMIN standing predicate but is still marked Deprecated and positions-derived — lift the mark, describe it as ADR-0095 D3 standing, date-note ADR-0068 D2/D4 (#21886 spec half) #22012 landed through PR fix(spec): EvalUser.isPlatformAdmin is the ADR-0095 D3 PLATFORM_ADMIN standing, not a deprecated alias #22018 (d5a14dd5c0), andEvalUserSchema.isPlatformAdminnow describes the PLATFORM_ADMIN standing of ADR-0095 D3.needs_decision(5999863859) with nothing pushed, and the ruling released it.File surface (at
origin/main56c88446ea), per the ruling 6019378035 and triage's unlock 6032081861:packages/platform-objects/src/identity/sys-member.object.ts:add_member(:147) gainsvisible: 'current_user.isPlatformAdmin == true', composed withrequiresFeature: 'organization'(:158).platform-objectspin on the served predicate (platform-objects.test.ts, whereadd_member's servedvisibleis pinned today).packages/qa/dogfood/test/,domain:cli's path, declared on [PM seat] domain:cli — 🟢 marchtian · session_01PFB9ZGRWYvjhTXsd8i6KBp · finishing 3, then serial (maintainer) #6024): an org owner and a plain member see no "Add Member", and a platform admin does. It bindscurrent_userthe way the console does, throughextra, notuser:. Withuser:,@objectstack/formulare-derivesisPlatformAdminfrom positions (the earlier dev report's third observation)..changeset/21886-*.md(@objectstack/platform-objectspatch).createEvalUsertaking the rung as input, the@objectstack/formulascope change), and B, C and D. ⛔ Nopackages/spec,plugin-authorformulaedit. ⛔ identity: the platform-admin-gated actions onsys_user,sys_oauth_applicationandsys_sso_providershow no standing term invisible— the affordance family's closing card, with an enumeration pin (after #21886's ruling) #21903's thirteen siblings are not this card's.Container & model:
S,mode:subagent,model: default(dispatch-gates --tier: no path-derived mandate).Clause-②: no
patch. No key, export or parameter is added.Thread-read: 6032081861
Serial constraints cleared: at 2026-10-07T06:32Z:
filename: none touchessys-member.object.ts,platform-objects.test.tsor any file underpackages/qa/dogfood/test/.EvalUser.isPlatformAdminis the live PLATFORM_ADMIN standing predicate but is still marked Deprecated and positions-derived — lift the mark, describe it as ADR-0095 D3 standing, date-note ADR-0068 D2/D4 (#21886 spec half) #22012, closedcompleted.metadata-protocolonly.sys_user,sys_oauth_applicationandsys_sso_providershow no standing term invisible— the affordance family's closing card, with an enumeration pin (after #21886's ruling) #21903 staysBlocked-by: #21886.objectstack-fleet commentedon Oct 7, 2026
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_membernow declaresvisible: 'current_user.isPlatformAdmin == true'(sys-member.object.ts), andrequiresFeature: '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 bindscurrent_userthroughextrafrom 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 emptygit 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
visibleis 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_memberon 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_userthroughextra. At the current objectui pin a58626c88d: fieldRules.ts:284 passes the scope asextra; ExpressionProvider.tsx:190 builds one subject under current_user / user / ctx.user / os.user, withfeaturesbeside it; expressionUser.ts:176 forwardsisPlatformAdminfrom the session. The session value isgrants.posture === 'PLATFORM_ADMIN'(plugin-auth auth-manager.ts:4083, emitted at :4107). Underuser:, formula re-derives it from positions (stdlib.ts:417-430 through createEvalUser); underextra, the bag is merged verbatim (stdlib.ts:462). The correction: the file's existingownerprincipal is the harness's seeded dev admin, and its served session carriesisPlatformAdmin: 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 toownerin system context (the file's own shape). It asserts that this owner carriesorg_ownerandisPlatformAdmin: false. The plain member, admin and delegated admin are reused. The existing cases still bind throughuser:, 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 bindscurrent_user.isPlatformAdminfor this predicate. @objectstack/formula was not touched."},
"clause_2": "no, measured. The diff adds one
visiblevalue 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 nomajorbump'. The changeset is a @objectstack/platform-objects patch whose body carriesClause-②: no, and the PR body opens withFixes #21886thenClause-②: no.","files_changed": [
"packages/platform-objects/src/identity/sys-member.object.ts (+11: the
visiblepredicate 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 predicatepasses, and the parsed object carriesvisible.source: '(current_user.isPlatformAdmin == true) && features.organization != false'. platform-objectstypecheck: exit 0, 0 TS errors;check:test-typecheck: OK;--listFilesshows 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), includingAdd Member is offered to a platform admin alone .... dogfoodtypecheck: exit 0;--listFilesincludes the file (1 hit). Reverse verification on committed HEAD 57b18bf: scripts/ablation-replace.mjs in WRAP mode deleted the linevisible: 'current_user.isPlatformAdmin == true',(anchor x1 to x0, blob 9beac68ce1c7 to b8792313cac4), with an outertrap restore EXIT INT TERMon 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, withgit diff HEADempty. 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/objectstackderives 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 fullturbo 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 a58626c88d) 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
ownerin 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 pushas one category, and none was a force-push.","The PR carries the labels
documentation,size/m,testsandtooling, 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/dispatchesfrom the session and executed as objectstack-fleet[bot]: (1) pr_create, run 37587839117, becomingPOST /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, becomingPOST /repos/objectstack-ai/objectstack/issues/22064/assignees(read-back matches); (3) this os-dev-report comment through scripts/pm/post-stamped.mjs, becomingPOST /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), whichcurrent_user.isPlatformAdmin == truedoes 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
objectstack-fleet commentedon Oct 7, 2026
Landed: PR #22064 →
77a94d8cedonmain. 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.origin/mainas a single-parent commit (parent5cfd8661c4). Its diffstat is the reviewed one: 4 files, +132/-1.main(the maintainer's ruling A-lite, 6019378035):sys_member.add_memberdeclaresvisible: 'current_user.isPlatformAdmin == true'. The served predicate is(current_user.isPlatformAdmin == true) && features.organization != false.platform-objectslowering matrix pins it.current_useras the console does (throughextra). 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.Fixes #21886closed this card ascompleted.pm:dispatchedis removed in this act. No other card was closed by the body.@objectstack/platform-objectspatch,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.sys_user,sys_oauth_applicationandsys_sso_providershow no standing term invisible— the affordance family's closing card, with an enumeration pin (after #21886's ruling) #21903 (the thirteen sibling actions and the enumeration pin) wasBlocked-by: #21886, and that blocker is now released. It goes to triage's unlock scan; it is not claimed here.Generated by Claude Code
fix(spec): EvalUser.isPlatformAdmin is the ADR-0095 D3 PLATFORM_ADMIN…
fix(platform-objects): offer Add Member only to a platform admin (obj…