Skip to content

fix(platform-objects)!: platform-admin actions declare their gate, read by MCP list_actions and both action doors - #30849

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/project-thread-5uh3yy
Nov 6, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/project-thread-5uh3yy

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

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, and content/docs/ai/connect-mcp.mdx already lists "the caller fails its requiredPermissions" as a reason an action is missing from list_actions, which is what now happens.

维护者速读

  • 改了什么:15 个只有平台管理员能用的内置动作(封禁用户、设置上级、建用户、重置密码、OAuth 应用启停、SSO 注册等)在声明里写上门槛 requiredPermissions,用户类为 manage_users,平台设置类为 manage_platform_settings;MCP run_action 被门槛拒绝时统一答 403 PERMISSION_DENIED,带不带 objectName 都一样。
  • 为什么改:裁决 A(Console and CLI polish from the real-user walkthrough #30126 第 16 行):以前普通成员在 MCP list_actions 里能看到 ban_user 等 36 个动作,试了才被目标拒绝;现在门槛写在一处,列表与两个执行入口都读它。
  • 风险与回滚:收窄(BREAKING)。普通成员看不到这些动作,调用在入口就被 403;只靠旧 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 且目标从未被调用。
  • 需要你做的:收窄改动,落地前需要你对这个 PR 点头。

What changes

Ruling A on #30126 row 16 (record 6674432613): an action declares its gate once, and REST /actions and MCP list_actions read that one declaration.

Measured on origin/main (c17719d) before building:

  1. An existing key carries the gate, so no spec change. requiredPermissions (ADR-0066 D4) is already on the action schema, and its one reader is actionPermissionError (packages/runtime/src/action-execution.ts), which compares it with ExecutionContext.systemPermissions. The PLATFORM_ADMIN rung that the platform-admin doors judge is conferred by two anchors (the stored admin_full_access grant and OS_PLATFORM_OWNER_EMAIL), and both put ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions on the holder (resolve-authz-context.ts, step 6b). manage_users and manage_platform_settings are in that list and are catalog scope: 'platform' capabilities that organization_admin withholds. So the rung implies the capability, and the declaration never hides an action from a caller its door admits on the rung.
  2. The shared reader already exists. REST /actions has no listing route; it is POST invocation only. POST /actions/:object/:action, MCP list_actions, run_action and resume_run all call actionPermissionError on the same declaration. The gap was that these 15 actions declared their gate only in visible, 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.
  3. invite_user listed four times is correct. It is declared on four objects (sys_user, sys_member, sys_invitation as a list-toolbar action, sys_organization as a record-scoped action with organizationId), all targeting /api/v1/auth/organization/invite-member. Each is a separate (objectName, name) address, and run_action refuses a bare name that is ambiguous across objects. Collapsing them would mean turning ai.exposed off on mirrors, which narrows run_action. Left as is.
  4. Clause-②: yes (narrowing), accepted by the seat. See the changeset for the three before/now pairs.

The change. A new module platform-admin-action-gate.ts exports PLATFORM_ADMIN_USERS_GATE (['manage_users']) and PLATFORM_ADMIN_SETTINGS_GATE (['manage_platform_settings']), with the reasoning above. The 15 actions whose target is a platform-admin-gated door (the population platform-admin-affordance-standing.test.ts already derives from the /api/v1/auth/admin/ namespace plus /organization/add-member) spread one of them into requiredPermissions:

Object Actions Gate
sys_user ban_user, unban_user, unlock_user, create_user, set_user_password, impersonate_user, set_user_manager, list_platform_admins manage_users
sys_member add_member manage_users
sys_oauth_application disable_oauth_application, enable_oauth_application manage_platform_settings
sys_sso_provider register_sso_provider, register_saml_provider, request_domain_verification, verify_domain manage_platform_settings

The MCP refusal (contract review S1, 6679092098). With allowSystemObjects off, the MCP tools admit a sys_* action called with objectName only where the bridge's list_actions lists 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 are ai.exposed: true. So:

  • @objectstack/mcp: McpActionBridge gains an OPTIONAL member, capabilityRefusal(name, objectName). For an unlisted sys_* 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 declarations listActions reads (domains/mcp.ts). The answer is actionPermissionRefusal (action-execution.ts, new): { code: 'PERMISSION_DENIED', status: 403, message }, the message being actionPermissionError's sentence. invokeBusinessAction now throws its capability refusal with that same code and status, so a scoped call and a by-name call answer identically. This applies to EVERY action with requiredPermissions, not only these 15: MCP run_action's capability refusal used to be a plain message, and it now carries the REST door's 403 and its standard code.

Before → now (for reviewers; the changeset states the same as before/now):

  • MCP list_actions: FROM every caller listed all 15 → TO a caller without the capability is not listed them.
  • POST /actions/:object/:action and MCP run_action (by actionName alone or with objectName): FROM forwarded, target answered 403 PERMISSION_DENIED "Admin role required" → TO the door answers 403 PERMISSION_DENIED with the capability sentence, and nothing is forwarded.
  • MCP run_action's capability refusal for any action with requiredPermissions: FROM a plain message (no code, no status) → TO code: 'PERMISSION_DENIED', status: 403, same message.
  • MCP run_action with objectName on a sys_* action that is NOT exposed to AI: unchanged, the system-object sentence.
  • A caller whose only standing is the legacy 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, that requiredPermissions is non-empty and each name is both in ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions and a scope: 'platform' catalog capability, with a positive control on the two names.
  • packages/runtime/src/platform-admin-action-gate.test.ts (new): the real sys_user declaration through the production MCP bridge and the REST door. A plain member is not listed ban_user/set_user_manager, a platform admin is; the member is refused 403 at REST and at run_action and the forward target is never called; the admin is forwarded; and for every sys_user action 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 listed ban_user; scoped and by-name run_action both answer 403 PERMISSION_DENIED with the identical capability sentence and never "not exposed"; nobody is banned. A new CONTROL: impersonate_user (gated, no ai block), 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. A capabilityRefusal answer 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.ts also pins that by-name run_action throws { code: 'PERMISSION_DENIED', status: 403 }, that capabilityRefusal answers the by-name message exactly, and that it answers undefined for the admin, for impersonate_user (CONTROL) and for an undeclared name.
Ablation (two legs, rebuilt dist, on-disk proof)
  • Leg 1, PLATFORM_ADMIN_USERS_GATE → ['ablation_30771_unheld'] via scripts/ablation-replace.mjs (anchor 1 → 0, blob 0dd52730 → a3e22e53), pnpm --filter @objectstack/platform-objects build, ablation-dist-preflight marker present in 4 dist files. Standing pin: 1 failed / 5. Runtime: 6 failed / 8 (the admin is no longer listed or forwarded).
  • Leg 2, the same constant → [] (blob 0dd52730 → 2e06f41b), rebuilt, manage_users count in dist/identity/index.mjs 0. Runtime: 6 failed / 8, including "a plain member is not listed ban_user or set_user_manager".
  • Restore: ablation-replace --restore (blob == HEAD, git diff HEAD empty), rebuild, ablation-dist-preflight --absent marker absent from 66 files and tree clean; both files green again (6/6, 8/8).

Acceptance notes

  • The target endpoints are unchanged and still judge the rung themselves, so a holder of manage_users without the rung (a custom permission set) is listed and then refused by the target, as every caller was before.
  • The two dogfood files were added to the claim surface (6678953801); their old assertions are what this change makes false.
  • McpActionBridge.capabilityRefusal is a new optional member of a published interface (widening). The run_action capability refusal gaining code and status is additive for a client that reads only the message.
  • The changeset carries adr-0087: not-required (no-migration-prescription): no authored metadata is retired or renamed. Its behaviour list is written as before/now, because a FROM → TO label is read by check-adr-0087-registration as a migration prescription and refuses that disposition; the FROM → TO form is in this body instead.

Verification

Local runs (final head 1764b50)
  • On 1764b50: pnpm --filter @objectstack/mcp typecheck exit 0, pnpm --filter @objectstack/mcp test Test Files 56 passed, Tests 683 passed; pnpm --filter @objectstack/runtime typecheck exit 0, pnpm --filter @objectstack/runtime test Test Files 548 passed, Tests 8770 passed, 19 skipped; dogfood mcp-system-object-action-exposure, api-action-door-forward, parent-read-gate-silent-skip: 28/28.
  • pnpm --filter @objectstack/platform-objects typecheck exit 0.
  • pnpm --filter @objectstack/platform-objects test: Test Files 116 passed, Tests 1584 passed.
  • Earlier, on 42264b4: pnpm --filter @objectstack/runtime test had 1 file / 4 tests failed (api-action-create-user-email-param.test.ts, the fixture above); fixed in ba157b0.
  • Full dogfood (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 --commands on 1764b50: 66 families derived; all 66 run, exit 0. --ran reconciliation: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN. (On 8b33698: 63/63; check-adr-0087-registration went green after the disposition marker, and check:dual-build-cjs-loads after building 7 packages that had no dist.)
  • CI on 8b33698: 49 check-runs, 0 failures.
  • CI runs the rest (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

… 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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/mcp, @objectstack/platform-objects, @objectstack/runtime, touching 13 documentable anchor(s).

22 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json c77dd17f2671f35e8f194ca1e63d8cc75bfb861a.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 222 client-bound route-ledger rows — the other 159 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 159: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 36 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c77dd17f2671f35e8f194ca1e63d8cc75bfb861a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 297afc4b2240b9a61c7a5cccee36e0e81b4accb8 — the merge of head 15a33791d0205cb2f7e49e5e27bc54c7a430aa6b into base c77dd17f2671f35e8f194ca1e63d8cc75bfb861a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c77dd17f2671f35e8f194ca1e63d8cc75bfb861a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4

Contract review for card #30771 · narrowing face (Clause-②: yes (narrowing)) · read-only reviewer at the contract-review tier, brief per record-recognisers --brief-template --face narrowing · 2026-11-06T06:52Z

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8b3369861b34ea14e11486c75beef344c84a648c
Local-runs: none
Scope: packages/platform-objects/src/identity/platform-admin-action-gate.ts, packages/platform-objects/src/identity/sys-member.object.ts, packages/platform-objects/src/identity/sys-oauth-application.object.ts, packages/platform-objects/src/identity/sys-sso-provider.object.ts, packages/platform-objects/src/identity/sys-user.object.ts, packages/platform-objects/src/platform-admin-affordance-standing.test.ts, packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts, packages/qa/dogfood/test/mcp-system-object-action-exposure.dogfood.test.ts, packages/runtime/src/domains/api-action-create-user-email-param.test.ts, packages/runtime/src/platform-admin-action-gate.test.ts + packages/runtime/src/action-execution.ts (actionPermissionError, invokeBusinessAction), packages/runtime/src/domains/actions.ts (REST door), packages/runtime/src/domains/mcp.ts (listActions, runAction, resumeAdmissionRefusal), packages/mcp/src/mcp-http-tools.ts (isListableAction, run_action), packages/core/src/security/resolve-authz-context.ts (§6b, §6b-config, §6d), packages/core/src/security/assemble-execution-context.ts (agent systemPermissions), packages/spec/src/identity/eval-user.zod.ts (ADMIN_FULL_ACCESS_CAPABILITIES), packages/spec/src/security/capabilities.ts (PLATFORM_CAPABILITIES), packages/plugins/plugin-auth/src/platform-admin-gate.ts and auth-manager-before-hook.ts and auth-route-ledger.ts (the target doors), docs/adr/0066 D4, content/docs/ai/connect-mcp.mdx. Face: narrowing (Clause-②: yes (narrowing) on body and changeset; no governed path, no published schema in the diff).

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 main, and the 47 check-runs on the head. Check-runs as read: 23 success, 13 skipped, 11 in progress (Build Core, Test Core 1-4 and 6-8, Type Check consumer gates and workspace, Live E2E UI); no failure. Check Changeset, Governed Surface Queue Guard, Lint & Repo Gates and Lint · PM dispatch-gates are green.

① Derived judgments

  1. The population is the family, no door missed — right. platform-admin-affordance-standing.test.ts on main derives the family as every first-party action whose target is in /api/v1/auth/admin/ minus has-permission and stop-impersonating, plus /api/v1/auth/organization/add-member. A grep of every target: under packages/**/*.object.ts on main finds exactly 15 such targets: sys_user 8 (ban, unban, unlock, create-user, set-user-password, impersonate, set-user-manager, list-platform-admins), sys_member.add_member, sys_oauth_application 2 (toggle-disabled twice), sys_sso_provider 4. The test's floor list names 14; the 15th (list_platform_admins) joins by its target. Every other /organization/* target (invite, remove-member, update-member-role, create, delete, teams) is judged by the vendor's org-role or a before-hook guard, not by the platform-admin judge, so none belongs here. Nothing outside the diff declares one of these targets.
  2. Each of the 15 is really a platform-admin door — right. The raw /admin/* mounts, set-user-manager, list-platform-admins, impersonate and the oauth2/toggle-disabled mount call judgePlatformAdmin (plugin-auth platform-admin-gate.ts); /sso/register and register-saml are judged by the before-hook's isPlatformAdminUserId; organization/add-member is recorded in auth-route-ledger.ts as "platform-admin gated (ADR-0068)". The visible predicate on all 15 already reads the rung.
  3. The two gate names are right, and a rung holder keeps every one of the 15. manage_users and manage_platform_settings are both in ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions and both scope: 'platform' in PLATFORM_CAPABILITIES. Both anchors of the rung put that list on the holder: the grant anchor via the catalog body of admin_full_access folded at §6b (a code-package set, not_overridable), the config anchor via the explicit push at §6b-config. A deactivated admin_full_access confers neither the rung nor the caps, so hidden-and-refused stays consistent. The new standing-test case pins every declared name against both lists, with a positive control on the two names. The users/settings split is cosmetic to the gate (every rung holder holds both) and reads well as documentation.
  4. Accept-set changes, each named. MCP list_actions: from every authenticated caller to holders of the capability (the actionPermissionError filter at mcp.ts line 878 already exists on main; the diff adds no runtime code). POST /actions/:object/:action, MCP run_action by name, resume_run: from forwarded-then-target-403 to door-403 with the capability sentence, nothing forwarded. MCP run_action with objectName: tool-layer system-object refusal (see ④ S1). A caller whose only standing is the legacy role === 'admin' scalar: from admitted at the target to refused at the doors (judged in ④). Console: unchanged, because visible already reads the rung and the D4 UI half (useConsoleActionRuntime) reads requiredPermissions fail-open; the changeset's "as before" claim holds.
  5. Agent principals — no new class refused. assemble-execution-context.ts gives an agent the delegating user's whole systemPermissions under actions:execute and [] without it; without it the forward was already refused by apiForwardCredentialRefusal rule 1. The dogfood pins for the admin's agent (E and confirmed C) are unchanged and reported green.
  6. Public surface — none moves. identity/index.ts is untouched, so the two constants are package-internal; the only published package the diff moves is @objectstack/platform-objects. api-action-create-user-email-param.test.ts's fixture now holds the rung's capabilities, which is what its docblock already claimed the caller was.
  7. Residual named, not a narrowing. A holder of manage_users without the rung (a custom set, or admin_full_access under a walled posture where §6b confers the set's caps but not standing) is listed and then refused by the target, exactly as before this PR. The acceptance notes say so; it is the inherent limit of a capability standing in for a rung no key can name, and the ruling's "list only what the caller can run" is met for every first-party standing.

② Semver level

'@objectstack/platform-objects': patch is right: Metadata-protocol: none (an existing ADR-0066 D4 key, no spec change, no authorable surface added or removed), and a non-protocol breaking change ships patch with its BREAKING banner. The PR body and the changeset both carry Clause-②: yes (narrowing) and Metadata-protocol: none line-initial; the banner **BREAKING.** is present; the title carries !; exactly one ADR-0087 marker (not-required (no-migration-prescription)), which is right because nothing an author wrote is retired or renamed; the one upgrader action (a grant or OS_PLATFORM_OWNER_EMAIL) is stated in the changeset's third bullet, which ships in CHANGELOG.md. No skip-changeset. Check Changeset is green. The expected shape (Clause-②: yes (narrowing), Metadata-protocol: none, BREAKING banner, ! title) is met.

③ Boundary flags

  1. Two dogfood files outside the claim surface — accepted. They are the standing pins of exactly the two doors this narrowing changes, and their old assertions ("passes the door and is refused by the identity route") became false by the ruling's own intent. Editing the pin of the behaviour one changes is the Post-Task rule, not scope creep. Both rewrites keep the safety half (nobody banned, status 403 on REST, anonymous 401).
  2. MCP run_action with objectName answers the system-object sentence, not the capability sentence — escalated as should-fix S1 in ④. The dev's "accurate but less precise" is not accurate: the sentence says the action "is not exposed via MCP" and names ai.exposed, which is false for ban_user (ai.exposed: true); the real cause is the caller's grant.
  3. Before/now prose instead of a literal FROM → TO — accepted. check-adr-0087-registration reads that label as a migration prescription and would refuse the not-required disposition; the migration content (grant or env var) is in the changeset in plain words, and the FROM → TO form is in the PR body. The marker's reasoning is right.
  4. invite_user declared four times — accepted. Four distinct (objectName, name) addresses on one target, /organization/invite-member, which is not a platform-admin door and so is outside this narrowing; run_action refuses an ambiguous bare name. Correctly measured and left alone.

④ Design judgment

The narrowing against the published spec on main. The spec's platform-admin standing is the rung: ADR-0095 D3 and resolve-authz-context.ts §6d resolve the posture "from held CAPABILITY grants — never from a better-auth role"; ADR-0068 D2 derives platform_admin from the anchoring admin_full_access grant; plugin-auth platform-admin-gate.ts keeps user.role === 'admin' "ONLY as the legacy fallback it has always been here", re-synthesis vetoed by the maintainer on 2026-08-18. ADR-0066 D4 (amended 2026-10-29) says the platform action door runs the declared-capability gate before forwarding and the target must still enforce itself, "the door's gate being the added check: defence in depth, not a replacement". So:

  • The legacy role === 'admin' caller without the rung: the spec's own refusal made real, not a taken-away capability. That caller holds no declared capability anywhere in the spec; it is admitted at the vendor-adjacent targets by a back-compat fallback the gate's own header calls legacy. The console already denies it these buttons (visible reads the rung, which the session emits from grants). What this PR closes is the one path where a declared gate was absent and the fallback leaked through: REST /actions and MCP. The target endpoints keep the fallback, so the deployment is not locked out, and the changeset names the one-line fix. This is the ruling's intent as the seat's claim amendment (6677709499) stated it before the dev built: "A legacy role==='admin' caller without the platform-admin rung is now refused at the door. BREAKING banner; the maintainer's per-PR word is owed before landing." Judged right. The maintainer's word on the BREAKING is still owed and is not this record's to give.
  • One reader, one ec, listed if and only if runnable at the gate — holds. listActions (mcp.ts line 878), runAction → invokeBusinessAction (action-execution.ts line 4323), resumeAdmissionRefusal (mcp.ts line 1324) and the REST door (actions.ts line 652) all call actionPermissionError on the same declaration with the same assembled context; the listing omits objectName from the call, which changes only the sentence, never the verdict. The gate runs before the type branch, the param contract and the record load on every door. The new runtime test pins the agreement per action for two callers against the real sys_user declaration through the production bridge and handleActions, with the forward target faked so "never forwarded" is observed, not inferred. Pre-existing non-agreements (a disabled action is listed and refused 409; a credential-refused forward is listed with unavailable) are unchanged by this diff.
  • Tried and not broken. isSystem bypass: a system caller passes the door and the target judges, as before. Deactivated anchor set: no caps and no rung, consistent. Walled posture: caps without rung is the residual in ①.7, as before. Handler-owner gate (action-execution.ts line 5172): irrelevant for type: 'api'. The standing test derives from source files, so the "every member declares" pin cannot be satisfied vacuously; its positive control fails if either name leaves ADMIN_FULL_ACCESS_CAPABILITIES.

S1 — should-fix: the scoped MCP refusal names a false cause. packages/mcp/src/mcp-http-tools.ts run_action (lines 1695–1703) admits a sys_* action with objectName only where the bridge's listing lists it; since the listing is now permission-filtered, a plain member calling run_action with objectName: 'sys_user' is answered systemObjectActionRefusal: "is not exposed via MCP: a system object's action is reachable only when its author exposed it to AI (ai.exposed) and list_actions lists it for you". For the 15 actions this is untrue (all are ai.exposed: true) and it points the agent at the author instead of at the caller's grant; a refusal that misnames its cause is the wrong-prescription class the refusal-prose rule and Route & surface ownership rule 4 exist for. The shape predates this PR, but this PR is the first to route a whole family through it and pins the sentence in mcp-system-object-action-exposure.dogfood.test.ts. The dev reported it "noted, not filed"; a reproducible wrong-cause refusal is Prime Directive 10's file-an-issue class, and packages/mcp/src/** is inside the claim's file surface, so either an in-PR fix (ask the bridge whether the action is unlisted for exposure or for permission, and answer the capability sentence with 403 in the second case) or a filed card is owed before landing. Not a FAIL: the call is refused with nothing forwarded, the by-name path answers the capability sentence with its status, and the ruling's substance (one declaration, read by the listing and both doors) is delivered.

No must-fix found.

Implemented-by: claude/project-thread-5uh3yy
Reviewed-by: session_016WV4czvZykUqhYxYXSSDt4

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
@github-actions github-actions Bot added size/l and removed size/m labels Nov 6, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4

Delta re-review for card #30771 over record 6679092098 (PASS on 8b336986, S1) · narrowing face · read-only reviewer at the contract-review tier · 2026-11-06T07:45Z

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1764b50e8474d94eaa7a521dd97ce3e33bb52018
Local-runs: none
Scope: packages/mcp/src/mcp-http-tools.ts, packages/mcp/src/mcp-action-system-object-exposure.test.ts, packages/runtime/src/action-execution.ts, packages/runtime/src/domains/mcp.ts, packages/runtime/src/platform-admin-action-gate.test.ts, packages/qa/dogfood/test/mcp-system-object-action-exposure.dogfood.test.ts, packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts, packages/platform-objects/src/identity/platform-admin-action-gate.ts, packages/platform-objects/src/identity/sys-member.object.ts, packages/platform-objects/src/identity/sys-oauth-application.object.ts, packages/platform-objects/src/identity/sys-sso-provider.object.ts, packages/platform-objects/src/identity/sys-user.object.ts, packages/platform-objects/src/platform-admin-affordance-standing.test.ts, packages/runtime/src/domains/api-action-create-user-email-param.test.ts + packages/runtime/src/domains/actions.ts (REST door, line 652), packages/runtime/src/http-dispatcher.ts (error), packages/runtime/src/error-envelope.ts (buildApiError), packages/spec/src/api/errors.zod.ts (StandardErrorCode, HttpStatusErrorCodeMap, standardErrorCodeForHttpStatus), packages/spec/src/api/error-code-ledger.zod.ts (owner keys @objectstack/runtime and @objectstack/mcp, the provenance rule), packages/spec/scripts/check-error-code-provenance.ts, scripts/check-error-status-conformance.mjs, scripts/check-dispatcher-error-vocabulary.mjs, content/docs/api/error-catalog.mdx (the 403 row), .github/workflows/ci.yml (dogfood, dogfood-gate), packages/qa/dogfood/test/parent-read-gate-silent-skip.dogfood.test.ts and packaged-action-disabled-dispatch-live.dogfood.test.ts and mcp-oauth-api-action-forward.dogfood.test.ts (reach of the delta). Face: narrowing (Clause-②: yes (narrowing) on body and changeset; no governed path, no published schema in the diff).

Delta record over 6679092098 (PASS on 8b3369861b34ea14e11486c75beef344c84a648c, one should-fix S1). Inputs read: the compare 8b336986...1764b50e (one commit, dated 2026-11-06, seven files: the changeset, mcp-http-tools.ts and its exposure test, action-execution.ts, domains/mcp.ts, the runtime gate test, the MCP dogfood file), the net diff against main (15 files), card #30771 with all six comments (no comment after the claim 6678953801; ruling A 6674432613 and the seat analysis 6671252245 re-read), the rewritten PR body, the docs-drift comment 6678866422, and the check-runs on the new head. Check-runs as read, second read: 51 total, 26 success, 15 skipped, 10 in progress (Test Core 1-4 and 6-8, Build Core, Type Check workspace and consumer gates); no failure. Green: Check Changeset, Governed Surface Queue Guard, Lint & Repo Gates, every Lint lane (ESLint, docs & code guards, repo & tooling gates, CI/release/engine gates, PM dispatch-gates, the three scoped lanes), Type Check debt ledger and source gates, Spec property liveness, Test Core 5/8, Test UI, Live E2E UI, the PR-body guards, and the aggregate Dogfood Regression Gate (see ④ S2 for what that one attests). The unchanged findings of the prior record (①.1 to ①.7, ③.1, ③.3, ③.4, and ④'s judgment of the narrowing against the published spec) are carried forward as they stand; the delta touches none of the declarations, the REST door, the listing filter or the standing test they were made on.

① Derived judgments

  1. S1 is closed: scoped and by-name answer one refusal, nothing runs. run_action (mcp-http-tools.ts lines 1712-1725) enters the pre-screen only for objectName on a sys_* object and only when the listing does not admit the pair; there it asks bridge.capabilityRefusal?.(actionName, objectName) and renders a non-undefined answer through errorResultWithCode, else the system-object sentence; both returns precede bridge.runAction. The runtime bridge (domains/mcp.ts lines 912-923) answers from collectActionDeclarations over the same getMeta, readServedObjects, readServedActions the listing reads, finds the one (objectName, name) declaration, returns undefined for not-declared, for systemObjectActionExposureError and for actionAiExposureError, and otherwise actionPermissionRefusal(deps, found.action, ec, objectName). The sentence is actionPermissionError with objectName, which is exactly what invokeBusinessAction passes by name (line 4346, the resolved objectName), so the two strings are one function's output on one declaration and one ec. Pinned three ways: the runtime test holds refusal.message === thrown.message per pinned action with the forward never called; the MCP unit test renders the bridge's answer as { code: 'PERMISSION_DENIED', status: 403, message } with bridge.runs empty; the dogfood holds messageOf(scoped) === messageOf(byName), both PERMISSION_DENIED 403, neither matching "is not exposed via MCP", and isBanned === false. The CONTROL unexposed action keeps the sentence: impersonate_user declares requiredPermissions: [...PLATFORM_ADMIN_USERS_GATE] (sys-user.object.ts line 487) and no ai block, so the bridge returns undefined at the system-object check and the tool answers the system-object sentence; pinned in the runtime test (undefined) and the dogfood (the sentence). The prior record's prescription ("ask the bridge whether the action is unlisted for exposure or for permission, and answer the capability sentence with 403 in the second case") is implemented as written.
  2. The new wire shape is not a vocabulary widening; it is the surface's existing coded-refusal carrier applied to one more refusal. Before: invokeBusinessAction threw new Error(gateError); errorResultFromThrown found no code and answered the bare sentence. Now the throw carries code: standardErrorCodeForHttpStatus(403) and status: 403, and errorResultFromThrown (line 469) routes PERMISSION_DENIED through errorResultWithCode to codedErrorResult, the { error: { code, message, status } } text the tool already answers for ACTION_CONFIRMATION_REQUIRED 428, ACTION_DISABLED 409 and the record-level security gate's own PERMISSION_DENIED (that arm exists on main). PERMISSION_DENIED is a StandardErrorCode member and HttpStatusErrorCodeMap[403] (errors.zod.ts lines 79 and 186); it has no row under any ledger owner key (the only mentions in error-code-ledger.zod.ts are a comment at line 1491 and the FORBIDDEN waiver's shadows), and check:error-code-provenance sweeps "every stamp site of a REGISTERED code" (ledger header, the [finding] error-code ledger: five more packages stamp a registered code with no provenance row — the #13254 class, swept #13353 paragraph), so it demands no row for @objectstack/runtime or @objectstack/mcp. The spelling code: standardErrorCodeForHttpStatus(403) already stands twice in action-execution.ts on main (lines 1832 and 1847) and domains/mcp.ts line 1010 stamps the literal, under a green Lint · repo & tooling gates on this head, which carries the dispatcher-vocabulary gate. check:error-status-conformance direction A asks that every emitted status for a code be documented: error-catalog.mdx publishes 403 for PERMISSION_DENIED (the quick-reference row at line 999), and the deriver reads the rest and types error classes, the sendError doors and the map, not a runtime object literal; the gate is green on this head. The changeset declares the shape in its own paragraph and bumps @objectstack/runtime and @objectstack/mcp patch, with Metadata-protocol: none and the single Clause-② arm kept (AGENTS.md Post-Task 3: at most one arm; the narrowing is the breaking half and dominates, the additive half is named in prose). The reach is every action with requiredPermissions, not only the 15, which the changeset and the PR body both say.
  3. Within ruling A and the claim surface. Ruling A: "REST /actions and MCP read it from one place"; the delta adds no second reading, it makes the MCP tool layer's scoped refusal read the same actionPermissionError the other three doors read. The files are action-execution.ts (named in the claim), mcp-http-tools.ts (packages/mcp/src/**, named), and domains/mcp.ts, the runtime half of the MCP list_actions bridge the claim's premise names (list_actions uses actionPermissionError) and the file the prior record already scoped for listActions, runAction and resumeAdmissionRefusal. The S1 fix cannot land without it. Judged inside the claim as the seat worded it, with ③.2 naming the literal gap.
  4. The REST door still agrees in code, status and sentence. actions.ts line 652 is untouched by the delta: deps.error(gateError, 403) with no explicit code goes through HttpDispatcher.error to buildApiError, whose code is input.code ?? promoted ?? standardErrorCodeForHttpStatus(403) = PERMISSION_DENIED, status 403, message = actionPermissionError(deps, actionDef, executionContext, objectName). Three doors, one function, one object name. resume_run was already there: resumeAdmissionRefusal wraps the same sentence in resumeDoorError(deps, permission, 403), which reads the dispatcher's envelope back and so carried PERMISSION_DENIED 403 on main; after the delta the four doors (REST, run_action by name, run_action scoped, resume_run) answer the gate with one code, one status and one sentence.
  5. Tried and not broken. A bridge without the member: bridge.capabilityRefusal?.() is undefined and the tool answers the system-object sentence, the [mcp: run_action refuses every action on a sys_* object, so an agent cannot reach the identity actions (ban_user, impersonate_user, set_user_password) that REST POST /actions serves #27681] cases over makeBridge (no member) run unchanged and pin it. A listed action: admitted is true and the branch is never entered; CONTROL case 9 pins asked empty and the run. An action declared on two objects: the listing keys on (a.name, a.objectName), the bridge on (d.objectName, d.action.name), and collectActionDeclarations dedupes per objectName:name with the object-embedded declaration winning, so each scoped call reads its own object's declaration; the by-name ambiguity refusal is untouched. An agent principal: listActions, capabilityRefusal and runAction close over the one ec the bridge was built with; an agent's systemPermissions are the delegating user's under actions:execute (prior ①.5), and the dogfood drives the member's agent on both paths. impersonate_user: ①.1. A sys_* action not ai.exposed: undefined at the first bridge check, the sentence stays; a stored-metadata-body object takes the same path and keeps the tool's generic sentence, as before this PR.
  6. Every run_action dogfood the delta reaches was re-run. The capability sentence appears in exactly one other dogfood file, parent-read-gate-silent-skip (read off a log line, not an MCP answer), and the system-object sentence in none other; the remaining run_action dogfood callers assert ACTION_DISABLED, the api-forward unavailable refusals and REST bodies (packaged-action-disabled-dispatch-live acceptance[3] drives REST, not the tool), none of which the delta touches. The dev reports those three files 28/28 and the whole runtime and mcp packages green on 1764b50; the rule's letter on the full battery is ④ S2.

② Semver level

'@objectstack/platform-objects': patch unchanged and right (prior ②). The two added lines, '@objectstack/runtime': patch and '@objectstack/mcp': patch, are right: Metadata-protocol: none, and AGENTS.md Post-Task 3 says "a non-protocol change, widening or breaking, ships patch". The new optional member McpActionBridge.capabilityRefusal on a published interface and the two new runtime exports (actionPermissionRefusal, ActionPermissionRefusal) are additive; the run_action error carrier change is additive for a client that reads the message, which the dogfood's own messageOf demonstrates. The body and changeset carry Clause-②: yes (narrowing) and Metadata-protocol: none line-initial, the **BREAKING.** banner, the ! title, exactly one ADR-0087 marker (not-required (no-migration-prescription)), and the changeset now names the carrier change for both packages. Check Changeset is green.

③ Boundary flags

  1. Two dogfood files outside the original claim surface — accepted (prior ③.1; the claim 6678953801 has since added them to the surface).
  2. packages/runtime/src/domains/mcp.ts is not spelled in the claim's file list — accepted. The list names action-execution.ts, domains/actions.ts, packages/platform-objects/** and packages/mcp/src/**; the runtime MCP bridge is the half of "MCP list_actions" that reads the declaration, the ruling's premise is written about it, and the prior record scoped it. The seat should read its own "MCP list_actions" as covering the bridge; no second reading of the gate was added there.
  3. Before/now prose instead of a literal FROM → TO — accepted (prior ③.3; the rewritten body carries the FROM → TO form, including the new carrier row).
  4. invite_user declared four times — accepted (prior ③.4; all four are exposed and ungated, so the pre-screen never asks for them).
  5. The bridge's capabilityRefusal re-checks exposure but not isHeadlessInvokableAction or the handler probe — accepted as a named residual. An exposed, gated action the listing omits for being non-invokable (a flow action with no automation service, a script with no bound handler) and whose gate also refuses the caller would be answered the gate's refusal scoped, while by name invokeBusinessAction answers "cannot be invoked via MCP" first. Both causes are true, the scoped answer discloses nothing beyond the caller's own missing capability (the same precedence the activation consult already keeps: gate before anything an unentitled caller must not learn), and no first-party action occupies the cell: all 15 are type: 'api' with a target, which both checks pass. Noted, not owed.

④ Design judgment

The narrowing against the published spec on main — unchanged from the prior record. The declarations, the listing filter, the REST door and the standing test are byte-identical to the prior head; the legacy role === 'admin' reading, the one-reader agreement and the residual holder-without-rung stand as judged there. The maintainer's per-PR word on the BREAKING is still owed and is not this record's to give.

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 (impersonate_user) is pinned at two. The refusal-prose rule and Route & surface ownership rule 4 are met for this family.

The carrier change, judged as its own change. Adding code and status to every run_action capability refusal is the #23509 class (the data tools' catches gained the same pair) and the right direction for an AI surface: an agent branches on error.code, and a refusal that was prose alone was the wrong-prescription class one notch down. It introduces no code, no status and no envelope the surface does not already answer, demands nothing of the ledger, the provenance gate or the status gate (①.2), and is declared in the changeset for both packages. Not a Clause-② widening of a vocabulary; an additive move inside an existing contract, correctly shipped patch.

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 pull_request and merge_group event by design (ci.yml, the dogfood job's if:), and dogfood-gate "with both legs skipped counts zero expected attestations and concludes success": on a PR that lane attests nothing about the suite, and the queue build does not run it either, so main's next push run will be the first full battery over this code. AGENTS.md Post-Task 10 asks for "dogfood once, on the final head". The dev's full run is on 64f9d94, before the S1 commit; on 1764b50 only the three reached files (28/28) plus the whole runtime and mcp packages ran. On the measured reach (①.6) the scoped run covers the delta, which is why this is not a FAIL; the rule's letter still owes one ~8-minute full run on 1764b50 (or on whatever the final head turns out to be) before auto-merge is armed, and the seat should record it in the PR body's Verification block.

No must-fix found.

Implemented-by: claude/project-thread-5uh3yy
Reviewed-by: session_016WV4czvZykUqhYxYXSSDt4

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4czSnPb3bu4Q5weHEpr

CI red on 1764b50e: Test Core (4/8), not this PR's.

  • What failed: one test in @objectstack/cli, test/released-port-production-spawn.pin.test.ts > "the census reaches the live tree: every way of spawning the CLI, and the draw, are found" — Test timed out in 5000ms (job 127773170008, run 42181698191). The other 3360 cli tests passed; the shard's other six packages passed.
  • Why it is not this PR's: the census walks only packages/cli/test/** (liveTree() reads every .ts under the test's own directory), and this PR changes no file under packages/cli/ (git diff --name-only origin/main...HEAD | grep -c '^packages/cli/' = 0). It is a wall-clock budget on a synchronous file scan: the cli package ran 834 s on that shard (1830 s of test time across workers), and the same file passes here, npx vitest run test/released-port-production-spawn.pin.test.ts: Test Files 1 passed, Tests 4 passed, tests 3.37 s, measured while the full dogfood battery was running on the same box — so it sits within 1.6 s of its 5 s budget even off CI.
  • Fix: none exists yet. The proportionate patch is an explicit timeout on that census case (or caching liveTree() across the three cases that each re-walk the tree, lines 342 / 366 / 386); it belongs to a cli test-budget change (test budget (domain:cli): 119 test file(s) over 10 s of wall in the hourly full battery (Part of #23151) #23181 is the open budget issue for domain:cli), not to this PR.
  • Next: one re-run of the failed job, once run 42181698191 completes; a second failure would be treated as real.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4

Flake judgment · 2026-11-06T07:59Z · run 42181698191, Test Core (4/8) on head 1764b50e. The single failure is packages/cli/test/released-port-production-spawn.pin.test.ts › "the census reaches the live tree", a 5000 ms timeout. The other 3360 cli tests passed. The census walks only packages/cli/test/**, and this PR touches no file under packages/cli. Locally the file passes 4/4 in 3.37 s under load, close to its budget (dev comment 6680122417). The seat judges it a timing flake and re-runs the failed jobs once with rerun-failed.mjs; a second failure is real.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4

维护者速读

  1. 改了什么:15 个平台管理动作现在都声明了门槛:用户管理类动作声明 manage_users,OAuth 和 SSO 类声明 manage_platform_settings。MCP list_actions 只列调用者能跑的动作;REST /actions 和 MCP run_action(按名或带 objectName)在入口按同一份声明拒绝,返回 403 PERMISSION_DENIED 和同一句话,什么都不转发。另外,run_action 的所有能力拒绝现在都带 code 和 status,以前只有一句文字。
  2. 为什么改:Console and CLI polish from the real-user walkthrough #30126 第 16 行,裁定 A(6674432613)。
  3. 风险:中。这是破坏性改动:只靠旧 role === 'admin'、却没有平台管理员身份的调用者,现在在入口就被拒绝。改法是授予 admin_full_access,或设置 OS_PLATFORM_OWNER_EMAIL。控制台不受影响,那些按钮本来就按管理员身份显示。
  4. 先看:packages/platform-objects/src/identity/platform-admin-action-gate.ts;packages/mcp/src/mcp-http-tools.ts 的 run_action 预检。
  5. 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/platform-admin-action-gate.test.ts。
  6. 需要你做的:在席位线程的决策卡上点头(破坏性)。点头后由席位入队落地。不属 Tier H,510 行 ≤ 3000。

席位意见:ACCEPT(#30771)。

席位核对

Head-sha: 1764b50e8474d94eaa7a521dd97ce3e33bb52018 · read 2026-11-06T08:21Z

Item Reading
Shape Draft → main, Fixes #30771, title fix(platform-objects)!:. 15 files, +496/−14. Branch claude/project-thread-5uh3yy; the whole claim was re-posted (6678953801) with the two dogfood files.
Seat diff read Fifteen first-party actions with platform-admin targets declare requiredPermissions through one module. Both capability names are in ADMIN_FULL_ACCESS_CAPABILITIES, so every holder of the rung keeps all 15. The listing, REST run, run_action and resume_run all read actionPermissionError on the same declaration. The S1 fix adds the optional McpActionBridge.capabilityRefusal, so the scoped call answers the same 403 sentence as by-name, and an unexposed sys_* action keeps the system-object sentence.
Evidence os-dev-report 6678862496 and addendum 6680380074. Full dogfood on this head: 423 files / 3534 tests, command-exit 0. mcp 683/683, runtime 8770 passed, dispatch-gates 66/66. Contract review PASS 6679092098 on 8b336986 (S1). Delta re-review PASS 6679925198 on this head (S1 closed; S2, the full dogfood on the final head, is done).
Gates (check-runs on this head) 53: 38 success, 15 skipped, 0 failed. Test Core (4/8) failed once on a cli census timeout outside this diff; the seat recorded it as a flake and re-ran it once, and attempt 2 passed. mergeable_state clean.
Accepted deviations packages/runtime/src/domains/mcp.ts (the runtime half of MCP list_actions) is read as inside the claim's "MCP list_actions". Before/now prose stays in the changeset; FROM → TO is in the body, because the ADR-0087 gate reads the label as a prescription.
Out of scope invite_user is declared four times, on distinct (objectName, name) addresses with one non-admin target: measured, left as is. A holder of manage_users without the rung is listed and then refused by the target, exactly as before.
Semver @objectstack/platform-objects, @objectstack/runtime, @objectstack/mcp: patch. Clause-②: yes (narrowing), Metadata-protocol: none, BREAKING banner, ADR-0087 not-required (no-migration-prescription).
Landing Not governed (check-governed-merges --pr 30849: NOT governed), 510 lines ≤ 3000, BREAKING. ⛔ Not queued until the maintainer gives the per-PR word. After that, the seat lands it with pr_ready + automerge_enable on this head.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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 1764b50e (card #30771: platform-admin actions gated by capability at the REST and MCP doors). CI is green on that head and the seat ACCEPT stands, so the seat lands it now with pr_ready + automerge_enable.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 6, 2026 15:03
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 6, 2026 15:03
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 6, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 6, 2026
…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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4

Seat note · 2026-11-06T16:46Z · head moved from 1764b50e to 15a33791d0 after a merge-queue ejection. The queue's lint lane had started running check:test-source-alias (added on main by #30969), and its source-reach half flagged the two dispatcher tests. The seat read the delta: 5ea5037015 is a clean merge of main at c77dd17f26, and 15a33791d0 adds one module-top import '@objectstack/metadata'; to each of platform-admin-action-gate.test.ts and api-action-create-user-email-param.test.ts, the gate's own prescription. Test-only, no source line, so no contract re-review. The ACCEPT, the contract review PASS 6679925198 and the maintainer's BREAKING word (6687900960) carry. CI on 15a33791d0: 36 success, 13 skipped, 0 failed. The seat re-arms auto-merge on this head.

@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 42257589439 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Dogfood Regression Gate (3/3) — 失败步骤: Boot example apps and exercise real user flows

    @objectstack/dogfood:test:  FAIL   isolated  test/admin-platform-admin-standing.dogfood.test.ts > #9482: what an ObjectStack platform admin gets from every /admin/ route > no /admin/ route refuses the
      ↳ 失败原因: @objectstack/dogfood:test: AssertionError: the stack serves /admin/ route(s) this file neither admits, refuses-by-design, nor classifies as answering for a non-authorization reason. A new admin route 
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 17 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4czSnPb3bu4Q5weHEpr

Queue build 42257589439 red: Dogfood Regression Gate (3/3), not this PR's.


Generated by Claude Code

Merged via the queue into main with commit 7569fb5 Nov 6, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-5uh3yy branch November 6, 2026 17:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

actions declare their gate once, and REST /actions and MCP list_actions read it: list only what the caller can run (#30126 row 16, ruling A)

1 participant