Skip to content

An action cannot declare which bulkActions dispatch contract its body is written for — bare-string fan-out and execution: 'aggregate' deliver opposite input shapes through one authoring surface #17319

Description

@claude

Filed from objectstack-ai/hotcrm#1184 phase 3 (the src/actions/ comment audit). Every claim below was measured against the @objectstack/* 17.4.0 install that hotcrm pins, not recalled from a comment.

The gap

A list view can wire the same declared action two ways, and the two deliver opposite input shapes to the same body:

  • bulkActions: ['x'] (bare string) — the renderer promotes the action to a def and dispatches it once per selected row. Each call carries that row's recordId and no selection array.
  • a bulkActionDefs entry with execution: 'aggregate'one dispatch for the whole selection. Every id arrives in the builtin _selectedIds param and there is no recordId.

The action itself declares neither. There is no key on the action naming the contract its body was written for, and nothing checks that a view's wiring agrees with it.

Why nothing catches it

The strict params gate (ADR-0104) structurally cannot see this. _selectedIds is a builtin — @objectstack/spec dist/ui/index.js:7134, ACTION_PARAM_BUILTIN_KEYS = ["recordId", "objectName", "_selectedIds"] — so it is admitted without a declaration, and declaring it is not a supported authoring move. A no-underscore selectedIds is refused outright. The gate is therefore silent on precisely the key that decides the contract.

Both mismatches fail quietly, in opposite directions:

  • a body written for aggregate (input._selectedIds) but wired bare-string sees _selectedIds undefined, falls through to its single-record branch, and the operator gets a success toast for one row out of ten;
  • a body written per-record (ctx.recordId) but wired aggregate sees no recordId and throws its own "nothing selected" error, which reads like a selection bug in the console rather than a wiring mismatch.

What it costs downstream

In hotcrm the distinction is held entirely by hand-copied prose. Three action files carry it (src/actions/lead.actions.ts, src/actions/opportunity.actions.ts, src/actions/contact.actions.ts) and two view files restate it (src/views/opportunity.view.ts, src/views/contact.view.ts). In the phase-3 comment audit it is the single largest surviving constraint block on the surface — roughly 60 comment lines whose only job is to tell the next author which contract a given body assumes. That is the signature of an expressiveness gap: a fact the platform knows, that only prose carries.

Proposal — make the mistake impossible

  1. An optional dispatch: 'per_record' | 'aggregate' on the action declaration.
  2. objectstack build / reference-integrity lint refuses a view that wires an aggregate-declared action as a bare string, and the reverse.
  3. Once it is declared, the runtime can refuse the dispatch itself rather than handing a body an input shape it was not written for.

Any of these turns a silent, direction-dependent misfire into an author-time refusal, and retires the prose.

Backlog search performed before filing

Searched this repo for the family first, because a sibling card filed an hour apart is the known failure mode here. Nearest neighbours, all closed, none covering this: #14092 (a row action has no declarative form while the bulk form does), #4457 (bulkActionDefs is z.record(z.any()) — type the def shape), #6257 (BulkActionDefSchema lacks requiredPermissions), #5568 (multi-row selection never reaches action input — closed not_planned as works-as-declared, which is the ruling that makes the two contracts legitimate and this card about declaring which one).


Generated by Claude Code

Activity

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions