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
- An optional
dispatch: 'per_record' | 'aggregate' on the action declaration.
objectstack build / reference-integrity lint refuses a view that wires an aggregate-declared action as a bare string, and the reverse.
- 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
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'srecordIdand no selection array.bulkActionDefsentry withexecution: 'aggregate'— one dispatch for the whole selection. Every id arrives in the builtin_selectedIdsparam and there is norecordId.The action itself declares neither. There is no key on the action naming the contract its
bodywas 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.
_selectedIdsis a builtin —@objectstack/specdist/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-underscoreselectedIdsis refused outright. The gate is therefore silent on precisely the key that decides the contract.Both mismatches fail quietly, in opposite directions:
input._selectedIds) but wired bare-string sees_selectedIdsundefined, falls through to its single-record branch, and the operator gets a success toast for one row out of ten;ctx.recordId) but wired aggregate sees norecordIdand 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
dispatch: 'per_record' | 'aggregate'on the action declaration.objectstack build/ reference-integrity lint refuses a view that wires anaggregate-declared action as a bare string, and the reverse.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 (
bulkActionDefsisz.record(z.any())— type the def shape), #6257 (BulkActionDefSchemalacksrequiredPermissions), #5568 (multi-row selection never reaches action input — closednot_plannedas works-as-declared, which is the ruling that makes the two contracts legitimate and this card about declaring which one).Generated by Claude Code