Filed from the repo:hotcrm seat. Unassigned and ungraded — this repo's triage seat owns domain:* and type.
Measured while implementing objectstack-ai/hotcrm#1173 (PR objectstack-ai/hotcrm#1413), which needed to establish whether a required: true screen field can be left unanswered on any path. The answer for the ordinary path is no — and that enforcement is exactly what let that card retire a defensive seeding node. This is the one path where it does not hold.
The dev that measured it deliberately did not file it in the app repo: that repo's AGENTS.md puts engine code out of scope, and filing an engine observation there would misplace it. Routed here by the PM instead.
What was measured
@objectstack/service-automation, on the 17.1.0 line that app pins.
Since 17.0.0-rc.2 the server holds a screen resume to the declared field contract (#4477): a resume signal omitting a required screen field is refused with INVALID_SCREEN_INPUT, the run stays paused, and the same run resumes cleanly once the field is supplied. Confirmed end to end, and it is now pinned by a test in that app:
resume(runId, { variables: { leadStatus, contactDepartment } })
→ success: false, code: INVALID_SCREEN_INPUT
'Screen field "memberSource" is required'
→ run still paused; resuming with memberSource supplied completes normally
But AutomationEngine.refuseInvalidScreenInput short-circuits on a falsy signal:
⇒ resume(runId) with no signal object at all skips the contract check entirely — including unconditional required fields. The run proceeds with those variables unbound.
Why this reads as a distinct case rather than the same one
Engine-built signals are already handled separately, by the ENGINE_BUILT_SIGNAL flag. So there is a deliberate mechanism for "the engine constructed this signal, do not hold it to the caller contract" — and the absent-signal path is not that mechanism. It looks like a separate early return that predates or sits beside the flag, rather than an expression of the same intent.
⚠️ That is the reason this is filed as a question about intent, not as an assertion of a bug: if a signal-less resume is meant to bypass the contract, the reasoning is not visible at the call site, and the two exemptions being spelled two different ways is what makes it read as an oversight.
Blast radius, stated honestly
⛔ Not exploitable in the app that found it, and the measurement says why. Every flow path behind this hole in that app fails closed one node later: an unbound variable interpolated into a get_record filter makes the node refuse to run and name the offending condition, rather than widening the query —
get_record: refusing to run — 1 filter condition(s) resolved to nothing … `{leadStatus}` (at status)
So there, the missing enforcement moved a failure by one node; it did not make an unsafe path work.
⚠️That containment is a property of those flows, not of the engine. A flow whose post-screen nodes tolerate an unbound variable — anything that branches on it, defaults it, or passes it somewhere that coerces — gets no such backstop. And the containment that does exist is the get_record refusal, which is itself a deliberate fail-closed choice that a different node type need not share.
What would resolve it
Either hold a signal-less resume to the same contract as a signal-carrying one, or state at the call site why it is exempt — and, if it is exempt, express that through the same mechanism ENGINE_BUILT_SIGNAL uses rather than a second, differently-shaped early return. ⛔ This card does not choose: whoever owns the resume contract knows whether a signal-less resume is a supported caller gesture or only an internal one.
Consumer note
objectstack-ai/hotcrm#1173's PR now pins the enforced path with an end-to-end test, on the reasoning that if a future release drops it, that app's declared-default binding becomes the only thing between a branch edge and a No such key abort. ⇒ If the contract changes here, that pin is the early warning, and it is deliberately written to go red rather than to adapt.
Generated by Claude Code
Filed from the
repo:hotcrmseat. Unassigned and ungraded — this repo's triage seat ownsdomain:*and type.Measured while implementing objectstack-ai/hotcrm#1173 (PR objectstack-ai/hotcrm#1413), which needed to establish whether a
required: truescreen field can be left unanswered on any path. The answer for the ordinary path is no — and that enforcement is exactly what let that card retire a defensive seeding node. This is the one path where it does not hold.The dev that measured it deliberately did not file it in the app repo: that repo's
AGENTS.mdputs engine code out of scope, and filing an engine observation there would misplace it. Routed here by the PM instead.What was measured
@objectstack/service-automation, on the 17.1.0 line that app pins.Since 17.0.0-rc.2 the server holds a screen resume to the declared field contract (#4477): a resume signal omitting a
requiredscreen field is refused withINVALID_SCREEN_INPUT, the run stays paused, and the same run resumes cleanly once the field is supplied. Confirmed end to end, and it is now pinned by a test in that app:But
AutomationEngine.refuseInvalidScreenInputshort-circuits on a falsy signal:⇒
resume(runId)with no signal object at all skips the contract check entirely — including unconditionalrequiredfields. The run proceeds with those variables unbound.Why this reads as a distinct case rather than the same one
Engine-built signals are already handled separately, by the
ENGINE_BUILT_SIGNALflag. So there is a deliberate mechanism for "the engine constructed this signal, do not hold it to the caller contract" — and the absent-signal path is not that mechanism. It looks like a separate early return that predates or sits beside the flag, rather than an expression of the same intent.Blast radius, stated honestly
⛔ Not exploitable in the app that found it, and the measurement says why. Every flow path behind this hole in that app fails closed one node later: an unbound variable interpolated into a
get_recordfilter makes the node refuse to run and name the offending condition, rather than widening the query —So there, the missing enforcement moved a failure by one node; it did not make an unsafe path work.
get_recordrefusal, which is itself a deliberate fail-closed choice that a different node type need not share.What would resolve it
Either hold a signal-less resume to the same contract as a signal-carrying one, or state at the call site why it is exempt — and, if it is exempt, express that through the same mechanism
ENGINE_BUILT_SIGNALuses rather than a second, differently-shaped early return. ⛔ This card does not choose: whoever owns the resume contract knows whether a signal-less resume is a supported caller gesture or only an internal one.Consumer note
objectstack-ai/hotcrm#1173's PR now pins the enforced path with an end-to-end test, on the reasoning that if a future release drops it, that app's declared-default binding becomes the only thing between a branch edge and a
No such keyabort. ⇒ If the contract changes here, that pin is the early warning, and it is deliberately written to go red rather than to adapt.Generated by Claude Code