Skip to content

intent: a gated checks: refusal crosses the delegate that does the posting (#7371) - #7419

Merged
delchev merged 1 commit into
masterfrom
issue-7371-async-gated-check
Sep 17, 2026
Merged

delchev merged 1 commit into
masterfrom
issue-7371-async-gated-check

Conversation

@delchev

@delchev delchev commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

The cause

completingTransactionNodes walks back from a check-gated status setter to the user tasks that
reach it and strips the flowable:async boundary off everything on the way, so the gate's
ValidationException is raised in the completing thread and leaves as a 400. The walk stopped at
any step that was not a decision - an authored service task in between was treated as real
background work whose completion nobody waits on.

Measured, that reading is wrong for the step that sits between the decision and the setter.
base-inventory's six posting flows are userTask post -> decision -> apply (custom delegate) -> activate (gated setter): apply's boundary commits the completion, the setter then runs as a
detached job, and the gate refuses that. The poster gets 200 with an empty body, the task is
consumed, the document is still DRAFT, and once the job's retries are exhausted the instance is
stranded - activityId: null, no task in anyone's Inbox, no way back short of an administrator
retriggering the dead letter.

The flowable:class emission path (appendDelegateServiceTask, the one a delegate: step takes)
also hard-coded flowable:async, so even a marked node would have kept its boundary there.

The change

  • gateReachingSteps crosses authored serviceTask steps too, via a new
    joinsTheCompletingTransaction. The whole stretch from the completing task to the gate runs in
    one transaction: the step's own work rolls back with the refusal, and the 400 carries the
    authored message out of POST /services/inbox/tasks/<id>.
  • appendDelegateServiceTask honours the async flag like the ${JavaTask} path already did.
  • The walk still stops where the transaction cannot span: a second user task or a wait is its own
    wait state, end is the end, and a step declaring retry: keeps its boundary - a Flowable
    failed-job retry cycle re-runs a JOB and there is no job without one, so crossing it would
    silently drop the declared re-attempts. (An onError: alone needs no job: IntentStepResilience
    converts a synchronous first-and-final failure exactly as it does an exhausted asynchronous one,
    so a step that only routes its failure is crossed like any other.)
  • A gate reachable only across a retrying step is therefore still a background incident - the
    one case this cannot repair. Nothing about that is visible in the generated output, so the
    generator now logs a WARN naming the process, the gate, the user task and the two ways out
    (drop the retry:, or move the retrying work off the route), rather than leaving it silent.
    This is the issue's option 3, narrowed to the shape option 1 cannot reach.

Not changed: the transitions[] path (already structurally correct - the endpoint writes the
column in the request thread), the user-task-owned setRelationField shape (#7014, already
synchronous), and the response mapping in BpmInboxEndpoint (a ValidationException in the cause
chain already becomes 400 with the message as the body - it simply never got one on this shape).

Verification

  • New IT, IntentCheckGateAcrossADelegateIT - the reported flow, reduced, driven end to end
    through the real engine: post -> postDecision -> apply (a client JavaDelegate bound with
    delegate:) -> activate (gated itemsMin). Asserts the outermost facts, not the XML: the
    completion answers 400 with the authored message, the stored Status is still 1, the task is
    still the poster's (same task id), the very same completion then succeeds once a line is
    added, and the ungated cancel arm of the same decision still completes with no line at all.
    Confirmed to fail without the generator change: reverted BpmnIntentGenerator.java to
    origin/master, rebuilt, re-ran - Expected status code <400> but was <200>, which is the
    issue's symptom verbatim.
  • IntentCheckGateRefusalIT (the intent: a checks: refusal after Approve parks the process in dead-letter - the message reaches Monitoring, not the user, and only an admin can retry #7063/intent: a gate reachable through two decision routes keeps its async boundary on the second route #7139 shapes) green on the fixed build.
  • IntentEngineIT (81) + IntentEmissionCoverageIT (1) green - the emission and resilience
    coverage over the shapes this walk touches.
  • engine-intent unit suite green (143 test classes). CheckGateBpmnTest gained three cases and
    flipped one: authoredWorkBetweenTheTaskAndTheGate... now asserts the synchronous emission with
    the issue's rationale, plus base-inventory's decision+flowable:class shape and the retrying-step
    control that must keep its boundary.
  • mvn formatter:validate green with the formatter cache wiped.

Not run: the full integration suite, and no fleet regen - base-inventory needs a pin bump and a
regen to pick this up.

Fixes #7371

🤖 Generated with Claude Code

…sting (#7371)

The walk that takes the async boundary off everything between a completing
user task and a check-gated status setter stopped at an authored service
task, on the reasoning that its own completion is what the person waited
for. Measured on base-inventory's six posting flows - userTask -> decision
-> custom posting delegate -> gated setter - that boundary commits the
completion and the gate then refuses a detached job: 200 with an empty
body, the task consumed, the document still DRAFT, and once the job's
retries are exhausted an instance stranded with no task in anyone's Inbox
and no way back short of an administrator retriggering the dead letter.

So the walk now crosses authored service tasks as well, and the
flowable:class delegate path - which hard-coded flowable:async - honours
the flag like the ${JavaTask} one. The whole stretch runs in the
completing transaction: the step's own work rolls back with the refusal
and the 400 carries the authored message out of the task completion.

It still stops where the transaction cannot span: a second user task or a
wait is its own wait state, and a step declaring retry: keeps its boundary
because a Flowable failed-job retry cycle re-runs a JOB and there is no
job without one. (An onError: route needs none - IntentStepResilience
converts a synchronous first-and-final failure exactly as it does an
exhausted asynchronous one.) A gate reachable only across a retrying step
is therefore still a background incident, and the generator now says so in
a WARN naming the gate, the task and the two ways out, rather than leaving
it invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +607 to +611
LOGGER.warn(
"Process [{}]: the check-gated status write of step [{}] is reachable from user task [{}] only across a step declaring `retry:`,"
+ " whose async boundary commits the task completion - a refusal there will dead-letter instead of reaching the person who acted."
+ " Drop the `retry:` from that step, or move it off the route between the task and the gate.",
process.getName(), gate, userTask.getName());
@delchev
delchev merged commit 9fbf655 into master Sep 17, 2026
10 checks passed
@delchev
delchev deleted the issue-7371-async-gated-check branch September 17, 2026 10:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

intent: a gated checks: refusal is lost when the status setter sits behind a flowable:async node (one flow shape)

2 participants