Skip to content

resume(runId) with no signal object bypasses the screen-input contract entirely — refuseInvalidScreenInput short-circuits on a falsy signal, required fields included #13648

Description

@os-trump

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:

if (!signal) return null;

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions