Repository navigation
fix(platform-objects)!: platform-admin actions declare their gate, read by MCP list_actions and both action doors - #30849
Conversation
… both action doors The 15 first-party actions whose door runs the platform-admin gate carried that fact only in `visible`, which no door reads, so MCP `list_actions` listed `ban_user` and `set_user_manager` to a plain member and the action door forwarded the call for the target to refuse. Each now declares `requiredPermissions` (ADR-0066 D4): `manage_users` for the account and membership doors, `manage_platform_settings` for the OAuth application and SSO doors. The rung carries both on either anchor, so a platform admin is unaffected; `actionPermissionError`, the one reader, hides them from `list_actions` and refuses them 403 at REST /actions and `run_action`. The standing pin grows the declared-gate half, and a runtime test drives the real sys_user declaration through both doors. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czSnPb3bu4Q5weHEpr
…apabilities Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czSnPb3bu4Q5weHEpr
…lared gate Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czSnPb3bu4Q5weHEpr
…n_user Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czSnPb3bu4Q5weHEpr
…action Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czSnPb3bu4Q5weHEpr
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czSnPb3bu4Q5weHEpr
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czSnPb3bu4Q5weHEpr
📓 Docs Drift CheckThis PR changes 3 package(s): 22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 36 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 297afc4b2240b9a61c7a5cccee36e0e81b4accb8 && git checkout 297afc4b2240b9a61c7a5cccee36e0e81b4accb8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c77dd17f2671f35e8f194ca1e63d8cc75bfb861a 15a33791d0205cb2f7e49e5e27bc54c7a430aa6b && git checkout -B drift-repro c77dd17f2671f35e8f194ca1e63d8cc75bfb861a && git merge --no-ff 15a33791d0205cb2f7e49e5e27bc54c7a430aa6b
node scripts/docs-audit/affected-docs.mjs --json c77dd17f2671f35e8f194ca1e63d8cc75bfb861a
|
|
domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4 Contract review for card #30771 · narrowing face ( Contract reviewServed-tier: Inputs read: card #30771 body and all six comments (triage 6675600532, claims 6677617495 and 6677709499, priority 6678665567, os-dev-report 6678862496), the ruling it cites (6674432613, row 16 letter A, with the seat analysis 6671252245), PR #30849 body, its 11-file list, the net diff against ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentThe narrowing against the published spec on
S1 — should-fix: the scoped MCP refusal names a false cause. No must-fix found. Implemented-by: VERDICT: PASS |
…swers that gate, not the system-object sentence The MCP tools admit a sys_* action called with objectName only where list_actions lists it. Now that the listing leaves out an exposed action whose declared gate refuses the caller, that call was told the action is not exposed to AI, which is false. A new optional McpActionBridge member, capabilityRefusal(name, objectName), lets the runtime bridge answer the gate's own refusal there; an action not exposed keeps the sentence, and a bridge without the member behaves as before. run_action's gate refusal now carries the REST door's 403 and PERMISSION_DENIED, by name and scoped. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czSnPb3bu4Q5weHEpr
|
domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4 Delta re-review for card #30771 over record 6679092098 (PASS on Contract reviewServed-tier: Delta record over 6679092098 (PASS on ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentThe narrowing against the published spec on S1 — closed. The scoped MCP refusal for a gated exposed action now names its true cause with the REST door's code and status (①.1, ①.4). The repaired sentence is pinned at three layers and the control for the false-cause case ( The carrier change, judged as its own change. Adding S2 — should-fix: one full dogfood on the final head, before arming. The aggregate "Dogfood Regression Gate" is green on this head, but the three shard jobs and Dogfood Verify CLI are skipped on every No must-fix found. Implemented-by: VERDICT: PASS |
|
domain:server#4 · os-elon-musk · session_016WV4czSnPb3bu4Q5weHEpr CI red on
Generated by Claude Code |
|
domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4 Flake judgment · 2026-11-06T07:59Z · run 42181698191, Test Core (4/8) on head |
|
domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4 维护者速读
席位意见:ACCEPT(#30771)。 席位核对Head-sha:
|
|
domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4 Maintainer's per-PR word (BREAKING) · 2026-11-06T15:00Z Elon chose 「同意落地」 on the seat's decision card in the server seat 4 thread, 2026-11-06T14:59Z, for this PR at head |
…e two #30771 dispatcher tests check:test-source-alias (its source-reach half, on main since this PR's last CI) reds the merge queue: both files reach standalone-stack.ts's dynamic import('@objectstack/metadata') through http-dispatcher.ts, so the first case to get past the gates would transform that graph inside its 5000 ms window. A module-top side-effect import moves the load to collection, as the gate prescribes; the source keeps its dynamic import. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czSnPb3bu4Q5weHEpr
|
domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4 Seat note · 2026-11-06T16:46Z · head moved from |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 42257589439 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
domain:server#4 · os-elon-musk · session_016WV4czSnPb3bu4Q5weHEpr Queue build 42257589439 red:
Generated by Claude Code |
Requested by Elon · project thread
domain:server#4 · os-elon-musk · session_016WV4czSnPb3bu4Q5weHEpr
Fixes #30771
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: none —
skills/**teaches none of these 15 actions, andcontent/docs/ai/connect-mcp.mdxalready lists "the caller fails itsrequiredPermissions" as a reason an action is missing fromlist_actions, which is what now happens.维护者速读
requiredPermissions,用户类为manage_users,平台设置类为manage_platform_settings;MCPrun_action被门槛拒绝时统一答 403PERMISSION_DENIED,带不带objectName都一样。list_actions里能看到ban_user等 36 个动作,试了才被目标拒绝;现在门槛写在一处,列表与两个执行入口都读它。role === 'admin'标量、没有平台管理员权限集的账号,经动作入口也被拒(直连/api/v1/auth/admin/*不变)。回滚即 revert。packages/platform-objects/src/identity/platform-admin-action-gate.ts(两个门槛常量及为何能代表平台管理员身份);②packages/mcp/src/mcp-http-tools.ts的run_action预检与新的可选成员capabilityRefusal;③sys-user.object.ts的 8 处requiredPermissions。pnpm --filter @objectstack/runtime exec vitest run src/platform-admin-action-gate.test.ts,预期 10/10 绿:普通成员列表里没有ban_user/set_user_manager,管理员有;成员调用被 403 且目标从未被调用。What changes
Ruling A on #30126 row 16 (record 6674432613): an action declares its gate once, and REST
/actionsand MCPlist_actionsread that one declaration.Measured on
origin/main(c17719d) before building:requiredPermissions(ADR-0066 D4) is already on the action schema, and its one reader isactionPermissionError(packages/runtime/src/action-execution.ts), which compares it withExecutionContext.systemPermissions. ThePLATFORM_ADMINrung that the platform-admin doors judge is conferred by two anchors (the storedadmin_full_accessgrant andOS_PLATFORM_OWNER_EMAIL), and both putADMIN_FULL_ACCESS_CAPABILITIES.systemPermissionson the holder (resolve-authz-context.ts, step 6b).manage_usersandmanage_platform_settingsare in that list and are catalogscope: 'platform'capabilities thatorganization_adminwithholds. So the rung implies the capability, and the declaration never hides an action from a caller its door admits on the rung./actionshas no listing route; it isPOSTinvocation only.POST /actions/:object/:action, MCPlist_actions,run_actionandresume_runall callactionPermissionErroron the same declaration. The gap was that these 15 actions declared their gate only invisible, which is display only. The one code change (the contract review's S1, below) is so the MCP tools name that gate when it refuses.invite_userlisted four times is correct. It is declared on four objects (sys_user,sys_member,sys_invitationas a list-toolbar action,sys_organizationas a record-scoped action withorganizationId), all targeting/api/v1/auth/organization/invite-member. Each is a separate(objectName, name)address, andrun_actionrefuses a bare name that is ambiguous across objects. Collapsing them would mean turningai.exposedoff on mirrors, which narrowsrun_action. Left as is.The change. A new module
platform-admin-action-gate.tsexportsPLATFORM_ADMIN_USERS_GATE(['manage_users']) andPLATFORM_ADMIN_SETTINGS_GATE(['manage_platform_settings']), with the reasoning above. The 15 actions whose target is a platform-admin-gated door (the populationplatform-admin-affordance-standing.test.tsalready derives from the/api/v1/auth/admin/namespace plus/organization/add-member) spread one of them intorequiredPermissions:sys_userban_user,unban_user,unlock_user,create_user,set_user_password,impersonate_user,set_user_manager,list_platform_adminsmanage_userssys_memberadd_membermanage_userssys_oauth_applicationdisable_oauth_application,enable_oauth_applicationmanage_platform_settingssys_sso_providerregister_sso_provider,register_saml_provider,request_domain_verification,verify_domainmanage_platform_settingsThe MCP refusal (contract review S1, 6679092098). With
allowSystemObjectsoff, the MCP tools admit asys_*action called withobjectNameonly where the bridge'slist_actionslists it, and answered every unlisted one with the system-object sentence (… is not exposed via MCP: … author exposed it to AI (ai.exposed) …). Once the listing leaves out an action whose gate refuses the caller, that sentence names the wrong cause: all 15 areai.exposed: true. So:@objectstack/mcp:McpActionBridgegains an OPTIONAL member,capabilityRefusal(name, objectName). For an unlistedsys_*action, the scoped pre-screen asks it. A refusal is answered as that envelope;undefined(not declared there, or not exposed to AI) keeps the system-object sentence. A bridge without the member behaves as before, and nothing runs in either case.@objectstack/runtime: the bridge implements it from the declarationslistActionsreads (domains/mcp.ts). The answer isactionPermissionRefusal(action-execution.ts, new):{ code: 'PERMISSION_DENIED', status: 403, message }, the message beingactionPermissionError's sentence.invokeBusinessActionnow throws its capability refusal with that samecodeandstatus, so a scoped call and a by-name call answer identically. This applies to EVERY action withrequiredPermissions, not only these 15: MCPrun_action's capability refusal used to be a plain message, and it now carries the REST door's403and its standard code.Before → now (for reviewers; the changeset states the same as before/now):
list_actions: FROM every caller listed all 15 → TO a caller without the capability is not listed them.POST /actions/:object/:actionand MCPrun_action(byactionNamealone or withobjectName): FROM forwarded, target answered 403PERMISSION_DENIED"Admin role required" → TO the door answers 403PERMISSION_DENIEDwith the capability sentence, and nothing is forwarded.run_action's capability refusal for any action withrequiredPermissions: FROM a plain message (nocode, nostatus) → TOcode: 'PERMISSION_DENIED',status: 403, same message.run_actionwithobjectNameon asys_*action that is NOT exposed to AI: unchanged, the system-object sentence.role === 'admin'scalar: FROM admitted through the action doors → TO refused 403 there; the/api/v1/auth/admin/*endpoints still admit it.Tests
packages/platform-objects/src/platform-admin-affordance-standing.test.ts: a new case, for every member of the derived family, thatrequiredPermissionsis non-empty and each name is both inADMIN_FULL_ACCESS_CAPABILITIES.systemPermissionsand ascope: 'platform'catalog capability, with a positive control on the two names.packages/runtime/src/platform-admin-action-gate.test.ts(new): the realsys_userdeclaration through the production MCP bridge and the REST door. A plain member is not listedban_user/set_user_manager, a platform admin is; the member is refused 403 at REST and atrun_actionand the forward target is never called; the admin is forwarded; and for everysys_useraction and both callers, the listing omits it exactly when the shared gate refuses it.packages/runtime/src/domains/api-action-create-user-email-param.test.ts: its caller is documented as a platform administrator but held no capabilities; it now holds the rung's.packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts: the plain-member case now asserts the door's capability sentence (status 403 as before).packages/qa/dogfood/test/mcp-system-object-action-exposure.dogfood.test.ts: the "plain member's agent passes the door and is refused by the identity route" case is replaced. The member's agent is not listedban_user; scoped and by-namerun_actionboth answer 403PERMISSION_DENIEDwith the identical capability sentence and never "not exposed"; nobody is banned. A new CONTROL:impersonate_user(gated, noaiblock), called scoped by the same agent, keeps the system-object sentence.packages/mcp/src/mcp-action-system-object-exposure.test.ts: three new cases over a double bridge. AcapabilityRefusalanswer is rendered as its 403 envelope and nothing runs. CONTROL: an action the bridge answers nothing for keeps the system-object sentence. CONTROL: a listed action never asks.packages/runtime/src/platform-admin-action-gate.test.tsalso pins that by-namerun_actionthrows{ code: 'PERMISSION_DENIED', status: 403 }, thatcapabilityRefusalanswers the by-name message exactly, and that it answersundefinedfor the admin, forimpersonate_user(CONTROL) and for an undeclared name.Ablation (two legs, rebuilt dist, on-disk proof)
PLATFORM_ADMIN_USERS_GATE→['ablation_30771_unheld']viascripts/ablation-replace.mjs(anchor 1 → 0, blob 0dd52730 → a3e22e53),pnpm --filter @objectstack/platform-objects build,ablation-dist-preflightmarker present in 4 dist files. Standing pin: 1 failed / 5. Runtime: 6 failed / 8 (the admin is no longer listed or forwarded).[](blob 0dd52730 → 2e06f41b), rebuilt,manage_userscount indist/identity/index.mjs0. Runtime: 6 failed / 8, including "a plain member is not listed ban_user or set_user_manager".ablation-replace --restore(blob == HEAD,git diff HEADempty), rebuild,ablation-dist-preflight --absentmarker absent from 66 files and tree clean; both files green again (6/6, 8/8).Acceptance notes
manage_userswithout the rung (a custom permission set) is listed and then refused by the target, as every caller was before.McpActionBridge.capabilityRefusalis a new optional member of a published interface (widening). Therun_actioncapability refusal gainingcodeandstatusis additive for a client that reads only the message.adr-0087: not-required (no-migration-prescription): no authored metadata is retired or renamed. Its behaviour list is written as before/now, because aFROM → TOlabel is read bycheck-adr-0087-registrationas a migration prescription and refuses that disposition; the FROM → TO form is in this body instead.Verification
Local runs (final head 1764b50)
pnpm --filter @objectstack/mcp typecheckexit 0,pnpm --filter @objectstack/mcp testTest Files 56 passed, Tests 683 passed;pnpm --filter @objectstack/runtime typecheckexit 0,pnpm --filter @objectstack/runtime testTest Files 548 passed, Tests 8770 passed, 19 skipped; dogfoodmcp-system-object-action-exposure,api-action-door-forward,parent-read-gate-silent-skip: 28/28.pnpm --filter @objectstack/platform-objects typecheckexit 0.pnpm --filter @objectstack/platform-objects test: Test Files 116 passed, Tests 1584 passed.pnpm --filter @objectstack/runtime testhad 1 file / 4 tests failed (api-action-create-user-email-param.test.ts, the fixture above); fixed in ba157b0.pnpm --filter @objectstack/dogfood test, under the verify lock) on 64f9d94: Test Files 423 passed, 3 skipped (426); Tests 3533 passed, 41 skipped (3574); VERDICT command-exit 0. Not re-run in full on 1764b50; the three dogfood files the S1 change reaches are the 28/28 above.node scripts/pm/dispatch-gates.mjs --commandson 1764b50: 66 families derived; all 66 run, exit 0.--ranreconciliation: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN. (On 8b33698: 63/63;check-adr-0087-registrationwent green after the disposition marker, andcheck:dual-build-cjs-loadsafter building 7 packages that had no dist.)pnpm lint,test:repo, the type-check lanes); not run locally.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_016WV4czSnPb3bu4Q5weHEpr