Platform ask from objectstack-ai/hotcrm, the reference CRM app. Measured on @objectstack/runtime17.1.0. Nothing is mis-implemented against a stated contract — there is no contract here to state, which is the ask.
The failure, measured end to end
case_sla_monitor is a scheduled sweep: select every breached case, then for each one flag the breach and notify the owner. On a fresh boot it died terminally:
ERROR Trigger-fired run of flow 'case_sla_monitor' failed (trigger 'schedule')
Node 'notify_team' failed: notify: at least one recipient is required,
but every recipient template resolved to nothing: {currentCase.owner_id}
crm_case.owner_id is nullable and an ownerless case is an ordinary state. The path is:
builtin notify returns success: false → executeNode throws → runRegion rethrows → the loop node awaits it with no try/catch → the run dies.
Reproduced deterministically in the app's own harness against the real AutomationEngine (five breached cases, the ownerless one third in line):
| run status | summary | flagged | notified |
|---|
| before | failed | {selected: 5, acted: 0} | 2 of 5 | 1 |
The two cases after the ownerless one were never processed at all. One row with a null lookup cost the other 60% of the sweep, and nothing retried it.
Why this is the platform's to answer, not the app's
The app has fixed its instance by inserting a decision gateway so notify is never reached with an empty audience. That works and it ships. But it is a per-call-site guard, and the general shape it patches is not specific to notify:
- any node that can fail on one row takes down a sweep over all rows;
- the blast radius is invisible at author time — nothing in
objectstack validate, build or the flow lint family says "this loop has no containment"; - the damage is silent in the only way that matters: the other rows produce no error of their own. They simply never run.
An app can only defend by hand-writing a predicate in front of every fallible node inside every loop body, for every failure mode that node has. That is not a contract, it is a memory test — and the app's own first draft of exactly such a predicate was wrong (a CEL != '' test is true for ' ', while notify trims before dropping), which is a small demonstration of the point.
The ask
A declared, opt-in per-iteration failure containment on the loop node — continue to the next iteration, record the failure against that iteration, and report it in the run summary rather than aborting the run.
Shape suggestion only; the naming is yours:
{id: 'loop_cases',type: 'loop',config: {onIterationError: 'continue'|'abort'}}with 'abort' the default so nothing changes for existing flows. The run summary already carries selected / acted / skipped (#4354), so failed-iteration counts have a natural home beside them, and a run that partially succeeded stops being indistinguishable from one that died at row 1.
Whether the default should eventually flip is a separate question — for a scheduled sweep "process the rest and tell me what failed" is almost always the intended semantics, and "abort" is the one an author is least likely to have chosen deliberately.
Prior art checked before filing
Searched the flow/loop neighbourhood: #5633, #5383 and #4347 are all about lint and conversion passes failing to descend into loop bodies; #4354 surfaced run summaries; #3712 and #3427 are unrelated trigger/context issues. None covers runtime failure containment inside the loop. If this is a duplicate of something I could not surface, close it against that card.
Downstream reference
objectstack-ai/hotcrm#1405 carries the full reproduction and the per-site workaround the app shipped. A sibling ask covering the narrower half — notify treating an empty audience as a hard failure rather than a recorded skip — is filed separately; this card is the general one and would retire the need for the guard at every call site, not just notify's.
Generated by Claude Code
Platform ask from
objectstack-ai/hotcrm, the reference CRM app. Measured on@objectstack/runtime17.1.0. Nothing is mis-implemented against a stated contract — there is no contract here to state, which is the ask.The failure, measured end to end
case_sla_monitoris a scheduled sweep: select every breached case, then for each one flag the breach and notify the owner. On a fresh boot it died terminally:crm_case.owner_idis nullable and an ownerless case is an ordinary state. The path is:Reproduced deterministically in the app's own harness against the real
AutomationEngine(five breached cases, the ownerless one third in line):failed{selected: 5, acted: 0}The two cases after the ownerless one were never processed at all. One row with a null lookup cost the other 60% of the sweep, and nothing retried it.
Why this is the platform's to answer, not the app's
The app has fixed its instance by inserting a decision gateway so
notifyis never reached with an empty audience. That works and it ships. But it is a per-call-site guard, and the general shape it patches is not specific tonotify:objectstack validate,buildor the flow lint family says "this loop has no containment";An app can only defend by hand-writing a predicate in front of every fallible node inside every loop body, for every failure mode that node has. That is not a contract, it is a memory test — and the app's own first draft of exactly such a predicate was wrong (a CEL
!= ''test is true for' ', whilenotifytrims before dropping), which is a small demonstration of the point.The ask
A declared, opt-in per-iteration failure containment on the
loopnode — continue to the next iteration, record the failure against that iteration, and report it in the run summary rather than aborting the run.Shape suggestion only; the naming is yours:
with
'abort'the default so nothing changes for existing flows. The run summary already carriesselected / acted / skipped(#4354), so failed-iteration counts have a natural home beside them, and a run that partially succeeded stops being indistinguishable from one that died at row 1.Whether the default should eventually flip is a separate question — for a scheduled sweep "process the rest and tell me what failed" is almost always the intended semantics, and "abort" is the one an author is least likely to have chosen deliberately.
Prior art checked before filing
Searched the flow/loop neighbourhood: #5633, #5383 and #4347 are all about lint and conversion passes failing to descend into loop bodies; #4354 surfaced run summaries; #3712 and #3427 are unrelated trigger/context issues. None covers runtime failure containment inside the loop. If this is a duplicate of something I could not surface, close it against that card.
Downstream reference
objectstack-ai/hotcrm#1405carries the full reproduction and the per-site workaround the app shipped. A sibling ask covering the narrower half —notifytreating an empty audience as a hard failure rather than a recorded skip — is filed separately; this card is the general one and would retire the need for the guard at every call site, not just notify's.Generated by Claude Code