From ae2fb5f821a17c20ab58425c0618022d481cf959 Mon Sep 17 00:00:00 2001 From: Marco Schaeck Date: Fri, 21 Aug 2026 09:07:58 +0200 Subject: [PATCH] test(bruno): poll for eventual consistency instead of fixed sleeps Replace the 18 fixed setTimeout sleeps in the Bruno e2e suite with predicate-based polling capped by an env-tunable timeout (ADR-0015). - Add shared pollUntil/pollApp/pollEngine helpers in bruno/collection.bru (collection-level pre-request, axios-based; the sandbox has no fetch). - Command steps gate on the read-model precondition that makes the command valid (contractId, status/orderId, inbox listing); read/assert steps poll their own assertion against the app read model or the engine-rest query. - Add pollTimeoutMs/pollIntervalMs to the local environment. - Pin the Bruno CLI to @usebruno/cli@4.0.0 in CI, AGENTS.md and the Conductor run command; sandbox capabilities can shift between majors. Suite verified green against a live stack: 39/39 requests, 67/67 assertions. --- .conductor/settings.toml | 2 +- .github/workflows/pre-merge.yml | 2 +- AGENTS.md | 2 +- bruno/01-happy-path/04-sign-contract.bru | 4 +- bruno/01-happy-path/05-report-handover.bru | 6 +- .../01-happy-path/06-get-withdrawal-timer.bru | 6 +- .../03-get-signature-deadline-timer.bru | 6 +- bruno/02-escalation/05-assert-rejected.bru | 4 +- bruno/03-abort/03-sign-contract.bru | 4 +- bruno/03-abort/04-withdraw.bru | 6 +- bruno/03-abort/05-get-clarify-return-task.bru | 7 +- bruno/03-abort/07-assert-cancelled.bru | 6 +- bruno/04-not-solvent/02-assert-rejected.bru | 4 +- .../05-bike-unavailable/03-sign-contract.bru | 4 +- .../04-clarify-alternative.bru | 5 +- .../05-report-handover.bru | 5 +- .../06-get-withdrawal-timer.bru | 6 +- .../08-assert-leasing-active.bru | 6 +- bruno/06-list-and-inbox/04-sign-contract.bru | 4 +- bruno/06-list-and-inbox/05-inbox-has-task.bru | 5 +- bruno/06-list-and-inbox/07-inbox-empty.bru | 5 +- bruno/collection.bru | 47 +++++++++++ bruno/environments/local.bru | 2 + docs/README.md | 1 + ...g-for-eventual-consistency-in-e2e-tests.md | 80 +++++++++++++++++++ 25 files changed, 187 insertions(+), 42 deletions(-) create mode 100644 bruno/collection.bru create mode 100644 docs/adr/0015-polling-for-eventual-consistency-in-e2e-tests.md diff --git a/.conductor/settings.toml b/.conductor/settings.toml index f39631f..2af0e5b 100644 --- a/.conductor/settings.toml +++ b/.conductor/settings.toml @@ -24,7 +24,7 @@ archive = "docker compose -f stack/docker-compose.yml down -v || true" # Bruno API smoke tests against a running local stack. [scripts.run.smoke] -command = "cd bruno && npx --yes @usebruno/cli run . --env local -r" +command = "cd bruno && npx --yes @usebruno/cli@4.0.0 run . --env local -r" icon = "flask" # Playwright browser end-to-end tests against a running local stack. diff --git a/.github/workflows/pre-merge.yml b/.github/workflows/pre-merge.yml index 539d1ba..cdd5430 100644 --- a/.github/workflows/pre-merge.yml +++ b/.github/workflows/pre-merge.yml @@ -162,7 +162,7 @@ jobs: - name: Run Bruno scenarios working-directory: bruno - run: npx --yes @usebruno/cli run . --env local -r + run: npx --yes @usebruno/cli@4.0.0 run . --env local -r - name: Dump application log on failure if: failure() diff --git a/AGENTS.md b/AGENTS.md index a4626f1..db5322c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ npm --prefix frontend run dev # UI on :5173 (proxies /api | Regenerate + verify the OpenAPI contract | `./gradlew :service:app:test --tests "io.miragon.blueprint.openapi.OpenApiSpecExportTest"` then `git diff --exit-code openapi/openapi.json` | | Frontend everything | `npm --prefix frontend run verify` | | Regenerate the API client | `npm --prefix frontend run api:generate` (check: `api:check`) | -| API scenarios (running stack) | `cd bruno && npx --yes @usebruno/cli run . --env local -r` | +| API scenarios (running stack) | `cd bruno && npx --yes @usebruno/cli@4.0.0 run . --env local -r` | | Browser e2e (running stack) | `npm --prefix frontend run e2e` | | BPMN lint | `npm run lint:bpmn` | | Backend OCI image · full-stack run | `./gradlew :service:app:bootBuildImage` · `docker compose -f stack/docker-compose.full.yml up` — [ADR-0014](docs/adr/0014-build-and-deployment-approach.md), CONTRIBUTING "Run it in containers" | diff --git a/bruno/01-happy-path/04-sign-contract.bru b/bruno/01-happy-path/04-sign-contract.bru index 8446b9e..d12fbda 100644 --- a/bruno/01-happy-path/04-sign-contract.bru +++ b/bruno/01-happy-path/04-sign-contract.bru @@ -11,8 +11,8 @@ post { } script:pre-request { - // Wait for the async job executor to drive the process to the contract-signature wait state. - await new Promise((resolve) => setTimeout(resolve, 2500)); + const id = bru.getVar('applicationId'); + await pollApp('/api/bike-leasing/' + id, (b, s) => s === 200 && b && b.contractId != null); } assert { diff --git a/bruno/01-happy-path/05-report-handover.bru b/bruno/01-happy-path/05-report-handover.bru index 33bdd44..7ea61b4 100644 --- a/bruno/01-happy-path/05-report-handover.bru +++ b/bruno/01-happy-path/05-report-handover.bru @@ -11,9 +11,9 @@ post { } script:pre-request { - // Wait for the async continuations (order bike + issue insurance, then join) to reach the - // "handover reported" wait state. - await new Promise((resolve) => setTimeout(resolve, 3000)); + const id = bru.getVar('applicationId'); + await pollApp('/api/bike-leasing/' + id, + (b, s) => s === 200 && b && b.status === 'ORDERED' && b.orderId != null); } assert { diff --git a/bruno/01-happy-path/06-get-withdrawal-timer.bru b/bruno/01-happy-path/06-get-withdrawal-timer.bru index 93561e6..d149158 100644 --- a/bruno/01-happy-path/06-get-withdrawal-timer.bru +++ b/bruno/01-happy-path/06-get-withdrawal-timer.bru @@ -11,8 +11,10 @@ get { } script:pre-request { - // Wait for the handover message's async continuation to reach the withdrawal-period timer. - await new Promise((resolve) => setTimeout(resolve, 2500)); + const pid = bru.getVar('processInstanceId'); + await pollEngine( + '/job?processInstanceId=' + pid + '&activityId=event_withdrawalPeriodElapsed&timers=true', + (b) => Array.isArray(b) && b.length >= 1); } assert { diff --git a/bruno/02-escalation/03-get-signature-deadline-timer.bru b/bruno/02-escalation/03-get-signature-deadline-timer.bru index 0da125f..48b11f3 100644 --- a/bruno/02-escalation/03-get-signature-deadline-timer.bru +++ b/bruno/02-escalation/03-get-signature-deadline-timer.bru @@ -11,8 +11,10 @@ get { } script:pre-request { - // Wait for the async job executor to drive the process to the contract-signature wait state. - await new Promise((resolve) => setTimeout(resolve, 2500)); + const pid = bru.getVar('processInstanceId'); + await pollEngine( + '/job?processInstanceId=' + pid + '&activityId=event_signatureDeadline&timers=true', + (b) => Array.isArray(b) && b.length >= 1); } assert { diff --git a/bruno/02-escalation/05-assert-rejected.bru b/bruno/02-escalation/05-assert-rejected.bru index f210cca..8d9a17b 100644 --- a/bruno/02-escalation/05-assert-rejected.bru +++ b/bruno/02-escalation/05-assert-rejected.bru @@ -11,8 +11,8 @@ get { } script:pre-request { - // Let the rejection's async continuation settle before reading the status. - await new Promise((resolve) => setTimeout(resolve, 1500)); + const id = bru.getVar('applicationId'); + await pollApp('/api/bike-leasing/' + id, (b, s) => s === 200 && b && b.status === 'REJECTED'); } assert { diff --git a/bruno/03-abort/03-sign-contract.bru b/bruno/03-abort/03-sign-contract.bru index 679a677..5c661a5 100644 --- a/bruno/03-abort/03-sign-contract.bru +++ b/bruno/03-abort/03-sign-contract.bru @@ -11,8 +11,8 @@ post { } script:pre-request { - // Wait for the async job executor to drive the process to the contract-signature wait state. - await new Promise((resolve) => setTimeout(resolve, 2500)); + const id = bru.getVar('applicationId'); + await pollApp('/api/bike-leasing/' + id, (b, s) => s === 200 && b && b.contractId != null); } assert { diff --git a/bruno/03-abort/04-withdraw.bru b/bruno/03-abort/04-withdraw.bru index 1d3de1a..cc31bef 100644 --- a/bruno/03-abort/04-withdraw.bru +++ b/bruno/03-abort/04-withdraw.bru @@ -11,9 +11,9 @@ post { } script:pre-request { - // Wait for the bike order (and insurance/contract) to complete, so they get compensated by the - // saga rollback when the application is withdrawn. - await new Promise((resolve) => setTimeout(resolve, 3000)); + const id = bru.getVar('applicationId'); + await pollApp('/api/bike-leasing/' + id, + (b, s) => s === 200 && b && b.status === 'ORDERED' && b.orderId != null); } assert { diff --git a/bruno/03-abort/05-get-clarify-return-task.bru b/bruno/03-abort/05-get-clarify-return-task.bru index e29152f..b4a80d4 100644 --- a/bruno/03-abort/05-get-clarify-return-task.bru +++ b/bruno/03-abort/05-get-clarify-return-task.bru @@ -11,9 +11,10 @@ get { } script:pre-request { - // Compensation (event sub-process -> call activity -> requestCancellation) runs asynchronously - // before the cancelBikeOrder sub-process parks on the clarify-return task. - await new Promise((resolve) => setTimeout(resolve, 3000)); + const id = bru.getVar('applicationId'); + await pollEngine( + '/task?processInstanceBusinessKey=' + id + '&taskDefinitionKey=userTask_clarifyReturn', + (b) => Array.isArray(b) && b.length >= 1); } assert { diff --git a/bruno/03-abort/07-assert-cancelled.bru b/bruno/03-abort/07-assert-cancelled.bru index d038285..6ff8366 100644 --- a/bruno/03-abort/07-assert-cancelled.bru +++ b/bruno/03-abort/07-assert-cancelled.bru @@ -11,8 +11,10 @@ get { } script:pre-request { - // Completing the task drives the remaining compensation + confirmation asynchronously to the end. - await new Promise((resolve) => setTimeout(resolve, 3000)); + const pid = bru.getVar('processInstanceId'); + await pollEngine( + '/history/activity-instance?processInstanceId=' + pid + '&activityId=endEvent_applicationCancelled', + (b) => Array.isArray(b) && b.length >= 1); } assert { diff --git a/bruno/04-not-solvent/02-assert-rejected.bru b/bruno/04-not-solvent/02-assert-rejected.bru index b6f81ea..1dbfc7b 100644 --- a/bruno/04-not-solvent/02-assert-rejected.bru +++ b/bruno/04-not-solvent/02-assert-rejected.bru @@ -11,8 +11,8 @@ get { } script:pre-request { - // The whole validate -> DMN -> reject chain runs through the async job executor. - await new Promise((resolve) => setTimeout(resolve, 2500)); + const id = bru.getVar('applicationId'); + await pollApp('/api/bike-leasing/' + id, (b, s) => s === 200 && b && b.status === 'REJECTED'); } assert { diff --git a/bruno/05-bike-unavailable/03-sign-contract.bru b/bruno/05-bike-unavailable/03-sign-contract.bru index 679a677..5c661a5 100644 --- a/bruno/05-bike-unavailable/03-sign-contract.bru +++ b/bruno/05-bike-unavailable/03-sign-contract.bru @@ -11,8 +11,8 @@ post { } script:pre-request { - // Wait for the async job executor to drive the process to the contract-signature wait state. - await new Promise((resolve) => setTimeout(resolve, 2500)); + const id = bru.getVar('applicationId'); + await pollApp('/api/bike-leasing/' + id, (b, s) => s === 200 && b && b.contractId != null); } assert { diff --git a/bruno/05-bike-unavailable/04-clarify-alternative.bru b/bruno/05-bike-unavailable/04-clarify-alternative.bru index e35da88..3673c6f 100644 --- a/bruno/05-bike-unavailable/04-clarify-alternative.bru +++ b/bruno/05-bike-unavailable/04-clarify-alternative.bru @@ -11,8 +11,9 @@ post { } script:pre-request { - // Wait for the order to find the requested bike unavailable and park on the clarify-alternative user task. - await new Promise((resolve) => setTimeout(resolve, 3000)); + const id = bru.getVar('applicationId'); + await pollApp('/api/tasks/clarify-alternative', + (b, s) => s === 200 && Array.isArray(b) && b.some((t) => t.applicationId === id)); } body:json { diff --git a/bruno/05-bike-unavailable/05-report-handover.bru b/bruno/05-bike-unavailable/05-report-handover.bru index e472776..7ea61b4 100644 --- a/bruno/05-bike-unavailable/05-report-handover.bru +++ b/bruno/05-bike-unavailable/05-report-handover.bru @@ -11,8 +11,9 @@ post { } script:pre-request { - // Wait for the re-order of the alternative bike to succeed and the parallel branches to join. - await new Promise((resolve) => setTimeout(resolve, 3000)); + const id = bru.getVar('applicationId'); + await pollApp('/api/bike-leasing/' + id, + (b, s) => s === 200 && b && b.status === 'ORDERED' && b.orderId != null); } assert { diff --git a/bruno/05-bike-unavailable/06-get-withdrawal-timer.bru b/bruno/05-bike-unavailable/06-get-withdrawal-timer.bru index 93561e6..d149158 100644 --- a/bruno/05-bike-unavailable/06-get-withdrawal-timer.bru +++ b/bruno/05-bike-unavailable/06-get-withdrawal-timer.bru @@ -11,8 +11,10 @@ get { } script:pre-request { - // Wait for the handover message's async continuation to reach the withdrawal-period timer. - await new Promise((resolve) => setTimeout(resolve, 2500)); + const pid = bru.getVar('processInstanceId'); + await pollEngine( + '/job?processInstanceId=' + pid + '&activityId=event_withdrawalPeriodElapsed&timers=true', + (b) => Array.isArray(b) && b.length >= 1); } assert { diff --git a/bruno/05-bike-unavailable/08-assert-leasing-active.bru b/bruno/05-bike-unavailable/08-assert-leasing-active.bru index 5396c2b..85ff9c4 100644 --- a/bruno/05-bike-unavailable/08-assert-leasing-active.bru +++ b/bruno/05-bike-unavailable/08-assert-leasing-active.bru @@ -11,8 +11,10 @@ get { } script:pre-request { - // Wait for the withdrawal-period timer's continuation to reach the leasing-active end event. - await new Promise((resolve) => setTimeout(resolve, 1500)); + const pid = bru.getVar('processInstanceId'); + await pollEngine( + '/history/activity-instance?processInstanceId=' + pid + '&activityId=endEvent_leasingActive', + (b) => Array.isArray(b) && b.length >= 1); } assert { diff --git a/bruno/06-list-and-inbox/04-sign-contract.bru b/bruno/06-list-and-inbox/04-sign-contract.bru index 51d0ec5..8998384 100644 --- a/bruno/06-list-and-inbox/04-sign-contract.bru +++ b/bruno/06-list-and-inbox/04-sign-contract.bru @@ -11,8 +11,8 @@ post { } script:pre-request { - // Give the engine a moment to reach the "wait for signature" state after submission. - await new Promise((resolve) => setTimeout(resolve, 2000)); + const id = bru.getVar('applicationId'); + await pollApp('/api/bike-leasing/' + id, (b, s) => s === 200 && b && b.contractId != null); } assert { diff --git a/bruno/06-list-and-inbox/05-inbox-has-task.bru b/bruno/06-list-and-inbox/05-inbox-has-task.bru index e648961..cb892b5 100644 --- a/bruno/06-list-and-inbox/05-inbox-has-task.bru +++ b/bruno/06-list-and-inbox/05-inbox-has-task.bru @@ -10,8 +10,9 @@ get { } script:pre-request { - // Wait for the order to run, find BIKE-OOS unavailable, and park on the clarify-alternative task. - await new Promise((resolve) => setTimeout(resolve, 3000)); + const id = bru.getVar('applicationId'); + await pollApp('/api/tasks/clarify-alternative', + (b, s) => s === 200 && Array.isArray(b) && b.some((t) => t.applicationId === id)); } assert { diff --git a/bruno/06-list-and-inbox/07-inbox-empty.bru b/bruno/06-list-and-inbox/07-inbox-empty.bru index b19215e..3b81643 100644 --- a/bruno/06-list-and-inbox/07-inbox-empty.bru +++ b/bruno/06-list-and-inbox/07-inbox-empty.bru @@ -10,8 +10,9 @@ get { } script:pre-request { - // Wait for the clarification to be completed and the task to leave the inbox. - await new Promise((resolve) => setTimeout(resolve, 3000)); + const id = bru.getVar('applicationId'); + await pollApp('/api/tasks/clarify-alternative', + (b, s) => s === 200 && Array.isArray(b) && !b.some((t) => t.applicationId === id)); } assert { diff --git a/bruno/collection.bru b/bruno/collection.bru new file mode 100644 index 0000000..b9f9d0e --- /dev/null +++ b/bruno/collection.bru @@ -0,0 +1,47 @@ +script:pre-request { + // Shared eventual-consistency polling helpers (see ADR-0012). Budgets are + // env-driven: pollTimeoutMs / pollIntervalMs. + const axios = require('axios'); + + const timeoutDefault = Number(bru.getEnvVar('pollTimeoutMs') || 20000); + const intervalDefault = Number(bru.getEnvVar('pollIntervalMs') || 500); + + globalThis.pollUntil = async function (config, predicate, opts) { + opts = opts || {}; + const timeoutMs = Number(opts.timeoutMs != null ? opts.timeoutMs : timeoutDefault); + const intervalMs = Number(opts.intervalMs != null ? opts.intervalMs : intervalDefault); + const deadline = Date.now() + timeoutMs; + let attempts = 0; + let last; + for (;;) { + attempts++; + try { + last = await axios(Object.assign({ validateStatus: () => true }, config)); + if (predicate(last.data, last.status)) return last; + } catch (err) { + last = { status: 0, data: null, error: err.message }; + } + if (Date.now() >= deadline) { + const where = (config.method || 'get').toUpperCase() + ' ' + config.url; + console.log('pollUntil: condition not met after ' + attempts + ' attempt(s) / ' + + timeoutMs + 'ms (' + where + ', last status ' + (last && last.status) + ')'); + return last; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + }; + + // GET {{baseUrl}}{path} (the application read model) until predicate(data, status) holds. + globalThis.pollApp = function (path, predicate, opts) { + return globalThis.pollUntil( + { method: 'get', url: bru.getEnvVar('baseUrl') + path }, + predicate, opts); + }; + + // GET {{engineRest}}{path} (a CIB seven / Camunda 7 engine query) until predicate holds. + globalThis.pollEngine = function (path, predicate, opts) { + return globalThis.pollUntil( + { method: 'get', url: bru.getEnvVar('engineRest') + path }, + predicate, opts); + }; +} diff --git a/bruno/environments/local.bru b/bruno/environments/local.bru index 8f7a2a8..80a00f4 100644 --- a/bruno/environments/local.bru +++ b/bruno/environments/local.bru @@ -1,4 +1,6 @@ vars { baseUrl: http://localhost:8080 engineRest: http://localhost:8080/engine-rest + pollTimeoutMs: 20000 + pollIntervalMs: 500 } diff --git a/docs/README.md b/docs/README.md index bf0c23f..85674c0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,7 @@ copied from [`adr/0000-adr-template.md`](adr/0000-adr-template.md). Write a new | [0012](adr/0012-actuator-probes-and-prometheus-metrics.md) | Actuator health/liveness/readiness probes and Prometheus metrics, exposed out of the box. | | [0013](adr/0013-flyway-for-database-migrations.md) | Flyway for versioned schema migrations; Hibernate switches to `validate`. | | [0014](adr/0014-build-and-deployment-approach.md) | Build & deployment: `bootBuildImage` OCI image + nginx frontend + a one-command full-stack compose. | +| [0015](adr/0015-polling-for-eventual-consistency-in-e2e-tests.md) | Poll for eventual consistency in the Bruno e2e suite instead of fixed sleeps; env-tuned budgets, CLI pinned. | ## Diagrams diff --git a/docs/adr/0015-polling-for-eventual-consistency-in-e2e-tests.md b/docs/adr/0015-polling-for-eventual-consistency-in-e2e-tests.md new file mode 100644 index 0000000..ad08ed7 --- /dev/null +++ b/docs/adr/0015-polling-for-eventual-consistency-in-e2e-tests.md @@ -0,0 +1,80 @@ +# 0015 — Poll for eventual consistency in end-to-end tests + +- **Status:** Accepted +- **Date:** 2026-08-21 + +## Context + +Every command endpoint in this API is **asynchronous**. A command controller hands a message or a +task completion to the embedded CIB seven (Camunda 7) engine and returns **`202 Accepted`** +immediately — it does not wait for the token to reach the next wait state. The observable effect +lands some unbounded time later: the async job executor drives the process to its next wait state, +a delegate runs and writes the read model (`leasing_application`), and only then does a subsequent +read (`GET /api/bike-leasing/{id}`, or an engine query on `/engine-rest`) see it. The gap between +"the command returned" and "its effect is visible" is real, and it is **environment-dependent** — a +loaded CI runner is slower than a laptop. + +The Bruno end-to-end suite (`bruno/`) originally bridged that gap with **fixed sleeps** — 18 +hand-tuned `setTimeout`s from 1.5 s to 3 s. That is the classic flaky-test anti-pattern: a sleep +tuned to pass locally loses the race under CI load (the `07-inbox-empty` scenario did exactly this), +while the safe-side sleeps waste minutes on every run. The same async shape is inherent to **every +process blueprint** in this family, so the fix has to be a reusable pattern, not a per-test number. + +## Decision + +We assert eventual state by **polling until the real condition holds, capped by a generous timeout** — +never by sleeping a guessed duration. + +- **Shared helpers** live in `bruno/collection.bru` (a collection-level `script:pre-request`, so they + are in scope for every request): `pollUntil(config, predicate, opts)` and the convenience wrappers + `pollApp(path, predicate)` (GET the app read model) and `pollEngine(path, predicate)` (GET a CIB + seven engine query on `/engine-rest`). They return the instant the predicate is met and only wait + the full budget when something is genuinely wrong — at which point the request's own assertions + report the real, still-wrong state instead of a bare timeout. +- **Budgets are env-driven** (`pollTimeoutMs` / `pollIntervalMs` in the environment file), so a + sibling blueprint with different propagation characteristics tunes them **once**, in one place. A + scenario that needs a wider window (e.g. an incident-retry window) passes `{ timeoutMs }` at the + call site. +- **Each scenario polls for its own precondition or assertion**, mirroring the read model's + observable fields — the `status` enum (`RECEIVED → ORDERED → HANDED_OVER → ACTIVE`, plus + `WITHDRAWN`, `REJECTED`, `CANCELLED`) and delegate-set fields (`contractId`, `orderId`). Command + steps gate on the precondition that makes the command valid (e.g. `contractId != null` before + `sign-contract`; `status == "ORDERED" && orderId != null` before `report-handover`; the inbox + listing the item before completing the `clarify-alternative` user task). +- **Division of labour holds:** Bruno asserts the **synchronous request/response contract** (status + codes, DTO shape); genuinely engine-level, deterministic checks (timer fast-forward, full token + flow) stay in the JVM `@CamundaSpringProcessTest` layer with JGiven. See ADR-0004 for the test + layering. +- **The Bruno CLI is pinned** (`@usebruno/cli@4.0.0`): the script sandbox's capabilities (available + globals, the `require` whitelist the helpers depend on) can change between majors, so an unpinned + `latest` is a correctness risk, not just a supply-chain one. + +## Consequences + +- **Positive:** the suite is robust under CI load and *faster* in the common case — it waits exactly + as long as the engine needs. One env-tunable budget replaces 18 magic numbers, and the pattern + ports to every sibling blueprint. +- **Negative / trade-offs:** each polled step issues an extra read before the "official" request; a + predicate must be kept honest (it should mirror what the request asserts, or it silently waits out + the whole budget). Two read-model writes — `report-handover` and `withdraw` — set their status + (`HANDED_OVER` / `WITHDRAWN`) *before* the process advances, so tests that need proof the process + actually advanced assert the **downstream** delegate-driven state (`ACTIVE`, `CANCELLED`), not + those intermediate flips. +- **Neutral:** the "submit → poll an observable status" shape becomes the documented client contract + for these async APIs, for real consumers as much as for tests. + +## Implementation notes + +- The Bruno script sandbox exposes **no global `fetch`**; `require('axios')` works and is what the + helpers use. Helpers are attached to `globalThis` in the collection script so request-level scripts + can call them. +- Message commands correlate by `businessKey`/`applicationId` once the matching subscription is open, + so gating on the read-model precondition (rather than exact timing) is sufficient. + `clarify-alternative` is a user-task-backed completion, so its precondition gates on the inbox + (`/api/tasks/clarify-alternative`) listing the application. +- Engine-query steps (finding the process instance, a timer job, or a user task) `pollEngine` the + `/engine-rest` query until the resource is searchable, then the request re-issues it and captures + the id into a variable. +- **Deferred, not adopted:** a test-only endpoint exposing engine progress deterministically would be + the strongest guarantee, but it leaks engine internals into the API surface for test convenience. + Revisit only if predicate-based polling proves insufficient.