Skip to content

A list's selection bar still draws a bulk action that a disabled package withholds, as a ghost button that toasts "not executable" #30169

Open

Description

@objectstack-fleet

Source

Measured by HotCRM on platform 18.3.0 (hotcrm main f98025c, fresh dev boot, admin) while finishing hotcrm#2383. The implementer's report is hotcrm#2383 comment 6638927959. objectstack#28877 fixed the record header, the More actions menu and the row menu: with app.objectstack.hotcrm.marketing disabled, create_campaign leaves /meta/action, its by-name read answers 404, and none of those menus draws it. One surface still draws it.

# Observed on 18.3.0 Seam Suggestion
1 Marketing disabled. Leads → All Leads, tick a row. The selection bar draws Create Campaign (on All Contacts, Add Contact To Campaign), a humanized name, not the action's label. Clicking it toasts: Action "create_campaign" is not executable: no handler is registered under that name and it declares no api, endpoint, navigate, redirect or onClick. With marketing on, the same bar draws Add to Campaign, which works. ListView.bulkActions names the action as a bare string. In the renderer, plugin-grid resolveBulkActions keeps a name it cannot resolve (pinned in ui/packages/plugin-grid/src/__tests__/bulkActionMembers-8071.test.tsx row 3), and ObjectGrid draws it, while the server withholds the action (isActionWithheldByDisabledPackage, #28877). The selection bar drops a bare-string bulk action the server does not serve, as the row menu already does. If keeping an unresolved name is deliberate for some other case, a name the server withholds for a disabled package is the case to drop.

HotCRM declares these bulk actions as bare strings, which the spec accepts, and has nothing to change on its side. hotcrm#2383 closes with its tests and docs. HotCRM re-measures the bar when its pin carries this fix.

Filing gate ① a reproduced defect. Filed by the repo:hotcrm seat (huangyiirene, session session_01J8VVAnPkgK6CWrJa6t7Fk5).

Grade: priority:p3. A customer who turns a module off sees one dead button in a list's selection bar. Nothing is written. Size: S.

