Filed by the repo:hotcrm execution seat (session session_019hUuCQStzXGMFSX4dzww5t, R32). ⛔ Observation-level, unassigned, no domain:* / type — routing and grading belong to central triage. Filed here rather than worked around locally, per the hotcrm lane charter.
Surfaced while implementing a maintainer ruling that required a conversion to be refused with copy naming why and which record — hotcrm#1288, landed as hotcrm#1555. The behaviour shipped is correct; the shape available to express it is not.
The gap
Measured against @objectstack/spec / @objectstack/console 17.2.0, there is no metadata-authorable way for a flow to refuse an operation with per-record text. Every candidate channel fails on one axis:
| channel |
why it cannot carry a per-record refusal |
Action.visible / Action.disabled |
each is ZodOptional<ZodUnion<[ZodBoolean, CEL-envelope]>> — no reason field of any kind. A hidden or greyed button cannot explain itself. |
Action.errorMessage |
a single static string, and shown on a failed run. |
a node executor's {success:false, error} |
the message is platform-authored, not authorable in metadata. |
script node |
needs a host-registered function; not reachable from declarative metadata. |
flow.errorMessage (console's error.details.errorMessage) |
again one static string for the whole flow. |
object validations[] |
fires too late — in this case at the terminal write, after the account, contact and opportunity had already been created. |
⇒ the only per-record channel is a screen node's config.description, which is interpolated per run and rendered by FlowRunner as the dialog body.
Why that channel is still the wrong shape
A message-only screen ({title, description}, no fields) is not a notice — it is an input step wearing a notice's clothes:
- it renders Cancel + Submit, so a refusal offers the user a Submit button;
- submitting resumes the run to
end, and FlowRunner then toasts its neutral Flow "…" completed;
- ⇒ a user who is being told "this is refused" clicks Submit and is told the flow completed.
⭐ One thing that does work correctly and is worth recording so nobody "fixes" it: the invoking action's own successMessage does not fire behind the dialog — a paused run returns {success: true, silent: true} and silent suppresses the action toast. Measured on the shipped console bundle (RecordDetailView flow handler). So the defect is the terminal toast and the Submit affordance, not a double-toast.
⛔ The behaviour is safe in the landed case regardless — every write in that flow sits behind the branch the refusal never reaches, so nothing is created on either path. This is about what an author can express, not a data-integrity bug.
What would close it
Either would do; ⛔ the seat is not proposing a design, only naming the shape:
- a terminal-notice screen variant — Close only, no Submit, and no completion toast; or
- a first-class refusal/stop node carrying authored, interpolated text, so "this run is refused, here is why" is a thing a flow can say.
Reproduction
src/flows/lead-conversion.flow.ts on objectstack-ai/hotcrm@f4068c4d, node refuse_confirmed_duplicate, reached by edge e25 out of decision_duplicate.
⚠️ A second, sharper finding from the same work, in case it is the same root: a decision node with no declared config.conditions takes every out-edge whose condition holds, in parallel. hotcrm#1555 caught this the hard way — a Clean edge spelled != "suspected" and a new == "confirmed" edge were both live for a confirmed record, so the refusal would have rendered and the conversion would have run in the same execution. It was found by an ablation, not by review, and the fix was to narrow the Clean condition into a true partition. ⇒ ⚠️ if a decision node's out-edges are intended to be exclusive, nothing in the platform enforces or warns about it, and the failure is silent and behavioural. That may deserve its own card; the seat is naming it here rather than splitting it, since central triage is better placed to judge whether they share a cause.
Filed by the
repo:hotcrmexecution seat (sessionsession_019hUuCQStzXGMFSX4dzww5t, R32). ⛔ Observation-level, unassigned, nodomain:*/type— routing and grading belong to central triage. Filed here rather than worked around locally, per the hotcrm lane charter.Surfaced while implementing a maintainer ruling that required a conversion to be refused with copy naming why and which record — hotcrm#1288, landed as hotcrm#1555. The behaviour shipped is correct; the shape available to express it is not.
The gap
Measured against
@objectstack/spec/@objectstack/console17.2.0, there is no metadata-authorable way for a flow to refuse an operation with per-record text. Every candidate channel fails on one axis:Action.visible/Action.disabledZodOptional<ZodUnion<[ZodBoolean, CEL-envelope]>>— no reason field of any kind. A hidden or greyed button cannot explain itself.Action.errorMessage{success:false, error}scriptnodeflow.errorMessage(console'serror.details.errorMessage)validations[]⇒ the only per-record channel is a
screennode'sconfig.description, which is interpolated per run and rendered byFlowRunneras the dialog body.Why that channel is still the wrong shape
A message-only screen (
{title, description}, nofields) is not a notice — it is an input step wearing a notice's clothes:end, andFlowRunnerthen toasts its neutralFlow "…" completed;⭐ One thing that does work correctly and is worth recording so nobody "fixes" it: the invoking action's own
successMessagedoes not fire behind the dialog — a paused run returns{success: true, silent: true}andsilentsuppresses the action toast. Measured on the shipped console bundle (RecordDetailViewflow handler). So the defect is the terminal toast and the Submit affordance, not a double-toast.⛔ The behaviour is safe in the landed case regardless — every write in that flow sits behind the branch the refusal never reaches, so nothing is created on either path. This is about what an author can express, not a data-integrity bug.
What would close it
Either would do; ⛔ the seat is not proposing a design, only naming the shape:
Reproduction
src/flows/lead-conversion.flow.tsonobjectstack-ai/hotcrm@f4068c4d, noderefuse_confirmed_duplicate, reached by edgee25out ofdecision_duplicate.config.conditionstakes every out-edge whose condition holds, in parallel. hotcrm#1555 caught this the hard way — aCleanedge spelled!= "suspected"and a new== "confirmed"edge were both live for a confirmed record, so the refusal would have rendered and the conversion would have run in the same execution. It was found by an ablation, not by review, and the fix was to narrow theCleancondition into a true partition. ⇒