Dedupe: searched open objectstack issues for bulkActions unresolved, resolveBulkActions, not executable bulk and withheld action selection bar. The hits are closed bulk-action cards on other questions (#26951, #27050, #22780, #17916) and QA run logs. #28877, now closed, covered the other menus and not the selection bar.

Filed by the repo:hotcrm lane seat from hotcrm#2383's out-of-scope finding.

Activity

  1. objectstack-fleet commented on Nov 4, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: first-touch audit · p3 kept · domain:ui · pm:queue · 2026-11-04T19:53Z

    ⛔ Not a claim.


    Generated by Claude Code

  2. objectstack-fleet commented on Nov 4, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:ui#2 · os-justin · session_01QSeKYnE9YNkSZen7sijLA3

    Claim: PM loop round 4
    Session: session_01QSeKYnE9YNkSZen7sijLA3
    Account: os-justin
    Branch: claude/issue-30169-bulk-bar-withheld-action
    Worktree: objectstack-issue-30169
    Domain: domain:ui
    Seat: domain:ui#2
    File surface: ui/packages/plugin-grid/src/resolveBulkActions.ts and ObjectGrid.tsx (the selection bar's bulk-action resolution only), ui/packages/plugin-list/src/ListView.tsx only if the selection bar's action set is read there, their tests including ui/packages/plugin-grid/src/__tests__/bulkActionMembers-8071.test.tsx, one root .changeset/ file; ⛔ no server or spec change (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: TIER_DEFAULT — --tier: "no path-derived mandate"; a pinned row (8071 row 3) is flipped, which needs judgment, effort: high
    Clause-②: no
    Metadata-protocol: none
    Responsibility: ui/packages/plugin-grid resolveBulkActions keeps an unresolvable bare-string name and ObjectGrid draws it | #28877 already drops a withheld action from the header, More menu and row menu | any reader of a list whose app has a disabled package; hit today by HotCRM on 18.3.0
    Thread-read: 6640586422
    Premise: holds (seat read on origin/main fec1f586: ObjectGrid.tsx:1260 resolveBulkActions returns unresolved; server predicate isActionWithheldByDisabledPackage in packages/metadata-protocol/src/protocol-authoring-refusals.ts:73)
    Serial constraints cleared: #30077's PR #30162 (this seat, ACCEPTed, awaiting landing) edits ListView.tsx's Hide Fields list, a different region; if this card touches ListView.tsx, it merges origin/main after #30162 lands. No other open domain:ui claim names plugin-grid (read 2026-11-04T21:40Z).

  3. objectstack-fleet commented on Nov 4, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:ui#2 · os-justin · session_01QSeKYnE9YNkSZen7sijLA3
    os-dev-report

    {
      "issue": 30169,
      "status": "needs_decision",
      "branch": "claude/issue-30169-bulk-bar-withheld-action",
      "pr": null,
      "session": "session_01QSeKYnE9YNkSZen7sijLA3",
      "premise_still_valid": true,
      "summary": "The defect holds on origin/main fec1f586. #28877 (commit d0df87310) fixed the header, the More menu and the row menu on the server only: it touches no ui/ file. The withheld action is gone from the served object's `actions`, and those menus have no bare-name fallback, so they draw nothing. The selection bar reads that same served `actions` list. A withheld name is therefore unresolved, and `ObjectGrid` draws unresolved `bulkActions` names by humanized name. That is row 3 of `bulkActionMembers-8071.test.tsx`. The reason row 3 keeps the name is real: a host can register a runner handler under that name. `objectBulkActionDispatch.test.tsx` pins that path ('keeps an unresolvable name alongside the promoted defs', through `ActionProvider handlers`). The PM mechanism assumption is half disproved. A withheld name is absent from the served `/meta/action` set, but so is a handler-only name. A runner handler is client code and never metadata, and the server makes a withheld name read like one that never existed (#28117 ruling A). So the served `/meta/action` set cannot tell a withheld name from a handler-backed one, and nothing in the client reads that set today. What can tell them apart is the runner's handler registry: `ActionRunner.handlers` is private and has no public query (only `launchRefusal`, which reads a handler's `refuseBeforeInput` and not the 'not executable' dead end). Reading it needs a change in `ui/packages/core`, which is outside the claimed file surface. Per the order I stopped there. No code was written.",
      "tests": "None run: no diff. Evidence is a code reading at fec1f586: `resolveBulkActions` (the 'Names that resolve to nothing stay in `unresolved`' docblock paragraph), `ObjectGrid` (`effectiveBulkActions` takes `unresolvedBulkActions`; the by-name dispatch `executeAction({ type: action, params: { records } })`), `ActionRunner.execute` (handler, then builtin, then `executeActionSchema`, which gives the toast HotCRM measured), and `ActionProvider` (it registers `handlers` synchronously in its runner `useMemo`). NOT MEASURED: the objectui#3002 issue body. Reason: GitHub API access to objectstack-ai/objectui is not enabled for this session (git-only read), and this checkout is shallow past the ui/ subtree import. The in-tree docblock and the dispatch pin carry the reason.",
      "gates": "n/a: no diff, so no derivation was run and nothing was pushed except the empty branch marker.",
      "dogfood_battery": "n/a: no PR",
      "mcp_calls": "1: mcp__claude-code-remote__add_repo (read access to objectstack-ai/objectui; it answered read_available and attached nothing). GitHub MCP calls: 0.",
      "api_writes": "1: POST /repos/objectstack-ai/objectstack/issues/30169/comments (this report, via scripts/pm/post-stamped.mjs). The git push of the empty branch is not a REST write.",
      "open_questions": [
        {
          "question": "How does the selection bar tell a bare `bulkActions` name the server withholds from one a host serves through a registered runner handler? Both are absent from the served object's `actions` and from `/meta/action`.",
          "options": [
            "A. Ask the runner. Add one public read-only method to `@object-ui/core` `ActionRunner` (for example `hasHandler(name)`, which answers `this.handlers.has(name)`). `ObjectGrid` then keeps an unresolved name only when `useAction().runner` has a handler under it. Withheld, mistyped and other dead names draw no button. The handler path (row 3's reason and the dispatch pin) keeps working. Row 3 flips to two assertions: with no handler, no `bulk-action-NAME` button; with a handler registered, the button is drawn by name. A withheld-trigger test is added (served object without the action: no button; with it: the action's label), plus an ablation of the drop. Cost: the file surface widens by `ui/packages/core/src/actions/ActionRunner.ts` and a core unit test, and a published package gains a public method (patch changeset for `@object-ui/core` and `@object-ui/plugin-grid`). Caveat: a handler a host registers in an effect after the grid's first render is read on the grid's next render, which comes when its rows arrive. Effort S.",
            "B. Read the served `/meta/action` set (the order's mechanism). `ObjectGrid` calls `useMetadata().ensureType('action')` and drops an unresolved name that set lacks while `getTypeStatus('action')` is 'ready'. This stays on the claimed surface. But it drops a handler-only name in every host that mounts a MetadataProvider, the Console included, which is the case row 3 protects. The existing pins would stay green only because their harness mounts no MetadataProvider: a false green. It also adds a `/meta/action` fetch that no other surface makes today. Effort S.",
            "C. Drop every unresolved name outright. On the surface and smallest, but it removes the handler-by-name capability. That is a product change: the dispatch pin 'keeps an unresolvable name alongside the promoted defs' flips with row 3. Effort XS."
          ],
          "recommendation": "A, because it is the only option that drops the withheld ghost and keeps the pinned handler path. It reuses the runner's own rule (a by-name dispatch with no handler ends in 'not executable'), so no second rule is written. Its one cost is widening the file surface into `@object-ui/core`, and the order told me to stop on that breach. The dispatch carried no decision axes, so this recommendation weighs only the stated cost and effort; paste the axes if you want an axis-by-axis analysis."
        }
      ],
      "security_findings": "none",
      "out_of_scope_findings": [
        "carrier: this card. The row menu's legacy `rowActions` fold (`resolveLegacyRowActions`, whose unresolved names reach the kebab as `customRowActions`) keeps unresolved names on the same registerHandler reasoning, so a withheld bare `rowActions` name would draw the same ghost. This is a read-only inference and was not reproduced (HotCRM places those actions through `locations`). Whatever rule the decision picks for the bulk bar applies to this fold too. Dedupe words: rowActions unresolved, resolveLegacyRowActions withheld, customRowActions ghost."
      ]
    }
  4. objectstack-fleet commented on Nov 4, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:ui#2 · os-justin · session_01QSeKYnE9YNkSZen7sijLA3

    维护者速读 · #30169 选择栏怎么认出「被禁用包扣下的操作」 · 2026-11-04T21:58Z

    一句话问题。 一个模块被关掉后,在列表里勾选记录,选择栏仍会出现该模块的批量按钮,点了只弹「不可执行」。要去掉这个按钮,选择栏得分清两种名字:一种是被服务端扣下的操作,另一种是宿主程序自己在前端接管的操作。今天这两种在前端看起来一模一样,所以要定下按什么来区分,或者干脆不再支持后一种。

    Governing text: packages/spec/src/ui/bulk-action.zod.ts:406 — "bulkActions: ['<action_name>'] — names an action the object declares." The renderer's tolerance of a name the object does not declare is in ui/packages/plugin-grid/src/resolveBulkActions.ts:78-82 ("Names that resolve to nothing stay in unresolved"), pinned by bulkActionMembers-8071.test.tsx:202-212 (row 3) and objectBulkActionDispatch.test.tsx:213-228. Authoring already refuses that name: packages/lint/src/validate-action-name-refs.ts (action-name-undefined, severity error, ADR-0049) rejects an undeclared bulkActions[]/rowActions[] name and says "the runtime ships NO built-in action names … a name resolving nowhere is dead, full stop" (:41-50), citing the record:alert/page:header precedent of drawing nothing for an unresolved id (#20105). The resolver's "that path must keep working" comment contradicts lint, and lint carries the ADR. Server rule: isActionWithheldByDisabledPackage (packages/metadata-protocol/src/protocol-authoring-refusals.ts:73, #28877); "disabled has exactly one meaning: hidden, refused on the data plane, data kept" (docs/adr/0070-package-first-authoring.md:66; "ruling A: disabled = hidden" at packages/objectql/src/registry.ts:7796).

    Protocol: no packages/spec change in any option. C brings the renderer back to the spec's own sentence. A widens a published ui package's API (@object-ui/core).

    前提(re-check,席位与隔离复核亲读)

    • The bar draws unresolved names: ObjectGrid.tsx:1260 (unresolved: unresolvedBulkActions) and :1287; it dispatches by name at :2020.
    • No in-repo app uses a handler-only bulk name. Every non-test bulkActions string in examples/, ui/ and packages/ names a declared action (for example examples/app-showcase/src/ui/actions/index.ts:57,179). The only exception is the demo ui/packages/plugin-grid/demo/bulk-actions.tsx:241 (legacy_only_handler). The Console's create/delete/navigate/refresh handlers (useObjectActions.ts:129-159) serve other surfaces; delete is routed away from the runner on purpose (ObjectGrid.tsx:1250-1258). HotCRM is not in this repo; that its bare strings name declared actions rests on A list's selection bar still draws a bulk action that a disabled package withholds, as a ghost button that toasts "not executable" #30169's own text ("With marketing on, the same bar draws Add to Campaign, which works").
    • ActionRunner's handler map is private (ui/packages/core/src/actions/ActionRunner.ts:1365), with no public query; useAction().runner is public (ui/packages/react/src/context/ActionContext.tsx:65).
    • The legacy row menu has the same tolerance (resolveLegacyRowActions.ts:35-37 → ObjectGrid.tsx:1002 customRowActions).

    选项

    选项 做什么 客户看到的后果 工作量
    A 问运行器 ActionRunner 加一个只读方法(如 hasHandler(name));选择栏只留「有前端接管」的未声明名字 幽灵按钮消失;只靠前端接管的按钮保留(今天没有应用在用) ui · 1 PR · S(dev 估);@object-ui/core 多一个公开方法
    B 读 /meta/action 选择栏额外拉取操作清单,清单里没有的就丢 幽灵按钮消失,前端接管的也一起丢;现有测试因测试环境未装元数据而照绿(假绿) ui · 1 PR · S;多一次网络请求
    C 按 spec 收紧 选择栏只画对象声明的操作,未声明的名字不画;行菜单的旧写法同规则;开发态打一行警告 幽灵按钮消失;作者写错或写了未声明的名字,开发时看到警告,而不是用户点了才报错 ui · 1 PR · XS-S;翻转两个既有 pin + demo

    业务含义直译。 A 是问前台「这个分机有人接吗」;B 是只看电话簿,连带有人接的也摘牌;C 是规定只能挂电话簿上的分机,挂错的,装修时就提醒。

    推荐:C

    只看① 选 C;②③④ 是否翻转:否。

    项目长远合理性(领起)。 终态句:两年后,选择栏只出现对象声明过、当前用户真能执行的操作。主流平台都是这样:Salesforce 的 list view mass actions 只列对象上定义的操作,ServiceNow 的 list UI actions 只列表上定义的操作。spec 已经写明裸字符串「指对象声明的操作」,lint 也已按 ADR-0049 在编写阶段把未声明的名字判为 error(「解析不到就是死的」),record:alert、page:header 早就对解析不到的 id 什么都不画。渲染器容忍未声明的名字,是在 spec 之外多出的一条宽容;按「spec 声明优先于实现」,应当把实现拉回 spec,而不是再加一个公开方法去养这条宽容(A)。B 用一份不完整的清单代替判据,会误删。

    实际业务需求。 幽灵按钮今天就会撞上,HotCRM 在 18.3.0 上实测到。另一边,只靠前端接管的批量名字,仓内没有一个应用在用,只有两个测试和一个 demo,所以 C 取消的是一项有测试、无在用方的能力。

    防 AI 写错。 出错时谁看到什么:今天 AI 在 bulkActions 里写一个未声明或拼错的名字,最终用户会看到一个点了就报错的按钮。第一道网已经在:lint 的 action-name-undefined 在发布前以 error 拒收。C 让运行时与之一致,按钮不画;开发态警告只是 lint 之后的第二道网。这是契约收紧,优于消费端宽容。A 保留宽容,拼错的名字反而静默消失。B 会假绿。

    创业阶段不扩散。 C 删掉一条零在用方的宽容,不加键、不加方法。A 为零在用方的能力新增一个公开方法,等于扩张。

    疑属不升级类。 spec 原文与 lint(ADR-0049)都已定下「未声明即死」,渲染器的宽容与之冲突;按「spec 与代码不一致默认改代码对齐」,C 即既有规则。按规则 dev 的 needs_decision 一律呈你,所以仍交你裁。

    置信缺口。 仓外宿主是否依赖前端接管批量名字,未知;如有具名的外部用户证据,应改为 A。行菜单的同类宽容未在浏览器复现,按同规则一并处理。

    裁后执行。 选 C:同卡重派一个 dev,范围是 plugin-grid 的 resolveBulkActions 与 resolveLegacyRowActions、两个 pin、demo,以及 ui/packages/plugin-grid/README.md:552、:771 里的 bulkActions: ['delete', 'export'](export 只靠前端接管),加开发态警告,再补被禁用模块的触发用例与消融。选 A:同卡重派,认领范围加 ActionRunner.ts,入队前按扩大公开面过一次契约复核。选 B:同卡重派。

    os-decision-facets

    • ① 长远:C 把渲染器拉回 spec 原文(bulk-action.zod.ts:406)与 lint action-name-undefined(ADR-0049),去掉一条特例;A 为特例加公开方法;B 误删。
    • ② 拉动:幽灵按钮在 HotCRM 18.3.0 实测;前端接管的批量名字仓内零在用方(只有测试和 demo)。
    • ③ 防错:lint 已在发布前以 error 拒收;C 让运行时一致,开发态警告作第二道网;A 让拼错名字静默消失;B 假绿。
    • ④ 不扩散:C 删一条零在用宽容;A 为零拉动能力新增公开面。

    Analysis-tier: seat (TIER_DEFAULT); reviewed by an isolated CONTRACT_REVIEW_TIER review, which returned FAIL with 6 required corrections. All 6 are applied in this text, and the recommendation moved from A to C on the corrected facet ② and the spec sentence it surfaced. A second isolated review of the C draft returned PASS with 5 corrections (the lint rule, the README scope, ADR paths, the HotCRM source, test spans), all applied.

    Prior rulings read: resolveBulkActions, unresolved bulk, #28877, #28117 → 1 hit (#28117 ruling record, re-affirmed by letter A); ADR none; thread: none.

  5. objectstack-fleet commented on Nov 4, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:ui#2 · os-justin · session_01QSeKYnE9YNkSZen7sijLA3

    Release: claim 6642827748 (session_01QSeKYnE9YNkSZen7sijLA3, seat domain:ui#2) is released into the decision inbox, 2026-11-04T21:59Z. No code was written. Branch claude/issue-30169-bulk-bar-withheld-action is an empty marker at fec1f586 and holds no commits. The dev report is 6642981552, and the seat analysis is 6643188402. After the ruling, any domain:ui seat re-claims the card and dispatches the letter chosen. The analysis names the scope for each letter.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions